diff --git a/app/api/__init__.py b/app/api/__init__.py index ee56aa78..c72813e7 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -9,9 +9,12 @@ from fastapi import APIRouter from app.api.admin_users import router as admin_users_router from app.api.api_tokens import router as api_tokens_router from app.api.audit_logs import router as audit_logs_router +from app.api.automation import router as automation_router from app.api.azure import router as azure_router from app.api.backup import router as backup_router from app.api.billing import router as billing_router +from app.api.classification_rules import router as classification_rules_router +from app.api.comments import router as comments_router from app.api.compliance import router as compliance_router from app.api.database import router as database_router from app.api.diagnostic import router as diagnostic_router @@ -43,6 +46,7 @@ from app.api.sessions import router as sessions_router from app.api.settings import router as settings_router from app.api.shared_links import public_router as shared_links_public_router from app.api.shared_links import router as shared_links_router +from app.api.sharing import router as sharing_router from app.api.similarity import router as similarity_router from app.api.subscriptions import router as subscriptions_router from app.api.system_reset import router as system_reset_router @@ -104,3 +108,7 @@ router.include_router(qr_auth_router) router.include_router(compliance_router) router.include_router(system_reset_router) router.include_router(translation_router) +router.include_router(classification_rules_router) +router.include_router(automation_router) +router.include_router(comments_router) +router.include_router(sharing_router) diff --git a/app/api/api_tokens.py b/app/api/api_tokens.py index 1beef61b..62074b8a 100644 --- a/app/api/api_tokens.py +++ b/app/api/api_tokens.py @@ -13,7 +13,7 @@ plaintext is returned exactly once at creation time. import hashlib import logging import secrets -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException, Request, status @@ -105,6 +105,7 @@ def _token_to_dict(t: ApiToken) -> dict[str, Any]: "last_used_ip": t.last_used_ip, "created_at": t.created_at, "revoked_at": t.revoked_at, + "expires_at": t.expires_at, } @@ -117,6 +118,12 @@ class TokenCreate(BaseModel): """Schema for creating a new API token.""" name: str = Field(..., min_length=1, max_length=255, description="Human-readable label for the token") + expires_in_days: int | None = Field( + default=None, + ge=1, + le=3650, # Maximum 10 years; keeps tokens from being effectively permanent while allowing long-lived CI/CD tokens. + description="Optional lifetime in days. If omitted the token never expires.", + ) class TokenResponse(BaseModel): @@ -130,6 +137,7 @@ class TokenResponse(BaseModel): last_used_ip: str | None created_at: datetime | None revoked_at: datetime | None + expires_at: datetime | None model_config = {"from_attributes": True} @@ -160,11 +168,16 @@ async def create_token( token_hash_value = hash_token(plaintext) prefix = plaintext[:12] # "de_" prefix + 9 random chars = 12 chars total + expires_at = None + if body.expires_in_days is not None: + expires_at = datetime.now(timezone.utc) + timedelta(days=body.expires_in_days) + db_token = ApiToken( owner_id=owner_id, name=body.name, token_hash=token_hash_value, token_prefix=prefix, + expires_at=expires_at, ) try: db.add(db_token) @@ -185,6 +198,7 @@ async def create_token( "last_used_ip": db_token.last_used_ip, "created_at": db_token.created_at, "revoked_at": db_token.revoked_at, + "expires_at": db_token.expires_at, "token": plaintext, } @@ -235,30 +249,73 @@ async def list_mobile_tokens( @router.delete("/{token_id}", status_code=status.HTTP_200_OK) -async def revoke_token( +async def revoke_or_delete_token( token_id: int, owner_id: CurrentOwner, db: DbSession, ) -> dict[str, str]: - """Revoke (soft-delete) an API token. + """Revoke or permanently delete an API token. - The token row is kept for audit purposes but marked inactive with a - ``revoked_at`` timestamp. + * **Active token** – soft-revoked: the row is kept for audit purposes + but marked inactive with a ``revoked_at`` timestamp. + * **Already-revoked token** – hard-deleted: the row is permanently + removed from the database. """ db_token = db.query(ApiToken).filter(ApiToken.id == token_id, ApiToken.owner_id == owner_id).first() if not db_token: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Token not found") - if not db_token.is_active: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Token is already revoked") + if db_token.is_active: + # Soft-revoke the active token. + try: + db_token.is_active = False + db_token.revoked_at = datetime.now(timezone.utc) + db.commit() + except Exception: + db.rollback() + raise + logger.info("API token revoked: id=%s owner=%s", token_id, owner_id) + return {"detail": "Token revoked"} + # Hard-delete an already-revoked token. try: - db_token.is_active = False - db_token.revoked_at = datetime.now(timezone.utc) + db.delete(db_token) db.commit() except Exception: db.rollback() raise + logger.info("API token permanently deleted: id=%s owner=%s", token_id, owner_id) + return {"detail": "Token deleted"} - logger.info("API token revoked: id=%s owner=%s", token_id, owner_id) - return {"detail": "Token revoked"} + +@router.post("/{token_id}/reactivate", status_code=status.HTTP_200_OK, response_model=TokenResponse) +async def reactivate_token( + token_id: int, + owner_id: CurrentOwner, + db: DbSession, +) -> dict[str, Any]: + """Reactivate a previously revoked API token. + + Clears the ``revoked_at`` timestamp and sets ``is_active`` back to + ``True``. The token can be used for authentication again immediately. + If the token had an ``expires_at`` in the past the caller should + consider re-creating a new token instead. + """ + db_token = db.query(ApiToken).filter(ApiToken.id == token_id, ApiToken.owner_id == owner_id).first() + if not db_token: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Token not found") + + if db_token.is_active: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Token is already active") + + try: + db_token.is_active = True + db_token.revoked_at = None + db.commit() + db.refresh(db_token) + except Exception: + db.rollback() + raise + + logger.info("API token reactivated: id=%s owner=%s", token_id, owner_id) + return _token_to_dict(db_token) diff --git a/app/api/automation.py b/app/api/automation.py new file mode 100644 index 00000000..ed81a343 --- /dev/null +++ b/app/api/automation.py @@ -0,0 +1,311 @@ +"""API endpoints for Zapier / Make.com automation integration. + +Provides a REST hooks subscription interface for outgoing triggers and +incoming action endpoints that external automation platforms can call. + +Outgoing triggers: + External platforms subscribe to DocuElevate events via + ``POST /api/automation/hooks/subscribe``. When a subscribed event + fires, DocuElevate POSTs a flat Zapier-compatible JSON payload to the + registered ``target_url``. + +Incoming actions: + ``POST /api/automation/actions/upload`` allows automation platforms to + push documents into DocuElevate for processing. + +Authentication: + All endpoints require a valid API token via ``Authorization: Bearer`` + header. +""" + +import json +import logging +import os +import tempfile +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.config import settings +from app.database import get_db +from app.models import AutomationHook +from app.utils.automation_hooks import SAMPLE_PAYLOADS +from app.utils.webhook import VALID_EVENTS + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/automation", tags=["automation"]) + +DbSession = Annotated[Session, Depends(get_db)] + + +# --------------------------------------------------------------------------- +# Auth helper – require a valid API token (Bearer) +# --------------------------------------------------------------------------- + + +def _require_api_user(request: Request) -> dict: + """Ensure the caller is authenticated via session or API token. + + Raises: + HTTPException: 401 if not authenticated, 403 if automation hooks are disabled. + """ + if not settings.automation_hooks_enabled: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Automation hooks are disabled", + ) + + # Check for API-token user first (set by auth middleware) + user = getattr(request.state, "api_token_user", None) + if user: + return user + + # Fall back to session user + user = request.session.get("user") + if user: + return user + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required (Bearer token or session)", + ) + + +AuthUser = Annotated[dict, Depends(_require_api_user)] + + +# --------------------------------------------------------------------------- +# Pydantic schemas +# --------------------------------------------------------------------------- + + +class HookSubscribe(BaseModel): + """Schema for subscribing to automation hook events.""" + + target_url: str = Field(..., min_length=1, max_length=2048, description="URL to POST event payloads to") + events: list[str] = Field(..., min_length=1, description="Event types to subscribe to") + secret: str | None = Field(default=None, max_length=512, description="Optional HMAC-SHA256 signing secret") + hook_type: str = Field( + default="generic", + max_length=50, + description="Platform identifier (zapier, make, generic)", + ) + description: str | None = Field(default=None, max_length=500, description="Optional human-readable label") + + +class HookResponse(BaseModel): + """Schema returned when listing or creating hooks.""" + + id: int + target_url: str + events: list[str] + is_active: bool + hook_type: str + description: str | None + has_secret: bool + + model_config = {"from_attributes": True} + + +class ActionUploadResponse(BaseModel): + """Response after an automation action uploads a document.""" + + status: str + filename: str + task_id: str | None = None + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _validate_events(events: list[str]) -> None: + """Raise 422 if any event name is not recognised.""" + invalid = set(events) - VALID_EVENTS + if invalid: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid event(s): {', '.join(sorted(invalid))}. Valid: {', '.join(sorted(VALID_EVENTS))}", + ) + + +def _hook_to_response(hook: AutomationHook) -> dict[str, Any]: + """Convert a DB model instance to a response dict.""" + try: + events = json.loads(hook.events) + except (json.JSONDecodeError, TypeError): + events = [] + return { + "id": hook.id, + "target_url": hook.target_url, + "events": events, + "is_active": hook.is_active, + "hook_type": hook.hook_type, + "description": hook.description, + "has_secret": hook.secret is not None and len(hook.secret) > 0, + } + + +# --------------------------------------------------------------------------- +# Outgoing triggers – REST hooks subscription endpoints +# --------------------------------------------------------------------------- + + +@router.post( + "/hooks/subscribe", + status_code=status.HTTP_201_CREATED, + summary="Subscribe to automation events (REST hooks)", +) +def subscribe_hook(body: HookSubscribe, db: DbSession, user: AuthUser) -> dict[str, Any]: + """Register a new automation hook subscription. + + Zapier and Make.com call this endpoint to subscribe to DocuElevate + events. When an event fires, a flat JSON payload is POSTed to + ``target_url``. + """ + _validate_events(body.events) + + hook = AutomationHook( + target_url=body.target_url, + secret=body.secret, + events=json.dumps(sorted(body.events)), + is_active=True, + hook_type=body.hook_type or "generic", + description=body.description, + ) + try: + db.add(hook) + db.commit() + db.refresh(hook) + except Exception: + db.rollback() + raise + + logger.info("Automation hook %d created (type=%s) for events %s", hook.id, hook.hook_type, body.events) + return _hook_to_response(hook) + + +@router.delete( + "/hooks/{hook_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Unsubscribe an automation hook", +) +def unsubscribe_hook(hook_id: int, db: DbSession, user: AuthUser) -> None: + """Remove an automation hook subscription. + + Zapier calls this endpoint when a Zap is turned off or deleted. + """ + hook = db.query(AutomationHook).filter(AutomationHook.id == hook_id).first() + if not hook: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hook not found") + + try: + db.delete(hook) + db.commit() + except Exception: + db.rollback() + raise + + logger.info("Automation hook %d deleted", hook_id) + + +@router.get("/hooks", summary="List automation hook subscriptions") +def list_hooks(db: DbSession, user: AuthUser) -> list[dict[str, Any]]: + """Return all active automation hook subscriptions.""" + hooks = db.query(AutomationHook).order_by(AutomationHook.id).all() + return [_hook_to_response(h) for h in hooks] + + +# --------------------------------------------------------------------------- +# Outgoing triggers – sample data for Zapier field mapping +# --------------------------------------------------------------------------- + + +@router.get("/triggers/sample/{event}", summary="Get sample trigger data") +def get_trigger_sample(event: str, user: AuthUser) -> list[dict[str, Any]]: + """Return sample payload data for the given event type. + + Zapier uses this during Zap setup to discover available fields and + provide a mapping interface. The response is wrapped in an array + as Zapier expects. + """ + if event not in VALID_EVENTS: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Unknown event: {event}. Valid: {', '.join(sorted(VALID_EVENTS))}", + ) + + sample = SAMPLE_PAYLOADS.get(event, {"id": "evt_sample", "event": event, "timestamp": 0}) + return [sample] + + +# --------------------------------------------------------------------------- +# Outgoing triggers – list valid events +# --------------------------------------------------------------------------- + + +@router.get("/events", summary="List valid automation event types") +def list_events(user: AuthUser) -> list[str]: + """Return the list of valid event types that automation hooks can subscribe to.""" + return sorted(VALID_EVENTS) + + +# --------------------------------------------------------------------------- +# Incoming actions – endpoints that Zapier / Make.com can call +# --------------------------------------------------------------------------- + + +@router.post("/actions/upload", summary="Upload a document (incoming action)") +def action_upload( + request: Request, + db: DbSession, + user: AuthUser, + file: UploadFile = File(...), +) -> dict[str, Any]: + """Accept a document upload from an automation platform. + + This endpoint allows Zapier or Make.com to push a document into + DocuElevate for processing. The file is saved to the work directory + and a background processing task is queued. + """ + if not file.filename: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Filename is required") + + # Sanitise filename to prevent path traversal attacks + safe_filename = os.path.basename(file.filename) + if not safe_filename: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Filename is required") + + owner_id = user.get("preferred_username") or user.get("email") or user.get("id", "automation") + workdir = settings.workdir or tempfile.gettempdir() + upload_dir = os.path.join(workdir, "uploads") + os.makedirs(upload_dir, exist_ok=True) + + dest_path = os.path.join(upload_dir, safe_filename) + try: + contents = file.file.read() + with open(dest_path, "wb") as f: + f.write(contents) + except Exception as exc: + logger.error("Failed to save uploaded file: %s", exc) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to save file") + + # Queue background processing + task_id = None + try: + from app.tasks.process_document import process_document + + result = process_document.delay(dest_path, owner_id) + task_id = result.id + logger.info("Automation upload queued: file=%s, task=%s, owner=%s", safe_filename, task_id, owner_id) + except Exception as exc: + logger.warning("Could not queue processing task (Celery may be unavailable): %s", exc) + + return { + "status": "accepted", + "filename": safe_filename, + "task_id": task_id, + } diff --git a/app/api/classification_rules.py b/app/api/classification_rules.py new file mode 100644 index 00000000..594ade24 --- /dev/null +++ b/app/api/classification_rules.py @@ -0,0 +1,325 @@ +"""Classification Rules API endpoints. + +Provides CRUD operations for managing custom document classification rules. +System-wide rules (``owner_id IS NULL``) can only be managed by admins. +""" + +from __future__ import annotations + +import logging +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.auth import require_login +from app.database import get_db +from app.models import ClassificationRuleModel +from app.utils.classification_rules import ( + BUILTIN_CATEGORIES, + RULE_TYPE_CONTENT, + RULE_TYPE_FILENAME, + RULE_TYPE_METADATA, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/classification-rules", tags=["classification"]) + +DbSession = Annotated[Session, Depends(get_db)] + +_VALID_RULE_TYPES = {RULE_TYPE_FILENAME, RULE_TYPE_CONTENT, RULE_TYPE_METADATA} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _get_user_id(request: Request) -> str: + """Extract the user identifier from the request session.""" + user = getattr(request.state, "user", None) + if user and hasattr(user, "get"): + return user.get("sub") or user.get("email") or "anonymous" + return "anonymous" + + +def _is_admin(request: Request) -> bool: + """Check whether the current user is an admin.""" + user = getattr(request.state, "user", None) + if user and hasattr(user, "get"): + groups = user.get("groups", []) + return "admin" in groups or "Admin" in groups + return False + + +# --------------------------------------------------------------------------- +# Pydantic schemas +# --------------------------------------------------------------------------- + + +class RuleCreate(BaseModel): + """Schema for creating a classification rule.""" + + name: str = Field(..., min_length=1, max_length=255) + category: str = Field(..., min_length=1, max_length=100) + rule_type: str = Field(..., description="One of: filename_pattern, content_keyword, metadata_match") + pattern: str = Field(..., min_length=1, max_length=1000) + priority: int = Field(default=0, ge=0, le=1000) + case_sensitive: bool = False + enabled: bool = True + + +class RuleUpdate(BaseModel): + """Schema for updating a classification rule.""" + + name: str | None = Field(default=None, min_length=1, max_length=255) + category: str | None = Field(default=None, min_length=1, max_length=100) + rule_type: str | None = Field(default=None) + pattern: str | None = Field(default=None, min_length=1, max_length=1000) + priority: int | None = Field(default=None, ge=0, le=1000) + case_sensitive: bool | None = None + enabled: bool | None = None + + +class RuleResponse(BaseModel): + """Schema for a classification rule response.""" + + id: int + owner_id: str | None + name: str + category: str + rule_type: str + pattern: str + priority: int + case_sensitive: bool + enabled: bool + + model_config = {"from_attributes": True} + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get("/categories") +@require_login +async def list_categories(request: Request) -> dict[str, str]: + """Return all built-in classification categories. + + Custom categories created via rules are not included here; they are + discovered dynamically when rules are evaluated. + """ + return BUILTIN_CATEGORIES + + +@router.get("/rule-types") +@require_login +async def list_rule_types(request: Request) -> list[dict[str, str]]: + """Return the supported rule types with descriptions.""" + return [ + { + "type": RULE_TYPE_FILENAME, + "label": "Filename Pattern", + "description": "Regex pattern matched against the original filename.", + }, + { + "type": RULE_TYPE_CONTENT, + "label": "Content Keyword", + "description": "Pipe-separated keywords matched against the OCR text.", + }, + { + "type": RULE_TYPE_METADATA, + "label": "Metadata Match", + "description": "field=value pattern matched against existing AI metadata.", + }, + ] + + +@router.get("/") +@require_login +async def list_rules(request: Request, db: DbSession) -> list[dict[str, Any]]: + """List classification rules visible to the current user. + + Returns both system rules (``owner_id IS NULL``) and the user's own rules. + """ + user_id = _get_user_id(request) + rules = ( + db.query(ClassificationRuleModel) + .filter((ClassificationRuleModel.owner_id.is_(None)) | (ClassificationRuleModel.owner_id == user_id)) + .order_by(ClassificationRuleModel.priority.desc(), ClassificationRuleModel.id) + .all() + ) + return [ + { + "id": r.id, + "owner_id": r.owner_id, + "name": r.name, + "category": r.category, + "rule_type": r.rule_type, + "pattern": r.pattern, + "priority": r.priority, + "case_sensitive": r.case_sensitive, + "enabled": r.enabled, + } + for r in rules + ] + + +@router.post("/", status_code=status.HTTP_201_CREATED) +@require_login +async def create_rule(request: Request, body: RuleCreate, db: DbSession) -> dict[str, Any]: + """Create a new custom classification rule. + + The rule is owned by the current user. Admins may create system-wide + rules by setting ``owner_id`` to ``null`` (not yet exposed). + """ + if body.rule_type not in _VALID_RULE_TYPES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid rule_type. Must be one of: {', '.join(sorted(_VALID_RULE_TYPES))}", + ) + + user_id = _get_user_id(request) + + # Check for duplicate name within the user's scope + existing = ( + db.query(ClassificationRuleModel) + .filter(ClassificationRuleModel.owner_id == user_id, ClassificationRuleModel.name == body.name) + .first() + ) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"A rule named '{body.name}' already exists.", + ) + + rule = ClassificationRuleModel( + owner_id=user_id, + name=body.name, + category=body.category, + rule_type=body.rule_type, + pattern=body.pattern, + priority=body.priority, + case_sensitive=body.case_sensitive, + enabled=body.enabled, + ) + try: + db.add(rule) + db.commit() + db.refresh(rule) + except Exception: + db.rollback() + raise + + logger.info("Classification rule created: id=%s, user=%s", rule.id, user_id) + return { + "id": rule.id, + "owner_id": rule.owner_id, + "name": rule.name, + "category": rule.category, + "rule_type": rule.rule_type, + "pattern": rule.pattern, + "priority": rule.priority, + "case_sensitive": rule.case_sensitive, + "enabled": rule.enabled, + } + + +@router.get("/{rule_id}") +@require_login +async def get_rule(request: Request, rule_id: int, db: DbSession) -> dict[str, Any]: + """Get a single classification rule by ID.""" + user_id = _get_user_id(request) + rule = db.query(ClassificationRuleModel).filter(ClassificationRuleModel.id == rule_id).first() + if rule is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rule not found") + + # Users can see system rules and their own rules + if rule.owner_id is not None and rule.owner_id != user_id and not _is_admin(request): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rule not found") + + return { + "id": rule.id, + "owner_id": rule.owner_id, + "name": rule.name, + "category": rule.category, + "rule_type": rule.rule_type, + "pattern": rule.pattern, + "priority": rule.priority, + "case_sensitive": rule.case_sensitive, + "enabled": rule.enabled, + } + + +@router.put("/{rule_id}") +@require_login +async def update_rule(request: Request, rule_id: int, body: RuleUpdate, db: DbSession) -> dict[str, Any]: + """Update an existing classification rule. + + Users can only update their own rules. Admins can update any rule. + """ + user_id = _get_user_id(request) + rule = db.query(ClassificationRuleModel).filter(ClassificationRuleModel.id == rule_id).first() + if rule is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rule not found") + + if rule.owner_id != user_id and not _is_admin(request): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot modify this rule") + + if body.rule_type is not None and body.rule_type not in _VALID_RULE_TYPES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid rule_type. Must be one of: {', '.join(sorted(_VALID_RULE_TYPES))}", + ) + + update_data = body.model_dump(exclude_unset=True) + for field_name, value in update_data.items(): + setattr(rule, field_name, value) + + try: + db.commit() + db.refresh(rule) + except Exception: + db.rollback() + raise + + logger.info("Classification rule updated: id=%s, user=%s", rule.id, user_id) + return { + "id": rule.id, + "owner_id": rule.owner_id, + "name": rule.name, + "category": rule.category, + "rule_type": rule.rule_type, + "pattern": rule.pattern, + "priority": rule.priority, + "case_sensitive": rule.case_sensitive, + "enabled": rule.enabled, + } + + +@router.delete("/{rule_id}", status_code=status.HTTP_204_NO_CONTENT) +@require_login +async def delete_rule(request: Request, rule_id: int, db: DbSession) -> None: + """Delete a classification rule. + + Users can only delete their own rules. Admins can delete any rule. + """ + user_id = _get_user_id(request) + rule = db.query(ClassificationRuleModel).filter(ClassificationRuleModel.id == rule_id).first() + if rule is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rule not found") + + if rule.owner_id != user_id and not _is_admin(request): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot delete this rule") + + try: + db.delete(rule) + db.commit() + except Exception: + db.rollback() + raise + + logger.info("Classification rule deleted: id=%s, user=%s", rule_id, user_id) diff --git a/app/api/comments.py b/app/api/comments.py new file mode 100644 index 00000000..e001e962 --- /dev/null +++ b/app/api/comments.py @@ -0,0 +1,751 @@ +"""Document comments and annotations API endpoints. + +Provides CRUD operations for threaded comments on documents, +text annotations on PDF pages, and a list of mentionable users +for the @mention feature. +""" + +import json +import logging +import re +from typing import Annotated, Any + +from fastapi import APIRouter, Body, Depends, HTTPException, Request, status +from sqlalchemy.orm import Session + +from app.auth import get_current_user_id, require_login +from app.database import get_db +from app.models import ( + FILE_SHARE_ROLE_VIEWER, + DocumentAnnotation, + DocumentComment, + FileRecord, + FileShare, + UserProfile, +) +from app.utils.user_scope import get_current_owner_id, has_file_role + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["comments"]) + +DbSession = Annotated[Session, Depends(get_db)] + +# Constraints +MAX_COMMENT_BODY_LENGTH = 10_000 +MAX_ANNOTATION_CONTENT_LENGTH = 5_000 + +# Allowed annotation types +ALLOWED_ANNOTATION_TYPES = frozenset({"note", "highlight", "underline", "strikethrough"}) + +# Simple pattern for @mentions – matches @username tokens inside comment body +_MENTION_PATTERN = re.compile(r"@([\w.\-]+)") + + +def _extract_mentions(body: str) -> list[str]: + """Extract unique @mentioned usernames from a comment body. + + Args: + body: The raw comment text. + + Returns: + A deduplicated list of mentioned usernames (without the ``@`` prefix). + """ + return list(dict.fromkeys(_MENTION_PATTERN.findall(body))) + + +def _serialize_comment(c: DocumentComment) -> dict[str, Any]: + """Serialize a DocumentComment to a JSON-friendly dict. + + Args: + c: The comment model instance. + + Returns: + A dictionary representation of the comment. + """ + mentions: list[str] = [] + if c.mentions: + try: + mentions = json.loads(c.mentions) + except (json.JSONDecodeError, TypeError): + pass + return { + "id": c.id, + "file_id": c.file_id, + "user_id": c.user_id, + "parent_id": c.parent_id, + "body": c.body, + "mentions": mentions, + "is_resolved": c.is_resolved, + "created_at": c.created_at.isoformat() if c.created_at else None, + "updated_at": c.updated_at.isoformat() if c.updated_at else None, + } + + +def _serialize_annotation(a: DocumentAnnotation) -> dict[str, Any]: + """Serialize a DocumentAnnotation to a JSON-friendly dict. + + Args: + a: The annotation model instance. + + Returns: + A dictionary representation of the annotation. + """ + return { + "id": a.id, + "file_id": a.file_id, + "user_id": a.user_id, + "page": a.page, + "x": a.x, + "y": a.y, + "width": a.width, + "height": a.height, + "content": a.content, + "annotation_type": a.annotation_type, + "color": a.color, + "created_at": a.created_at.isoformat() if a.created_at else None, + "updated_at": a.updated_at.isoformat() if a.updated_at else None, + } + + +def _build_thread_tree(comments: list[DocumentComment]) -> list[dict[str, Any]]: + """Organize a flat list of comments into a threaded tree structure. + + Top-level comments (``parent_id is None``) appear as root nodes. + Replies are nested inside their parent's ``replies`` list. + + Args: + comments: All comments for a given document, ordered by ``created_at``. + + Returns: + A list of root-level comment dicts, each with a ``replies`` key. + """ + by_id: dict[int, dict[str, Any]] = {} + roots: list[dict[str, Any]] = [] + + for c in comments: + node = _serialize_comment(c) + node["replies"] = [] + by_id[c.id] = node + + for c in comments: + node = by_id[c.id] + if c.parent_id and c.parent_id in by_id: + by_id[c.parent_id]["replies"].append(node) + else: + roots.append(node) + + return roots + + +# --------------------------------------------------------------------------- +# Comments endpoints +# --------------------------------------------------------------------------- + + +@router.get("/files/{file_id}/comments") +@require_login +def list_comments(request: Request, file_id: int, db: DbSession): + """List all comments for a document, organized into threads. + + Returns a threaded tree where top-level comments contain nested + ``replies``. Requires at least viewer access. + + Path Parameters: + file_id: The ID of the document. + + Returns: + A dict with ``file_id``, ``comments`` (threaded), and ``total``. + """ + user_id = get_current_owner_id(request) + user = request.session.get("user") + is_admin = isinstance(user, dict) and bool(user.get("is_admin")) + + file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + if not file_record: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + if not is_admin and not has_file_role(file_record, user_id, db, minimum_role=FILE_SHARE_ROLE_VIEWER): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + comments = ( + db.query(DocumentComment).filter(DocumentComment.file_id == file_id).order_by(DocumentComment.created_at).all() + ) + + return { + "file_id": file_id, + "comments": _build_thread_tree(comments), + "total": len(comments), + } + + +@router.post("/files/{file_id}/comments", status_code=status.HTTP_201_CREATED) +@require_login +def create_comment( + request: Request, + file_id: int, + db: DbSession, + body: str = Body(..., embed=True), + parent_id: int | None = Body(None, embed=True), +): + """Create a new comment on a document. + + Automatically extracts @mentions from the comment body and stores + them for later notification or UI highlighting. When multi-user + mode is enabled, any mentioned user that does not already have + access to the document is automatically granted ``viewer`` access by + the file owner so they can read the file and continue the discussion. + + Path Parameters: + file_id: The ID of the document to comment on. + + Request body (JSON): + body: Comment text (required, max 10 000 characters). + parent_id: ID of the parent comment for threaded replies (optional). + + Returns: + The created comment object. + """ + user_id = get_current_user_id(request) + owner_id = get_current_owner_id(request) + user = request.session.get("user") + is_admin = isinstance(user, dict) and bool(user.get("is_admin")) + + file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + if not file_record: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + if not is_admin and not has_file_role(file_record, owner_id, db, minimum_role=FILE_SHARE_ROLE_VIEWER): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + if not isinstance(body, str) or not body.strip(): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="body is required and must be non-empty", + ) + body = body.strip() + if len(body) > MAX_COMMENT_BODY_LENGTH: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"body must be at most {MAX_COMMENT_BODY_LENGTH} characters", + ) + + if parent_id is not None: + parent = ( + db.query(DocumentComment) + .filter(DocumentComment.id == parent_id, DocumentComment.file_id == file_id) + .first() + ) + if not parent: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Parent comment not found", + ) + + mentions = _extract_mentions(body) + + comment = DocumentComment( + file_id=file_id, + user_id=user_id, + parent_id=parent_id, + body=body, + mentions=json.dumps(mentions) if mentions else None, + ) + + try: + db.add(comment) + db.flush() # write comment so we can get its id before committing + + # Auto-share the file with mentioned users that don't have access yet. + # Only do this in multi-user mode and only when the file has an owner + # (unowned files are already visible to all authenticated users). + if mentions and file_record.owner_id is not None: + from app.config import settings as _settings + + if _settings.multi_user_enabled: + for mentioned_user in mentions: + # Skip the file owner (already has full access) and the commenter + # themselves (they already have access to be posting a comment). + if mentioned_user in {file_record.owner_id, owner_id}: + continue + existing_share = ( + db.query(FileShare) + .filter( + FileShare.file_id == file_id, + FileShare.shared_with_user_id == mentioned_user, + ) + .first() + ) + if not existing_share: + auto_share = FileShare( + file_id=file_id, + owner_id=file_record.owner_id, + shared_with_user_id=mentioned_user, + role=FILE_SHARE_ROLE_VIEWER, + ) + db.add(auto_share) + logger.info( + "Auto-shared file_id=%s with mentioned user=%s as viewer", + file_id, + mentioned_user, + ) + + db.commit() + db.refresh(comment) + except HTTPException: + raise + except Exception: + db.rollback() + logger.exception("Failed to create comment on file_id=%s", file_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create comment", + ) + + logger.info("Comment created: id=%s, file_id=%s, user=%s", comment.id, file_id, user_id) + return _serialize_comment(comment) + + +@router.put("/files/{file_id}/comments/{comment_id}") +@require_login +def update_comment( + request: Request, + file_id: int, + comment_id: int, + db: DbSession, + body: str = Body(..., embed=True), +): + """Update the body of an existing comment. + + Only the comment author may update the comment. Mentions are + re-extracted from the updated body. + + Path Parameters: + file_id: The ID of the document. + comment_id: The ID of the comment to update. + + Request body (JSON): + body: New comment text (required). + + Returns: + The updated comment object. + """ + user_id = get_current_user_id(request) + + comment = ( + db.query(DocumentComment).filter(DocumentComment.id == comment_id, DocumentComment.file_id == file_id).first() + ) + if not comment: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found") + + if comment.user_id != user_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only edit your own comments") + + if not isinstance(body, str) or not body.strip(): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="body is required and must be non-empty", + ) + body = body.strip() + if len(body) > MAX_COMMENT_BODY_LENGTH: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"body must be at most {MAX_COMMENT_BODY_LENGTH} characters", + ) + + mentions = _extract_mentions(body) + comment.body = body + comment.mentions = json.dumps(mentions) if mentions else None + + try: + db.commit() + db.refresh(comment) + except Exception: + db.rollback() + logger.exception("Failed to update comment id=%s", comment_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update comment", + ) + + logger.info("Comment updated: id=%s, user=%s", comment_id, user_id) + return _serialize_comment(comment) + + +@router.delete("/files/{file_id}/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT) +@require_login +def delete_comment(request: Request, file_id: int, comment_id: int, db: DbSession): + """Delete a comment. + + Only the comment author may delete the comment. Replies to the + deleted comment are **not** removed — they become orphaned root + comments so that conversation context is preserved. + + Path Parameters: + file_id: The ID of the document. + comment_id: The ID of the comment to delete. + """ + user_id = get_current_user_id(request) + + comment = ( + db.query(DocumentComment).filter(DocumentComment.id == comment_id, DocumentComment.file_id == file_id).first() + ) + if not comment: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found") + + if comment.user_id != user_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only delete your own comments") + + try: + db.delete(comment) + db.commit() + except Exception: + db.rollback() + logger.exception("Failed to delete comment id=%s", comment_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to delete comment", + ) + + logger.info("Comment deleted: id=%s, user=%s", comment_id, user_id) + + +@router.patch("/files/{file_id}/comments/{comment_id}/resolve") +@require_login +def resolve_comment( + request: Request, + file_id: int, + comment_id: int, + db: DbSession, + is_resolved: bool = Body(..., embed=True), +): + """Mark a top-level comment thread as resolved or unresolved. + + Path Parameters: + file_id: The ID of the document. + comment_id: The ID of the comment to resolve / unresolve. + + Request body (JSON): + is_resolved: ``true`` to resolve, ``false`` to unresolve. + + Returns: + The updated comment object. + """ + comment = ( + db.query(DocumentComment).filter(DocumentComment.id == comment_id, DocumentComment.file_id == file_id).first() + ) + if not comment: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found") + + comment.is_resolved = is_resolved + + try: + db.commit() + db.refresh(comment) + except Exception: + db.rollback() + logger.exception("Failed to resolve comment id=%s", comment_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update comment", + ) + + logger.info("Comment %s: id=%s", "resolved" if is_resolved else "unresolved", comment_id) + return _serialize_comment(comment) + + +# --------------------------------------------------------------------------- +# Annotations endpoints +# --------------------------------------------------------------------------- + + +@router.get("/files/{file_id}/annotations") +@require_login +def list_annotations(request: Request, file_id: int, db: DbSession): + """List all annotations for a document. + + Requires at least viewer access. + + Path Parameters: + file_id: The ID of the document. + + Returns: + A dict with ``file_id``, ``annotations``, and ``total``. + """ + user_id = get_current_owner_id(request) + user = request.session.get("user") + is_admin = isinstance(user, dict) and bool(user.get("is_admin")) + + file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + if not file_record: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + if not is_admin and not has_file_role(file_record, user_id, db, minimum_role=FILE_SHARE_ROLE_VIEWER): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + annotations = ( + db.query(DocumentAnnotation) + .filter(DocumentAnnotation.file_id == file_id) + .order_by(DocumentAnnotation.page, DocumentAnnotation.created_at) + .all() + ) + + return { + "file_id": file_id, + "annotations": [_serialize_annotation(a) for a in annotations], + "total": len(annotations), + } + + +@router.post("/files/{file_id}/annotations", status_code=status.HTTP_201_CREATED) +@require_login +def create_annotation( + request: Request, + file_id: int, + db: DbSession, + page: int = Body(..., embed=True), + x: float = Body(..., embed=True), + y: float = Body(..., embed=True), + content: str = Body(..., embed=True), + width: float = Body(0, embed=True), + height: float = Body(0, embed=True), + annotation_type: str = Body("note", embed=True), + color: str | None = Body(None, embed=True), +): + """Create a new annotation on a PDF page. + + Path Parameters: + file_id: The ID of the document. + + Request body (JSON): + page: Page number (1-based, required). + x: Horizontal position on the page (required). + y: Vertical position on the page (required). + content: Annotation text (required, max 5 000 characters). + width: Width of the annotation bounding box (default 0). + height: Height of the annotation bounding box (default 0). + annotation_type: One of ``note``, ``highlight``, ``underline``, + ``strikethrough`` (default ``note``). + color: Optional CSS colour string (e.g. ``#ff0000``). + + Returns: + The created annotation object. + """ + user_id = get_current_user_id(request) + owner_id = get_current_owner_id(request) + user = request.session.get("user") + is_admin = isinstance(user, dict) and bool(user.get("is_admin")) + + file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + if not file_record: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + if not is_admin and not has_file_role(file_record, owner_id, db, minimum_role=FILE_SHARE_ROLE_VIEWER): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + if not isinstance(content, str) or not content.strip(): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="content is required and must be non-empty", + ) + content = content.strip() + if len(content) > MAX_ANNOTATION_CONTENT_LENGTH: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"content must be at most {MAX_ANNOTATION_CONTENT_LENGTH} characters", + ) + + if page < 1: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="page must be >= 1", + ) + + if annotation_type not in ALLOWED_ANNOTATION_TYPES: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"annotation_type must be one of: {', '.join(sorted(ALLOWED_ANNOTATION_TYPES))}", + ) + + annotation = DocumentAnnotation( + file_id=file_id, + user_id=user_id, + page=page, + x=x, + y=y, + width=width, + height=height, + content=content, + annotation_type=annotation_type, + color=color, + ) + + try: + db.add(annotation) + db.commit() + db.refresh(annotation) + except Exception: + db.rollback() + logger.exception("Failed to create annotation on file_id=%s", file_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create annotation", + ) + + logger.info("Annotation created: id=%s, file_id=%s, user=%s", annotation.id, file_id, user_id) + return _serialize_annotation(annotation) + + +@router.put("/files/{file_id}/annotations/{annotation_id}") +@require_login +def update_annotation( + request: Request, + file_id: int, + annotation_id: int, + db: DbSession, + content: str | None = Body(None, embed=True), + x: float | None = Body(None, embed=True), + y: float | None = Body(None, embed=True), + width: float | None = Body(None, embed=True), + height: float | None = Body(None, embed=True), + annotation_type: str | None = Body(None, embed=True), + color: str | None = Body(None, embed=True), +): + """Update an existing annotation. + + Only the annotation author may update the annotation. + + Path Parameters: + file_id: The ID of the document. + annotation_id: The ID of the annotation to update. + + Request body (JSON): + Any subset of ``content``, ``x``, ``y``, ``width``, ``height``, + ``annotation_type``, and ``color``. + + Returns: + The updated annotation object. + """ + user_id = get_current_user_id(request) + + annotation = ( + db.query(DocumentAnnotation) + .filter(DocumentAnnotation.id == annotation_id, DocumentAnnotation.file_id == file_id) + .first() + ) + if not annotation: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Annotation not found") + + if annotation.user_id != user_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only edit your own annotations") + + if content is not None: + content = content.strip() if isinstance(content, str) else "" + if not content: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="content must be non-empty", + ) + if len(content) > MAX_ANNOTATION_CONTENT_LENGTH: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"content must be at most {MAX_ANNOTATION_CONTENT_LENGTH} characters", + ) + annotation.content = content + + if x is not None: + annotation.x = x + if y is not None: + annotation.y = y + if width is not None: + annotation.width = width + if height is not None: + annotation.height = height + if annotation_type is not None: + if annotation_type not in ALLOWED_ANNOTATION_TYPES: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"annotation_type must be one of: {', '.join(sorted(ALLOWED_ANNOTATION_TYPES))}", + ) + annotation.annotation_type = annotation_type + if color is not None: + annotation.color = color + + try: + db.commit() + db.refresh(annotation) + except Exception: + db.rollback() + logger.exception("Failed to update annotation id=%s", annotation_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update annotation", + ) + + logger.info("Annotation updated: id=%s, user=%s", annotation_id, user_id) + return _serialize_annotation(annotation) + + +@router.delete("/files/{file_id}/annotations/{annotation_id}", status_code=status.HTTP_204_NO_CONTENT) +@require_login +def delete_annotation(request: Request, file_id: int, annotation_id: int, db: DbSession): + """Delete an annotation. + + Only the annotation author may delete the annotation. + + Path Parameters: + file_id: The ID of the document. + annotation_id: The ID of the annotation to delete. + """ + user_id = get_current_user_id(request) + + annotation = ( + db.query(DocumentAnnotation) + .filter(DocumentAnnotation.id == annotation_id, DocumentAnnotation.file_id == file_id) + .first() + ) + if not annotation: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Annotation not found") + + if annotation.user_id != user_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only delete your own annotations") + + try: + db.delete(annotation) + db.commit() + except Exception: + db.rollback() + logger.exception("Failed to delete annotation id=%s", annotation_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to delete annotation", + ) + + logger.info("Annotation deleted: id=%s, user=%s", annotation_id, user_id) + + +# --------------------------------------------------------------------------- +# Mentionable users endpoint +# --------------------------------------------------------------------------- + + +@router.get("/users/mentionable") +@require_login +def list_mentionable_users(request: Request, db: DbSession): + """List users that can be @mentioned in comments. + + Returns all user profiles that are not blocked, sorted by + ``display_name``. + + Returns: + A list of ``{user_id, display_name}`` objects. + """ + profiles = db.query(UserProfile).filter(UserProfile.is_blocked.is_(False)).order_by(UserProfile.display_name).all() + + return [ + { + "user_id": p.user_id, + "display_name": p.display_name or p.user_id, + } + for p in profiles + ] diff --git a/app/api/diagnostic.py b/app/api/diagnostic.py index a329da46..d21a3d39 100644 --- a/app/api/diagnostic.py +++ b/app/api/diagnostic.py @@ -21,6 +21,67 @@ _DEFAULT_REDIS_URL = "redis://localhost:6379/0" router = APIRouter() +# --------------------------------------------------------------------------- +# Unauthenticated probe endpoints for Kubernetes liveness / readiness checks. +# These intentionally skip authentication so that kubelet can reach them +# without credentials. They live under /diagnostic/healthz/* so that the +# existing authenticated /diagnostic/health endpoint is unaffected. +# --------------------------------------------------------------------------- + + +@router.get("/diagnostic/healthz/live") +async def liveness_probe() -> JSONResponse: + """Lightweight liveness probe for Kubernetes. + + Returns **200 OK** as long as the process is running. Kubernetes uses + this to decide whether to *restart* the container — it should therefore + be as cheap as possible and **never** check external dependencies. + + **Authentication:** None (designed for kubelet probes). + """ + return JSONResponse(content={"status": "ok"}, status_code=200) + + +@router.get("/diagnostic/healthz/ready") +async def readiness_probe() -> JSONResponse: + """Readiness probe for Kubernetes. + + Verifies that the application can serve traffic by checking the database + and Redis. Kubernetes uses this to decide whether to *route traffic* to + the pod. + + Returns **200 OK** when all critical subsystems are reachable, or + **503 Service Unavailable** when the database is down. + + **Authentication:** None (designed for kubelet probes). + """ + checks: dict[str, dict[str, str]] = {} + db_ok = False + + # ── Database check ───────────────────────────────────────────────── + try: + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + checks["database"] = {"status": "ok"} + db_ok = True + except Exception as exc: + logger.warning("Readiness probe: database check failed: %s", exc) + checks["database"] = {"status": "error", "detail": str(exc)} + + # ── Redis check ──────────────────────────────────────────────────── + try: + redis_url = settings.redis_url or _DEFAULT_REDIS_URL + r = redis_lib.from_url(redis_url, socket_connect_timeout=2, socket_timeout=2) + r.ping() + checks["redis"] = {"status": "ok"} + except Exception as exc: + logger.warning("Readiness probe: Redis check failed: %s", exc) + checks["redis"] = {"status": "error", "detail": str(exc)} + + http_status = 503 if not db_ok else 200 + overall = "ready" if db_ok else "not_ready" + return JSONResponse(content={"status": overall, "checks": checks}, status_code=http_status) + @router.get("/diagnostic/health") @require_login diff --git a/app/api/dropbox.py b/app/api/dropbox.py index a7c4d7f0..e2d33448 100644 --- a/app/api/dropbox.py +++ b/app/api/dropbox.py @@ -5,16 +5,18 @@ Dropbox API endpoints import logging import os from typing import Annotated, Optional +from urllib.parse import quote import httpx +import requests from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from sqlalchemy.orm import Session -from app.auth import AUTH_ENABLED, require_login +from app.auth import require_login from app.config import settings from app.database import get_db from app.utils.oauth_helper import exchange_oauth_token -from app.utils.settings_service import save_setting_to_db, update_env_file +from app.utils.settings_service import save_setting_to_db from app.utils.settings_sync import notify_settings_updated # Set up logging @@ -23,21 +25,91 @@ logger = logging.getLogger(__name__) router = APIRouter() -def _require_admin(request: Request) -> dict: - """Dependency to ensure the current user is an admin. +def _build_dropbox_redirect_uri(request: Request) -> str: + """Build the Dropbox OAuth callback redirect URI. - When AUTH_ENABLED=False (single-user/development mode), admin checks are - skipped because there is no authentication at all. + Uses ``PUBLIC_BASE_URL`` when configured (recommended for deployments behind + a reverse proxy that doesn't forward ``X-Forwarded-Proto``). Falls back to + deriving the URI from the incoming request's scheme and host headers. """ - if not AUTH_ENABLED: - return {} - user = request.session.get("user") - if not user or not user.get("is_admin"): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") - return user + if settings.public_base_url: + return settings.public_base_url.rstrip("/") + "/dropbox-callback" + return f"{request.url.scheme}://{request.url.netloc}/dropbox-callback" -AdminUser = Annotated[dict, Depends(_require_admin)] +@router.get("/dropbox/global-authorize-url") +@require_login +async def dropbox_global_authorize_url(request: Request): + """Return the Dropbox OAuth authorization URL using the global app credentials. + + This endpoint is used when ``DROPBOX_ALLOW_GLOBAL_CREDENTIALS_FOR_INTEGRATIONS`` + is enabled so that users can authorize their personal Dropbox integration without + needing to supply their own app key/secret. Only the public ``app_key`` is + embedded in the URL; the ``app_secret`` is never sent to the browser. + """ + if not settings.dropbox_allow_global_credentials_for_integrations: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Global credentials for integrations are not enabled", + ) + if not settings.dropbox_app_key or not settings.dropbox_app_secret: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Global Dropbox credentials are not configured", + ) + redirect_uri = _build_dropbox_redirect_uri(request) + authorize_url = ( + "https://www.dropbox.com/oauth2/authorize" + f"?client_id={settings.dropbox_app_key}" + "&response_type=code" + "&token_access_type=offline" + f"&redirect_uri={quote(redirect_uri, safe='')}" + ) + return {"authorize_url": authorize_url} + + +@router.post("/dropbox/exchange-token-global") +@require_login +async def exchange_dropbox_token_global( + request: Request, + code: Annotated[str, Form(...)], + redirect_uri: Annotated[str, Form(...)], +): + """Exchange an authorization code using the global Dropbox app credentials. + + Used when ``DROPBOX_ALLOW_GLOBAL_CREDENTIALS_FOR_INTEGRATIONS`` is enabled so + that the ``app_secret`` is never exposed to the browser. Only the OAuth code + and redirect URI need to be supplied by the client. + """ + if not settings.dropbox_allow_global_credentials_for_integrations: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Global credentials for integrations are not enabled", + ) + if not settings.dropbox_app_key or not settings.dropbox_app_secret: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Global Dropbox credentials are not configured", + ) + + token_url = "https://api.dropboxapi.com/oauth2/token" + payload = { + "client_id": settings.dropbox_app_key, + "client_secret": settings.dropbox_app_secret, + "code": code, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + } + + token_data = exchange_oauth_token(provider_name="Dropbox", token_url=token_url, payload=payload) + + return { + "refresh_token": token_data["refresh_token"], + "access_token": token_data["access_token"], + "expires_in": token_data.get("expires_in", 14400), + # Return the public app_key so the callback can store it in the integration + "app_key": settings.dropbox_app_key, + } @router.post("/dropbox/exchange-token") @@ -227,10 +299,95 @@ async def test_dropbox_token(request: Request): return {"status": "error", "message": f"Connection error: {str(e)}"} +@router.post("/dropbox/list-folders") +@require_login +async def list_dropbox_folders( + request: Request, + access_token: Annotated[str, Form(...)], + path: Annotated[str, Form()] = "", +): + """ + List folders in a Dropbox account for the directory selector. + + Accepts an OAuth access token (short-lived) and a path to list. + Returns a flat list of folder entries under the given path. + """ + try: + # Normalize path: Dropbox API uses "" for root, otherwise "/path" + folder_path = path.strip() + if folder_path == "/": + folder_path = "" + elif folder_path and not folder_path.startswith("/"): + folder_path = f"/{folder_path}" + + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + + payload = { + "path": folder_path, + "recursive": False, + "include_deleted": False, + "include_has_explicit_shared_members": False, + "include_mounted_folders": True, + } + + response = requests.post( + "https://api.dropboxapi.com/2/files/list_folder", + headers=headers, + json=payload, + timeout=settings.http_request_timeout, + ) + + if response.status_code == 401: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Access token is invalid or expired. Please re-authorize.", + ) + + if response.status_code != 200: + logger.error(f"Dropbox list_folder failed: {response.status_code} {response.text}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Failed to list Dropbox folders: {response.text}", + ) + + data = response.json() + folders = [] + for entry in data.get("entries", []): + if entry.get(".tag") == "folder": + folders.append( + { + "name": entry["name"], + "path": entry["path_display"], + "id": entry.get("id", ""), + } + ) + + # Sort folders alphabetically + folders.sort(key=lambda f: f["name"].lower()) + + return { + "folders": folders, + "path": folder_path or "/", + "has_more": data.get("has_more", False), + } + + except HTTPException: + raise + except Exception as e: + logger.exception(f"Error listing Dropbox folders: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to list folders: {str(e)}", + ) + + @router.post("/dropbox/save-settings") +@require_login async def save_dropbox_settings( request: Request, - _admin: AdminUser, refresh_token: Annotated[str, Form(...)], app_key: Annotated[Optional[str], Form()] = None, app_secret: Annotated[Optional[str], Form()] = None, @@ -269,16 +426,44 @@ async def save_dropbox_settings( # Best-effort .env file write try: env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env") - dropbox_settings = {"DROPBOX_REFRESH_TOKEN": refresh_token} - if app_key: - dropbox_settings["DROPBOX_APP_KEY"] = app_key - if app_secret: - dropbox_settings["DROPBOX_APP_SECRET"] = app_secret - if folder_path: - dropbox_settings["DROPBOX_FOLDER"] = folder_path + if not os.path.exists(env_path): + logger.warning(f".env file not found at {env_path}, skipping file write") + else: + logger.info(f"Updating Dropbox settings in {env_path}") - if not update_env_file(env_path, dropbox_settings): - logger.info("Continuing with in-memory update despite .env file update failure or skip") + with open(env_path, "r") as f: + env_lines = f.readlines() + + dropbox_settings = {"DROPBOX_REFRESH_TOKEN": refresh_token} + if app_key: + dropbox_settings["DROPBOX_APP_KEY"] = app_key + if app_secret: + dropbox_settings["DROPBOX_APP_SECRET"] = app_secret + if folder_path: + dropbox_settings["DROPBOX_FOLDER"] = folder_path + + updated = set() + new_env_lines = [] + for line in env_lines: + stripped_line = line.rstrip() + is_updated = False + for key, value in dropbox_settings.items(): + if stripped_line.startswith(f"{key}=") or stripped_line.startswith(f"# {key}="): + new_env_lines.append(f"{key}={value}") + updated.add(key) + is_updated = True + break + if not is_updated: + new_env_lines.append(stripped_line) + + for key, value in dropbox_settings.items(): + if key not in updated: + new_env_lines.append(f"{key}={value}") + + with open(env_path, "w") as f: + f.write("\n".join(new_env_lines) + "\n") + + logger.info("Successfully updated Dropbox settings in .env file") except Exception as env_err: logger.warning(f"Failed to write .env file (non-fatal): {env_err}") diff --git a/app/api/files.py b/app/api/files.py index 65cdaaf8..da9907c1 100644 --- a/app/api/files.py +++ b/app/api/files.py @@ -20,6 +20,7 @@ from sqlalchemy.orm import Session from app.auth import require_login from app.config import settings from app.database import get_db +from app.middleware.upload_rate_limit import require_upload_rate_limit from app.models import FileProcessingStep, FileRecord, ProcessingLog from app.tasks.convert_to_pdf import convert_to_pdf from app.tasks.process_document import process_document @@ -29,7 +30,7 @@ from app.utils.file_queries import apply_status_filter from app.utils.file_status import get_files_processing_status from app.utils.filename_utils import sanitize_filename from app.utils.input_validation import validate_search_query, validate_sort_field, validate_sort_order -from app.utils.user_scope import apply_owner_filter, get_current_owner_id +from app.utils.user_scope import apply_owner_filter, get_current_owner_id, get_file_role # Set up logging logger = logging.getLogger(__name__) @@ -299,6 +300,7 @@ def delete_file_record(request: Request, file_id: int, db: DbSession): """ Delete a file record from the database. This only removes the database entry, not the actual file. + Only the file owner (or an admin) may delete a document. """ # Check if file deletion is allowed if not settings.allow_file_delete: @@ -313,6 +315,18 @@ def delete_file_record(request: Request, file_id: int, db: DbSession): if not file_record: raise HTTPException(status_code=404, detail=f"File record with ID {file_id} not found") + # Enforce owner-only deletion in multi-user mode + user = request.session.get("user") + is_admin = isinstance(user, dict) and bool(user.get("is_admin")) + if not is_admin: + owner_id = get_current_owner_id(request) + role = get_file_role(file_record, owner_id, db) + if role != "owner": + raise HTTPException( + status_code=403, + detail="Only the file owner can delete this document", + ) + # Log the deletion logger.info(f"Deleting file record: ID={file_id}, Filename={file_record.original_filename}") @@ -339,6 +353,7 @@ def bulk_delete_files(request: Request, file_ids: List[int], db: DbSession): """ Delete multiple file records from the database. This only removes the database entries, not the actual files. + Only the file owner (or an admin) may delete each document. """ # Check if file deletion is allowed if not settings.allow_file_delete: @@ -353,6 +368,18 @@ def bulk_delete_files(request: Request, file_ids: List[int], db: DbSession): if not file_records: raise HTTPException(status_code=404, detail="No files found with the provided IDs") + # Enforce owner-only deletion in multi-user mode + user = request.session.get("user") + is_admin = isinstance(user, dict) and bool(user.get("is_admin")) + if not is_admin: + owner_id = get_current_owner_id(request) + non_owner_ids = [f.id for f in file_records if get_file_role(f, owner_id, db) != "owner"] + if non_owner_ids: + raise HTTPException( + status_code=403, + detail=f"You can only delete files you own. Not owner of file IDs: {non_owner_ids}", + ) + deleted_count = len(file_records) deleted_ids = [f.id for f in file_records] @@ -1267,7 +1294,12 @@ async def _save_upload_file_chunks(file: UploadFile, target_path: str, max_size: def _check_for_exact_duplicate(db: DbSession, target_path: str, safe_filename: str) -> dict | None: - """Check for an exact duplicate of the uploaded file and return a warning if found.""" + """Check for an exact duplicate of the uploaded file. + + Returns a dict with duplicate info when the file's SHA-256 hash matches an + already-processed document, or ``None`` when no duplicate is found (or + deduplication is disabled). + """ if not settings.enable_deduplication: return None @@ -1286,8 +1318,8 @@ def _check_for_exact_duplicate(db: DbSession, target_path: str, safe_filename: s "original_file_id": existing.id, "original_filename": existing.original_filename, "message": ( - "This file appears to be an exact duplicate of an already-processed document. " - "It will still be queued but will be flagged as a duplicate." + "This file is an exact duplicate of an already-processed document. " + "It has not been queued for processing again." ), } except Exception as e: @@ -1298,7 +1330,12 @@ def _check_for_exact_duplicate(db: DbSession, target_path: str, safe_filename: s @router.post("/ui-upload") @require_login -async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...)): +async def ui_upload( + request: Request, + db: DbSession, + file: UploadFile = File(...), + _rate_ok: None = Depends(require_upload_rate_limit), +): """Endpoint to accept a user-uploaded file and enqueue it for processing.""" workdir = settings.workdir @@ -1384,6 +1421,25 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(... logger.info(f"Saved uploaded file '{safe_filename}' as '{target_filename}'") file_size = written_size + # ── Early duplicate rejection ────────────────────────────────────────── + # Check for exact duplicates (same SHA-256 hash) BEFORE enqueuing a + # processing task. When deduplication is enabled and the file already + # exists, we skip processing entirely, clean up the temp file, and + # return the existing file's information to the caller. + exact_duplicate = _check_for_exact_duplicate(db, target_path, safe_filename) + if exact_duplicate: + # Remove the just-saved temp file — it's a duplicate. + try: + os.remove(target_path) + except OSError: + pass + return { + "status": "duplicate", + "original_filename": safe_filename, + "stored_filename": target_filename, + "duplicate_of": exact_duplicate, + } + # Determine if the file is a PDF or needs conversion mime_type, _ = mimetypes.guess_type(target_path) file_ext = os.path.splitext(target_path)[1].lower() @@ -1447,6 +1503,8 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(... ".tif", ".webp", ".svg", + ".heic", + ".heif", }: # If it's an image, convert to PDF first task = convert_to_pdf.delay(target_path, original_filename=safe_filename, owner_id=upload_owner_id) @@ -1460,20 +1518,12 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(... logger.warning(f"Unsupported MIME type {mime_type} for {target_path}, attempting conversion") task = convert_to_pdf.delay(target_path, original_filename=safe_filename, owner_id=upload_owner_id) - # Check for exact duplicates (same SHA-256 hash) before returning. - # This gives the caller an immediate warning without waiting for the pipeline. - # Only performed when deduplication is enabled in settings. - exact_duplicate_warning = _check_for_exact_duplicate(db, target_path, safe_filename) - - response: dict = { + return { "task_id": task.id, "status": "queued", "original_filename": safe_filename, "stored_filename": target_filename, } - if exact_duplicate_warning: - response["duplicate_warning"] = exact_duplicate_warning - return response # --------------------------------------------------------------------------- diff --git a/app/api/google_drive.py b/app/api/google_drive.py index 69d52f19..f9ca4f92 100644 --- a/app/api/google_drive.py +++ b/app/api/google_drive.py @@ -14,7 +14,7 @@ from app.auth import require_login from app.config import settings from app.database import get_db from app.utils.oauth_helper import exchange_oauth_token -from app.utils.settings_service import save_setting_to_db, update_env_file +from app.utils.settings_service import save_setting_to_db from app.utils.settings_sync import notify_settings_updated # Set up logging @@ -23,17 +23,6 @@ logger = logging.getLogger(__name__) router = APIRouter() -def _require_admin(request: Request) -> dict: - """Dependency to ensure the current user is an admin.""" - user = request.session.get("user") - if not user or not user.get("is_admin"): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") - return user - - -AdminUser = Annotated[dict, Depends(_require_admin)] - - @router.post("/google-drive/exchange-token") @require_login async def exchange_google_drive_token( @@ -373,9 +362,9 @@ def format_time_remaining(time_delta): @router.post("/google-drive/save-settings") +@require_login async def save_google_drive_settings( request: Request, - _admin: AdminUser, refresh_token: Annotated[str, Form(...)], client_id: Annotated[Optional[str], Form()] = None, client_secret: Annotated[Optional[str], Form()] = None, @@ -415,9 +404,46 @@ async def save_google_drive_settings( drive_settings["GOOGLE_DRIVE_FOLDER_ID"] = folder_id # Try to update the .env file, but don't fail if it doesn't exist (for Docker containers) - env_write_success = update_env_file(env_path, drive_settings) - if not env_write_success: - logger.info("Continuing with in-memory update despite .env file update failure or skip") + if os.path.exists(env_path): + try: + logger.info(f"Updating Google Drive settings in {env_path}") + + # Read the current .env file + with open(env_path, "r") as f: + env_lines = f.readlines() + + # Process each line and update or add settings + updated = set() + new_env_lines = [] + for line in env_lines: + stripped_line = line.rstrip() + is_updated = False + for key, value in drive_settings.items(): + if stripped_line.startswith(f"{key}=") or stripped_line.startswith(f"# {key}="): + # Uncomment if commented out - check the original stripped line + new_env_lines.append(f"{key}={value}") + updated.add(key) + is_updated = True + break + if not is_updated: + new_env_lines.append(stripped_line) + + # Add any settings that weren't updated (they weren't in the file) + for key, value in drive_settings.items(): + if key not in updated: + new_env_lines.append(f"{key}={value}") + + # Write the updated .env file + with open(env_path, "w") as f: + f.write("\n".join(new_env_lines) + "\n") + + logger.info("Successfully updated Google Drive settings in .env file") + except Exception as e: + logger.warning(f"Failed to update .env file: {str(e)}, but will continue with in-memory update") + else: + logger.warning( + f".env file not found at {env_path}, skipping file update but continuing with in-memory update" + ) # Update the settings in memory (this always happens) if refresh_token: @@ -455,7 +481,7 @@ async def save_google_drive_settings( return { "status": "success", "message": "Google Drive settings have been saved", - "in_memory_only": not env_write_success, + "in_memory_only": not os.path.exists(env_path), } except Exception as e: diff --git a/app/api/integrations.py b/app/api/integrations.py index 8d2d9209..f5b11a16 100644 --- a/app/api/integrations.py +++ b/app/api/integrations.py @@ -32,6 +32,21 @@ from app.utils.encryption import decrypt_value, encrypt_value from app.utils.subscription import get_tier, get_user_tier_id from app.utils.user_scope import get_current_owner_id +# Optional Dropbox SDK — imported at module level so tests can patch it cleanly. +try: + import dropbox as dbx_lib + from dropbox.exceptions import AuthError as _DropboxAuthError + from dropbox.exceptions import BadInputError as _DropboxBadInputError +except ImportError: # pragma: no cover + dbx_lib = None # type: ignore[assignment] + + class _DropboxAuthError(Exception): # type: ignore[no-redef] + """Stub — only used when the dropbox package is missing.""" + + class _DropboxBadInputError(Exception): # type: ignore[no-redef] + """Stub — only used when the dropbox package is missing.""" + + logger = logging.getLogger(__name__) router = APIRouter(prefix="/integrations", tags=["integrations"]) @@ -550,9 +565,50 @@ def _test_s3_connection(config: dict[str, Any] | None, credentials: dict[str, An return {"success": False, "message": "S3 connection failed"} +def _test_dropbox_connection(config: dict[str, Any] | None, credentials: dict[str, Any] | None) -> dict[str, Any]: + """Test a Dropbox connection by verifying OAuth credentials via the Dropbox API.""" + if dbx_lib is None: + return {"success": False, "message": "dropbox package is not installed"} # pragma: no cover + + creds = credentials or {} + app_key = creds.get("app_key", "") + app_secret = creds.get("app_secret", "") + refresh_token = creds.get("refresh_token", "") + + if not refresh_token: + return {"success": False, "message": "Missing required credential: refresh_token"} + if not app_key or not app_secret: + return {"success": False, "message": "Missing required credentials: app_key and app_secret"} + + try: + dbx = dbx_lib.Dropbox( + app_key=app_key, + app_secret=app_secret, + oauth2_refresh_token=refresh_token, + ) + account = dbx.users_get_current_account() + display_name = getattr(account, "name", None) + name_str = "" + if display_name: + name_str = f" ({getattr(display_name, 'display_name', '') or ''})" + return {"success": True, "message": f"Dropbox connection successful{name_str}"} + except _DropboxAuthError as exc: + logger.warning("Dropbox auth error: %s", exc) + return { + "success": False, + "message": "Dropbox authentication failed — check app_key, app_secret, and refresh_token", + } + except _DropboxBadInputError as exc: + logger.warning("Dropbox bad input error: %s", exc) + return {"success": False, "message": "Dropbox connection failed — invalid credentials format"} + except Exception as exc: # noqa: BLE001 + logger.warning("Dropbox connection error: %s", exc) + return {"success": False, "message": "Dropbox connection failed — check credentials and network connectivity"} + + def _test_webdav_connection(config: dict[str, Any] | None, credentials: dict[str, Any] | None) -> dict[str, Any]: """Test a WebDAV/Nextcloud connection by issuing an HTTP PROPFIND.""" - import urllib.request + import httpx cfg = config or {} creds = credentials or {} @@ -579,23 +635,21 @@ def _test_webdav_connection(config: dict[str, Any] | None, credentials: dict[str return {"success": False, "message": "URLs pointing to internal or private networks are not allowed"} try: - import base64 + auth = (username, password) if username and password else None + headers = {"Depth": "0"} - req = urllib.request.Request(url, method="PROPFIND") # noqa: S310 - if username and password: - token = base64.b64encode(f"{username}:{password}".encode()).decode() - req.add_header("Authorization", f"Basic {token}") - req.add_header("Depth", "0") - with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310 - if resp.status < 400: - return {"success": True, "message": "WebDAV connection successful"} - return {"success": False, "message": f"WebDAV returned HTTP {resp.status}"} + # Use httpx for secure connection testing, avoiding urllib vulnerabilities + resp = httpx.request("PROPFIND", url, auth=auth, headers=headers, timeout=10.0, follow_redirects=False) + if resp.status_code < 400: + return {"success": True, "message": "WebDAV connection successful"} + return {"success": False, "message": f"WebDAV returned HTTP {resp.status_code}"} except Exception as exc: # noqa: BLE001 logger.warning("WebDAV connection error for %s: %s", hostname, exc) return {"success": False, "message": "WebDAV connection failed — check URL and credentials"} _CONNECTION_TESTERS: dict[str, Any] = { + IntegrationType.DROPBOX: _test_dropbox_connection, IntegrationType.IMAP: _test_imap_connection, IntegrationType.S3: _test_s3_connection, IntegrationType.WEBDAV: _test_webdav_connection, diff --git a/app/api/mobile.py b/app/api/mobile.py index 465872ab..5305c5d2 100644 --- a/app/api/mobile.py +++ b/app/api/mobile.py @@ -120,6 +120,7 @@ class WhoAmIResponse(BaseModel): email: str | None avatar_url: str | None is_admin: bool + preferred_language: str | None # --------------------------------------------------------------------------- @@ -273,31 +274,44 @@ async def list_devices( return [_device_to_response(d) for d in devices] -@router.delete("/devices/{device_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/devices/{device_id}", status_code=status.HTTP_200_OK) @require_login async def deactivate_device( request: Request, device_id: int, owner_id: CurrentOwner, db: DbSession, -) -> None: - """Deactivate a push-notification device registration. +) -> dict[str, str]: + """Deactivate or permanently delete a push-notification device registration. - The device record is kept for audit purposes but will no longer receive - push notifications. + * **Active device** – soft-deactivated: the record is kept for audit + purposes but will no longer receive push notifications. + * **Already-inactive device** – hard-deleted: the record is permanently + removed from the database. """ device = db.get(MobileDevice, device_id) if not device or device.owner_id != owner_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device not found") - device.is_active = False + if device.is_active: + device.is_active = False + try: + db.commit() + except Exception: + db.rollback() + raise + logger.info("Mobile device deactivated: id=%s owner=%s", device_id, owner_id) + return {"detail": "Device deactivated"} + + # Hard-delete an already-inactive device. try: + db.delete(device) db.commit() except Exception: db.rollback() raise - - logger.info("Mobile device deactivated: id=%s owner=%s", device_id, owner_id) + logger.info("Mobile device permanently deleted: id=%s owner=%s", device_id, owner_id) + return {"detail": "Device deleted"} @router.get("/whoami", response_model=WhoAmIResponse) @@ -344,4 +358,5 @@ async def whoami( "email": email, "avatar_url": avatar_url, "is_admin": is_admin, + "preferred_language": profile.preferred_language if profile else None, } diff --git a/app/api/onedrive.py b/app/api/onedrive.py index a61acd1f..cf39f43b 100644 --- a/app/api/onedrive.py +++ b/app/api/onedrive.py @@ -3,20 +3,20 @@ OneDrive API endpoints """ import logging -import os from datetime import datetime, timedelta from typing import Annotated, Optional import httpx +import requests from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from sqlalchemy.orm import Session -from app.auth import AUTH_ENABLED, require_login +from app.auth import require_login from app.config import settings from app.database import get_db -from app.utils.env_utils import update_env_file as _update_env_file_auto +from app.utils.env_utils import update_env_file from app.utils.oauth_helper import exchange_oauth_token -from app.utils.settings_service import save_setting_to_db, update_env_file +from app.utils.settings_service import save_setting_to_db from app.utils.settings_sync import notify_settings_updated # Set up logging @@ -25,23 +25,6 @@ logger = logging.getLogger(__name__) router = APIRouter() -def _require_admin(request: Request) -> dict: - """Dependency to ensure the current user is an admin. - - When AUTH_ENABLED=False (single-user/development mode), admin checks are - skipped because there is no authentication at all. - """ - if not AUTH_ENABLED: - return {} - user = request.session.get("user") - if not user or not user.get("is_admin"): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") - return user - - -AdminUser = Annotated[dict, Depends(_require_admin)] - - @router.post("/onedrive/exchange-token") @require_login async def exchange_onedrive_token( @@ -74,6 +57,7 @@ async def exchange_onedrive_token( # Return just what's needed by the frontend return { "refresh_token": token_data["refresh_token"], + "access_token": token_data.get("access_token", ""), "expires_in": token_data.get("expires_in", 3600), } @@ -134,7 +118,7 @@ async def test_onedrive_token(request: Request): settings.onedrive_refresh_token = new_refresh_token # Also try to update .env file if it exists - _update_env_file_auto({"ONEDRIVE_REFRESH_TOKEN": new_refresh_token}) + update_env_file({"ONEDRIVE_REFRESH_TOKEN": new_refresh_token}) # Persist the rotated refresh token to the database try: @@ -201,6 +185,102 @@ async def test_onedrive_token(request: Request): return {"status": "error", "message": f"Connection error: {str(e)}"} +@router.post("/onedrive/list-folders") +@require_login +async def list_onedrive_folders( + request: Request, + access_token: Annotated[str, Form(...)], + path: Annotated[str, Form()] = "", +): + """ + List folders in a OneDrive account for the directory selector. + + Accepts an OAuth access token (short-lived) and a path to list. + Returns a flat list of folder entries under the given path. + """ + try: + folder_path = path.strip().strip("/") + + headers = { + "Authorization": f"Bearer {access_token}", + } + + # Build the Graph API URL for listing children + if not folder_path or folder_path == "root": + url = "https://graph.microsoft.com/v1.0/me/drive/root/children" + else: + url = f"https://graph.microsoft.com/v1.0/me/drive/root:/{folder_path}:/children" + + # Only request folders and minimal fields + params = { + "$filter": "folder ne null", + "$select": "name,id,parentReference,folder", + "$top": "200", + } + + response = requests.get( + url, + headers=headers, + params=params, + timeout=settings.http_request_timeout, + ) + + if response.status_code == 401: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Access token is invalid or expired. Please re-authorize.", + ) + + if response.status_code != 200: + logger.error(f"OneDrive list children failed: {response.status_code} {response.text}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Failed to list OneDrive folders: {response.text}", + ) + + data = response.json() + folders = [] + for item in data.get("value", []): + if "folder" in item: + parent_path = "" + if item.get("parentReference", {}).get("path"): + # parentReference.path looks like /drive/root:/some/path + raw_parent = item["parentReference"]["path"] + prefix = "/drive/root:" + if raw_parent.startswith(prefix): + parent_path = raw_parent[len(prefix) :] + elif raw_parent == "/drive/root": + parent_path = "" + + item_path = f"{parent_path}/{item['name']}" if parent_path else f"/{item['name']}" + + folders.append( + { + "name": item["name"], + "path": item_path, + "id": item.get("id", ""), + "child_count": item.get("folder", {}).get("childCount", 0), + } + ) + + # Sort folders alphabetically + folders.sort(key=lambda f: f["name"].lower()) + + return { + "folders": folders, + "path": f"/{folder_path}" if folder_path else "/", + } + + except HTTPException: + raise + except Exception as e: + logger.exception(f"Error listing OneDrive folders: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to list folders: {str(e)}", + ) + + def format_time_remaining(time_delta): """Format a timedelta into a human-readable string.""" if time_delta.total_seconds() <= 0: @@ -222,9 +302,9 @@ def format_time_remaining(time_delta): @router.post("/onedrive/save-settings") +@require_login async def save_onedrive_settings( request: Request, - _admin: AdminUser, refresh_token: Annotated[str, Form(...)], client_id: Annotated[Optional[str], Form()] = None, client_secret: Annotated[Optional[str], Form()] = None, @@ -241,44 +321,26 @@ async def save_onedrive_settings( user.get("preferred_username") or user.get("username") or user.get("email") or user.get("id") or "wizard" ) - # Best-effort .env file write - env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env") - onedrive_settings = {"ONEDRIVE_REFRESH_TOKEN": refresh_token} - if client_id: - onedrive_settings["ONEDRIVE_CLIENT_ID"] = client_id - if client_secret: - onedrive_settings["ONEDRIVE_CLIENT_SECRET"] = client_secret - if tenant_id: - onedrive_settings["ONEDRIVE_TENANT_ID"] = tenant_id - if folder_path: - onedrive_settings["ONEDRIVE_FOLDER_PATH"] = folder_path + # Build settings dictionary mapped to database/memory keys + onedrive_settings = { + "onedrive_refresh_token": refresh_token, + "onedrive_client_id": client_id, + "onedrive_client_secret": client_secret, + "onedrive_tenant_id": tenant_id, + "onedrive_folder_path": folder_path, + } - if not update_env_file(env_path, onedrive_settings): - logger.info("Continuing with in-memory update despite .env file update failure or skip") + # Filter out None values + onedrive_settings = {k: v for k, v in onedrive_settings.items() if v is not None} - # Update the settings in memory - if refresh_token: - settings.onedrive_refresh_token = refresh_token - if client_id: - settings.onedrive_client_id = client_id - if client_secret: - settings.onedrive_client_secret = client_secret - if tenant_id: - settings.onedrive_tenant_id = tenant_id - if folder_path: - settings.onedrive_folder_path = folder_path + # Best-effort .env file write using the new utility + env_settings = {k.upper(): v for k, v in onedrive_settings.items()} + update_env_file(env_settings) - # Persist to database (primary) - if refresh_token: - save_setting_to_db(db, "onedrive_refresh_token", refresh_token, changed_by=changed_by) - if client_id: - save_setting_to_db(db, "onedrive_client_id", client_id, changed_by=changed_by) - if client_secret: - save_setting_to_db(db, "onedrive_client_secret", client_secret, changed_by=changed_by) - if tenant_id: - save_setting_to_db(db, "onedrive_tenant_id", tenant_id, changed_by=changed_by) - if folder_path: - save_setting_to_db(db, "onedrive_folder_path", folder_path, changed_by=changed_by) + # Update in-memory settings and persist to database dynamically + for key, value in onedrive_settings.items(): + setattr(settings, key, value) + save_setting_to_db(db, key, value, changed_by=changed_by) notify_settings_updated() diff --git a/app/api/pipelines.py b/app/api/pipelines.py index 8175832f..6acf273f 100644 --- a/app/api/pipelines.py +++ b/app/api/pipelines.py @@ -117,8 +117,14 @@ PIPELINE_STEP_TYPES: dict[str, dict[str, Any]] = { }, "classify": { "label": "Document Classification", - "description": "Classify the document type using AI without full metadata extraction.", - "config_schema": {}, + "description": "Classify the document type using built-in and custom rules (filename patterns, content keywords, metadata matching).", + "config_schema": { + "use_builtin_rules": { + "type": "boolean", + "default": True, + "description": "Include the pre-built classification rules (invoice, contract, receipt, etc.).", + }, + }, }, } diff --git a/app/api/qr_auth.py b/app/api/qr_auth.py index 8f891e4e..97aadb94 100644 --- a/app/api/qr_auth.py +++ b/app/api/qr_auth.py @@ -31,6 +31,7 @@ from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.auth import require_login +from app.config import settings from app.database import get_db from app.middleware.audit_log import get_client_ip from app.utils.session_manager import ( @@ -151,6 +152,11 @@ async def create_challenge( displayed to the user. The mobile app scans this QR code and calls the ``/claim`` endpoint. """ + if not settings.qr_login_enabled: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="QR login feature is currently disabled. Please contact your administrator to enable it.", + ) ip = get_client_ip(request) challenge = create_qr_challenge(db, owner_id, ip_address=ip) @@ -187,6 +193,11 @@ async def poll_challenge_status( The web UI calls this endpoint every few seconds to check if the mobile app has scanned the QR code and claimed the challenge. """ + if not settings.qr_login_enabled: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="QR login feature is currently disabled. Please contact your administrator to enable it.", + ) result = get_challenge_status(db, challenge_id, owner_id) if not result: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Challenge not found") @@ -206,6 +217,11 @@ async def claim_challenge( serves as proof that the user authorized this login from their web session. """ + if not settings.qr_login_enabled: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="QR login feature is currently disabled. Please contact your administrator to enable it.", + ) ip = get_client_ip(request) result = claim_qr_challenge(db, body.challenge_token, device_name=body.device_name, ip_address=ip) diff --git a/app/api/settings.py b/app/api/settings.py index ffe1dfcf..dc560b8f 100644 --- a/app/api/settings.py +++ b/app/api/settings.py @@ -55,6 +55,12 @@ class SettingUpdate(BaseModel): value: Optional[str] = Field(None, description="Setting value (None to delete)") +class SettingValueUpdate(BaseModel): + """Model for updating a setting value by key (key is provided in the URL path).""" + + value: Optional[str] = Field(None, description="Setting value (None to delete)") + + class SettingResponse(BaseModel): """Model for setting response""" @@ -323,6 +329,62 @@ async def update_setting( ) +@router.put("/{key}") +async def put_setting( + key: str, + body: SettingValueUpdate, + request: Request, + db: DbSession, + admin: AdminUser, +): + """ + Update a specific setting by key (RESTful PUT). + + Accepts a body with only ``value``; the key is taken from the URL path. + This is the endpoint used by the admin Connections wizard. + Admin only. + """ + validate_setting_key(key) + try: + if body.value is not None: + is_valid, error_message = validate_setting_value(key, body.value) + if not is_valid: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_message) + + user = request.session.get("user", {}) if hasattr(request, "session") else {} + changed_by = ( + user.get("preferred_username") or user.get("username") or user.get("email") or user.get("id") or "admin" + ) + + success = save_setting_to_db(db, key, body.value, changed_by=changed_by) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to save setting to database", + ) + + notify_settings_updated() + + metadata = get_setting_metadata(key) + restart_required = metadata.get("restart_required", False) + + return { + "success": True, + "message": f"Setting '{key}' updated successfully", + "restart_required": restart_required, + "key": key, + "value": body.value, + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Error updating setting {key}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update setting: {key}", + ) + + @router.delete("/{key}") async def delete_setting(key: str, request: Request, db: DbSession, admin: AdminUser): """ diff --git a/app/api/sharing.py b/app/api/sharing.py new file mode 100644 index 00000000..2d66d92b --- /dev/null +++ b/app/api/sharing.py @@ -0,0 +1,355 @@ +"""File-sharing API endpoints. + +Provides CRUD operations for ``FileShare`` records, which grant named +users ``viewer`` or ``editor`` access to a document owned by someone +else. Only the file owner may create, update, or revoke shares. +""" + +import logging +from typing import Annotated, Any + +from fastapi import APIRouter, Body, Depends, HTTPException, Request, status +from sqlalchemy.orm import Session + +from app.auth import require_login +from app.database import get_db +from app.models import FILE_SHARE_ROLE_VIEWER, FILE_SHARE_ROLES, FileRecord, FileShare, UserProfile +from app.utils.user_scope import get_current_owner_id, get_file_role + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["sharing"]) + +DbSession = Annotated[Session, Depends(get_db)] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _serialize_share(share: FileShare) -> dict[str, Any]: + """Serialize a ``FileShare`` to a JSON-friendly dict.""" + return { + "id": share.id, + "file_id": share.file_id, + "owner_id": share.owner_id, + "shared_with_user_id": share.shared_with_user_id, + "role": share.role, + "created_at": share.created_at.isoformat() if share.created_at else None, + "updated_at": share.updated_at.isoformat() if share.updated_at else None, + } + + +def _require_owner(file_record: FileRecord, user_id: str | None, db: Session) -> None: + """Raise 403 unless the calling user is the file owner.""" + if get_file_role(file_record, user_id, db) != "owner": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the file owner can manage shares", + ) + + +# --------------------------------------------------------------------------- +# List shares +# --------------------------------------------------------------------------- + + +@router.get("/files/{file_id}/shares") +@require_login +def list_shares(request: Request, file_id: int, db: DbSession): + """List all shares for a document. + + Only the file owner (or an admin) may call this endpoint. + + Path Parameters: + file_id: The ID of the document. + + Returns: + A list of share objects. + """ + user_id = get_current_owner_id(request) + user = request.session.get("user") + is_admin = isinstance(user, dict) and bool(user.get("is_admin")) + + file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + if not file_record: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + role = get_file_role(file_record, user_id, db) + if role is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + if role != "owner" and not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the file owner can view shares", + ) + + shares = db.query(FileShare).filter(FileShare.file_id == file_id).all() + return [_serialize_share(s) for s in shares] + + +# --------------------------------------------------------------------------- +# Create share +# --------------------------------------------------------------------------- + + +@router.post("/files/{file_id}/shares", status_code=status.HTTP_201_CREATED) +@require_login +def create_share( + request: Request, + file_id: int, + db: DbSession, + shared_with_user_id: str = Body(..., embed=True), + role: str = Body(FILE_SHARE_ROLE_VIEWER, embed=True), +): + """Share a document with another user. + + Only the file owner may share the document. Sharing with a user + that already has access updates their role instead of creating a + duplicate record. + + Path Parameters: + file_id: The ID of the document to share. + + Request body (JSON): + shared_with_user_id: The stable user identifier of the recipient. + role: ``"viewer"`` (default) or ``"editor"``. + + Returns: + The created or updated share object. + """ + owner_id = get_current_owner_id(request) + + file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + if not file_record: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + _require_owner(file_record, owner_id, db) + + if role not in FILE_SHARE_ROLES: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"role must be one of: {', '.join(FILE_SHARE_ROLES)}", + ) + + if not shared_with_user_id or not shared_with_user_id.strip(): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="shared_with_user_id must be a non-empty string", + ) + shared_with_user_id = shared_with_user_id.strip() + + # Cannot share with yourself + if shared_with_user_id == owner_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="You cannot share a file with yourself", + ) + + try: + existing = ( + db.query(FileShare) + .filter(FileShare.file_id == file_id, FileShare.shared_with_user_id == shared_with_user_id) + .first() + ) + + if existing: + # Update role if different + if existing.role != role: + existing.role = role + db.commit() + db.refresh(existing) + logger.info( + "Share updated: file_id=%s, shared_with=%s, role=%s, by owner=%s", + file_id, + shared_with_user_id, + role, + owner_id, + ) + return _serialize_share(existing) + + share = FileShare( + file_id=file_id, + owner_id=owner_id, + shared_with_user_id=shared_with_user_id, + role=role, + ) + db.add(share) + db.commit() + db.refresh(share) + except HTTPException: + raise + except Exception: + db.rollback() + logger.exception("Failed to create share: file_id=%s, shared_with=%s", file_id, shared_with_user_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create share", + ) + + logger.info( + "Share created: id=%s, file_id=%s, shared_with=%s, role=%s, by owner=%s", + share.id, + file_id, + shared_with_user_id, + role, + owner_id, + ) + return _serialize_share(share) + + +# --------------------------------------------------------------------------- +# Update share role +# --------------------------------------------------------------------------- + + +@router.put("/files/{file_id}/shares/{share_id}") +@require_login +def update_share( + request: Request, + file_id: int, + share_id: int, + db: DbSession, + role: str = Body(..., embed=True), +): + """Update the role of an existing share. + + Only the file owner may change the role of a share. + + Path Parameters: + file_id: The ID of the document. + share_id: The ID of the share record to update. + + Request body (JSON): + role: New role — ``"viewer"`` or ``"editor"``. + + Returns: + The updated share object. + """ + owner_id = get_current_owner_id(request) + + file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + if not file_record: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + _require_owner(file_record, owner_id, db) + + if role not in FILE_SHARE_ROLES: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"role must be one of: {', '.join(FILE_SHARE_ROLES)}", + ) + + share = db.query(FileShare).filter(FileShare.id == share_id, FileShare.file_id == file_id).first() + if not share: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share not found") + + try: + share.role = role + db.commit() + db.refresh(share) + except Exception: + db.rollback() + logger.exception("Failed to update share: share_id=%s", share_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update share", + ) + + logger.info("Share updated: id=%s, file_id=%s, new_role=%s, by owner=%s", share_id, file_id, role, owner_id) + return _serialize_share(share) + + +# --------------------------------------------------------------------------- +# Revoke share +# --------------------------------------------------------------------------- + + +@router.delete("/files/{file_id}/shares/{share_id}", status_code=status.HTTP_200_OK) +@require_login +def revoke_share(request: Request, file_id: int, share_id: int, db: DbSession): + """Revoke a share, removing the user's access. + + Only the file owner may revoke shares. + + Path Parameters: + file_id: The ID of the document. + share_id: The ID of the share record to delete. + + Returns: + A success message. + """ + owner_id = get_current_owner_id(request) + + file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + if not file_record: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + _require_owner(file_record, owner_id, db) + + share = db.query(FileShare).filter(FileShare.id == share_id, FileShare.file_id == file_id).first() + if not share: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share not found") + + try: + db.delete(share) + db.commit() + except Exception: + db.rollback() + logger.exception("Failed to revoke share: share_id=%s", share_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to revoke share", + ) + + logger.info("Share revoked: id=%s, file_id=%s, by owner=%s", share_id, file_id, owner_id) + return {"status": "success", "message": "Share revoked successfully"} + + +# --------------------------------------------------------------------------- +# List users that the file is already shared with (for the share-picker UI) +# --------------------------------------------------------------------------- + + +@router.get("/files/{file_id}/shared-with") +@require_login +def list_shared_with(request: Request, file_id: int, db: DbSession): + """Return the list of users a document is shared with and their roles. + + Accessible to any user that has at least viewer access to the file, + so that editors/viewers can see who else has access. + + Path Parameters: + file_id: The ID of the document. + + Returns: + A list of ``{share_id, user_id, display_name, role}`` objects. + """ + user_id = get_current_owner_id(request) + user = request.session.get("user") + is_admin = isinstance(user, dict) and bool(user.get("is_admin")) + + file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + if not file_record: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + role = get_file_role(file_record, user_id, db) + if role is None and not is_admin: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + shares = db.query(FileShare).filter(FileShare.file_id == file_id).all() + + results = [] + for s in shares: + profile = db.query(UserProfile).filter(UserProfile.user_id == s.shared_with_user_id).first() + results.append( + { + "share_id": s.id, + "user_id": s.shared_with_user_id, + "display_name": (profile.display_name if profile and profile.display_name else s.shared_with_user_id), + "role": s.role, + } + ) + return results diff --git a/app/api/url_upload.py b/app/api/url_upload.py index ae286ad3..e93eaea3 100644 --- a/app/api/url_upload.py +++ b/app/api/url_upload.py @@ -11,11 +11,12 @@ from typing import Optional import aiofiles import httpx -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, HttpUrl, field_validator from app.auth import require_login from app.config import settings +from app.middleware.upload_rate_limit import require_upload_rate_limit from app.tasks.process_document import process_document from app.utils.allowed_types import ALLOWED_MIME_TYPES from app.utils.filename_utils import sanitize_filename @@ -107,7 +108,11 @@ def validate_file_type(content_type: str, filename: str) -> bool: @router.post("/process-url") @require_login -async def process_url(request: Request, url_request: URLUploadRequest): +async def process_url( + request: Request, + url_request: URLUploadRequest, + _rate_ok: None = Depends(require_upload_rate_limit), +): """ Download a file from a URL and enqueue it for processing. diff --git a/app/auth.py b/app/auth.py index 17621e3d..14756c47 100644 --- a/app/auth.py +++ b/app/auth.py @@ -45,78 +45,264 @@ OAUTH_PROVIDER_NAME = "Single Sign-On" # Social login providers that are enabled and registered SOCIAL_PROVIDERS: dict[str, dict[str, str]] = {} -if AUTH_ENABLED and settings.authentik_client_id and settings.authentik_client_secret: - oauth.register( - name="authentik", - client_id=settings.authentik_client_id, - client_secret=settings.authentik_client_secret, - server_metadata_url=settings.authentik_config_url, - client_kwargs={"scope": "openid profile email"}, - ) - OAUTH_CONFIGURED = True - OAUTH_PROVIDER_NAME = settings.oauth_provider_name or "Authentik SSO" -# --- Social Login Providers --------------------------------------------------- -if AUTH_ENABLED and settings.social_auth_google_enabled: - if settings.social_auth_google_client_id and settings.social_auth_google_client_secret: - oauth.register( - name="google", - client_id=settings.social_auth_google_client_id, - client_secret=settings.social_auth_google_client_secret, - server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", +# --------------------------------------------------------------------------- +# Helpers for dynamic (re-)registration of OAuth providers +# --------------------------------------------------------------------------- + + +def _register_oauth_client(name: str, **kwargs: object) -> None: + """Register (or re-register) an authlib OAuth client, clearing any cached instance. + + authlib caches the constructed client object in ``oauth._clients`` after the + first ``register()`` call. Subsequent ``register()`` calls overwrite the + registry entry but the stale cached client is still returned by + ``create_client()`` / ``__getattr__``. Popping the name from ``_clients`` + before re-registering ensures the new credentials are picked up immediately. + + Args: + name: Provider name (e.g. ``"google"``, ``"github"``). + **kwargs: Keyword arguments forwarded verbatim to ``oauth.register()``. + """ + oauth._clients.pop(name, None) + oauth.register(name, **kwargs) + + +def _dropbox_userinfo_compliance_fix(client, user_cls, token, data): + """Normalize Dropbox userinfo response for authlib compatibility. + + Dropbox's /2/users/get_current_account returns a non-standard response + format. This compliance fix normalizes the response data — the HTTP + method (POST) is handled by authlib's compliance infrastructure. + + Args: + client: The OAuth client instance (required by authlib compliance fix interface). + user_cls: The user class (required by authlib compliance fix interface). + token: The OAuth token dict. + data: The raw userinfo response dict from Dropbox. + + Returns: + The normalized userinfo dict with ``sub`` and ``name`` fields. + """ + # Dropbox returns account_id instead of sub + if "account_id" in data and "sub" not in data: + data["sub"] = data["account_id"] + # Normalize name field + name_info = data.get("name", {}) + if isinstance(name_info, dict) and "display_name" in name_info: + data["name"] = name_info["display_name"] + return data + + +def _setup_social_providers() -> None: + """Register all configured OAuth / social-login providers from current settings. + + This function is **idempotent**: it clears ``SOCIAL_PROVIDERS``, + ``OAUTH_CONFIGURED``, and ``OAUTH_PROVIDER_NAME`` before rebuilding them, + and calls :func:`_register_oauth_client` (which also clears the authlib + client cache) so that credential changes in the database are reflected + without an application restart. + + Can safely be called multiple times, e.g. after a settings reload. + """ + global OAUTH_CONFIGURED, OAUTH_PROVIDER_NAME + + SOCIAL_PROVIDERS.clear() + OAUTH_CONFIGURED = False + OAUTH_PROVIDER_NAME = "Single Sign-On" + + if not AUTH_ENABLED: + return + + # --- Authentik / OIDC --- + if settings.authentik_client_id and settings.authentik_client_secret: + _register_oauth_client( + "authentik", + client_id=settings.authentik_client_id, + client_secret=settings.authentik_client_secret, + server_metadata_url=settings.authentik_config_url, client_kwargs={"scope": "openid profile email"}, ) - SOCIAL_PROVIDERS["google"] = {"name": "Google", "icon": "fab fa-google", "color": "red"} - logger.info("Social login provider registered: Google") - else: - logger.warning("SOCIAL_AUTH_GOOGLE_ENABLED=true but client ID/secret not configured") + OAUTH_CONFIGURED = True + OAUTH_PROVIDER_NAME = settings.oauth_provider_name or "Authentik SSO" -if AUTH_ENABLED and settings.social_auth_microsoft_enabled: - if settings.social_auth_microsoft_client_id and settings.social_auth_microsoft_client_secret: - tenant = settings.social_auth_microsoft_tenant or "common" - oauth.register( - name="microsoft", - client_id=settings.social_auth_microsoft_client_id, - client_secret=settings.social_auth_microsoft_client_secret, - server_metadata_url=f"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration", - client_kwargs={"scope": "openid profile email"}, - ) - SOCIAL_PROVIDERS["microsoft"] = {"name": "Microsoft", "icon": "fab fa-microsoft", "color": "blue"} - logger.info("Social login provider registered: Microsoft (tenant=%s)", tenant) - else: - logger.warning("SOCIAL_AUTH_MICROSOFT_ENABLED=true but client ID/secret not configured") + # --- Social Login Providers --- -if AUTH_ENABLED and settings.social_auth_apple_enabled: - if settings.social_auth_apple_client_id and settings.social_auth_apple_team_id: - oauth.register( - name="apple", - client_id=settings.social_auth_apple_client_id, - server_metadata_url="https://appleid.apple.com/.well-known/openid-configuration", - client_kwargs={ - "scope": "openid name email", - "response_mode": "form_post", - }, - ) - SOCIAL_PROVIDERS["apple"] = {"name": "Apple", "icon": "fab fa-apple", "color": "gray"} - logger.info("Social login provider registered: Apple") - else: - logger.warning("SOCIAL_AUTH_APPLE_ENABLED=true but client ID/team ID not configured") + # Google + if settings.social_auth_google_enabled: + _google_client_id = settings.social_auth_google_client_id + _google_client_secret = settings.social_auth_google_client_secret + if settings.social_auth_google_use_global_credentials and not (_google_client_id and _google_client_secret): + _google_client_id = settings.google_drive_client_id + _google_client_secret = settings.google_drive_client_secret -if AUTH_ENABLED and settings.social_auth_dropbox_enabled: - if settings.social_auth_dropbox_client_id and settings.social_auth_dropbox_client_secret: - oauth.register( - name="dropbox", - client_id=settings.social_auth_dropbox_client_id, - client_secret=settings.social_auth_dropbox_client_secret, - authorize_url="https://www.dropbox.com/oauth2/authorize", - access_token_url="https://api.dropboxapi.com/oauth2/token", - userinfo_endpoint="https://api.dropboxapi.com/2/users/get_current_account", - client_kwargs={"token_endpoint_auth_method": "client_secret_post"}, - ) - SOCIAL_PROVIDERS["dropbox"] = {"name": "Dropbox", "icon": "fab fa-dropbox", "color": "blue"} - logger.info("Social login provider registered: Dropbox") - else: - logger.warning("SOCIAL_AUTH_DROPBOX_ENABLED=true but client ID/secret not configured") + if _google_client_id and _google_client_secret: + _register_oauth_client( + "google", + client_id=_google_client_id, + client_secret=_google_client_secret, + server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", + client_kwargs={"scope": "openid profile email"}, + ) + SOCIAL_PROVIDERS["google"] = {"name": "Google", "icon": "fab fa-google", "color": "red"} + logger.info("Social login provider registered: Google") + else: + logger.warning("SOCIAL_AUTH_GOOGLE_ENABLED=true but client ID/secret not configured") + + # Microsoft + if settings.social_auth_microsoft_enabled: + _microsoft_client_id = settings.social_auth_microsoft_client_id + _microsoft_client_secret = settings.social_auth_microsoft_client_secret + if settings.social_auth_microsoft_use_global_credentials and not ( + _microsoft_client_id and _microsoft_client_secret + ): + _microsoft_client_id = settings.onedrive_client_id + _microsoft_client_secret = settings.onedrive_client_secret + + if _microsoft_client_id and _microsoft_client_secret: + tenant = settings.social_auth_microsoft_tenant or "common" + _register_oauth_client( + "microsoft", + client_id=_microsoft_client_id, + client_secret=_microsoft_client_secret, + server_metadata_url=f"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration", + client_kwargs={"scope": "openid profile email"}, + ) + SOCIAL_PROVIDERS["microsoft"] = {"name": "Microsoft", "icon": "fab fa-microsoft", "color": "blue"} + logger.info("Social login provider registered: Microsoft (tenant=%s)", tenant) + else: + logger.warning("SOCIAL_AUTH_MICROSOFT_ENABLED=true but client ID/secret not configured") + + # Apple + if settings.social_auth_apple_enabled: + if settings.social_auth_apple_client_id and settings.social_auth_apple_team_id: + _register_oauth_client( + "apple", + client_id=settings.social_auth_apple_client_id, + server_metadata_url="https://appleid.apple.com/.well-known/openid-configuration", + client_kwargs={ + "scope": "openid name email", + "response_mode": "form_post", + }, + ) + SOCIAL_PROVIDERS["apple"] = {"name": "Apple", "icon": "fab fa-apple", "color": "gray"} + logger.info("Social login provider registered: Apple") + else: + logger.warning("SOCIAL_AUTH_APPLE_ENABLED=true but client ID/team ID not configured") + + # Dropbox + if settings.social_auth_dropbox_enabled: + _dropbox_client_id = settings.social_auth_dropbox_client_id + _dropbox_client_secret = settings.social_auth_dropbox_client_secret + if settings.social_auth_dropbox_use_global_credentials and not (_dropbox_client_id and _dropbox_client_secret): + _dropbox_client_id = settings.dropbox_app_key + _dropbox_client_secret = settings.dropbox_app_secret + + if _dropbox_client_id and _dropbox_client_secret: + _register_oauth_client( + "dropbox", + client_id=_dropbox_client_id, + client_secret=_dropbox_client_secret, + authorize_url="https://www.dropbox.com/oauth2/authorize", + access_token_url="https://api.dropboxapi.com/oauth2/token", + userinfo_endpoint="https://api.dropboxapi.com/2/users/get_current_account", + userinfo_compliance_fix=_dropbox_userinfo_compliance_fix, + client_kwargs={ + "token_endpoint_auth_method": "client_secret_post", + "token_access_type": "offline", + }, + ) + SOCIAL_PROVIDERS["dropbox"] = {"name": "Dropbox", "icon": "fab fa-dropbox", "color": "blue"} + logger.info("Social login provider registered: Dropbox") + else: + logger.warning("SOCIAL_AUTH_DROPBOX_ENABLED=true but client ID/secret not configured") + + # GitHub + if settings.social_auth_github_enabled: + if settings.social_auth_github_client_id and settings.social_auth_github_client_secret: + _register_oauth_client( + "github", + client_id=settings.social_auth_github_client_id, + client_secret=settings.social_auth_github_client_secret, + authorize_url="https://github.com/login/oauth/authorize", + access_token_url="https://github.com/login/oauth/access_token", + userinfo_endpoint="https://api.github.com/user", + client_kwargs={"scope": "read:user user:email"}, + ) + SOCIAL_PROVIDERS["github"] = {"name": "GitHub", "icon": "fab fa-github", "color": "gray"} + logger.info("Social login provider registered: GitHub") + else: + logger.warning("SOCIAL_AUTH_GITHUB_ENABLED=true but client ID/secret not configured") + + # Keycloak + if settings.social_auth_keycloak_enabled: + _kc_server = settings.social_auth_keycloak_server_url + _kc_realm = settings.social_auth_keycloak_realm + if ( + settings.social_auth_keycloak_client_id + and settings.social_auth_keycloak_client_secret + and _kc_server + and _kc_realm + ): + _kc_base = f"{_kc_server.rstrip('/')}/realms/{_kc_realm}" + _register_oauth_client( + "keycloak", + client_id=settings.social_auth_keycloak_client_id, + client_secret=settings.social_auth_keycloak_client_secret, + server_metadata_url=f"{_kc_base}/.well-known/openid-configuration", + client_kwargs={"scope": "openid profile email"}, + ) + SOCIAL_PROVIDERS["keycloak"] = {"name": "Keycloak", "icon": "fas fa-key", "color": "gray"} + logger.info("Social login provider registered: Keycloak (realm=%s)", _kc_realm) + else: + logger.warning("SOCIAL_AUTH_KEYCLOAK_ENABLED=true but required settings not configured") + + # Generic OAuth2 + if settings.social_auth_generic_oauth2_enabled: + if ( + settings.social_auth_generic_oauth2_client_id + and settings.social_auth_generic_oauth2_client_secret + and settings.social_auth_generic_oauth2_authorize_url + and settings.social_auth_generic_oauth2_token_url + ): + _register_oauth_client( + "generic_oauth2", + client_id=settings.social_auth_generic_oauth2_client_id, + client_secret=settings.social_auth_generic_oauth2_client_secret, + authorize_url=settings.social_auth_generic_oauth2_authorize_url, + access_token_url=settings.social_auth_generic_oauth2_token_url, + userinfo_endpoint=settings.social_auth_generic_oauth2_userinfo_url, + client_kwargs={"scope": settings.social_auth_generic_oauth2_scope}, + ) + _generic_name = settings.social_auth_generic_oauth2_name or "OAuth2" + SOCIAL_PROVIDERS["generic_oauth2"] = { + "name": _generic_name, + "icon": "fas fa-sign-in-alt", + "color": "indigo", + } + logger.info("Social login provider registered: Generic OAuth2") + else: + logger.warning("SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED=true but required settings not configured") + + +def refresh_social_providers() -> None: + """Re-register all OAuth providers from the *current* settings object. + + Call this after loading or reloading settings from the database so that + providers configured (or updated) through the admin UI take effect + immediately — **no application restart required**. + + This function is safe to call multiple times and is idempotent. + """ + logger.info("Refreshing social login provider registrations from current settings") + _setup_social_providers() + + +# Perform the initial registration from environment / default settings at +# import time. The lifespan hook and settings_sync will call +# refresh_social_providers() again after DB settings are loaded so that +# any providers configured only in the database are also active. +_setup_social_providers() router = APIRouter() @@ -186,6 +372,16 @@ def _resolve_bearer_user(request: Request, db: Session) -> dict | None: logger.debug("[AUTH] _resolve_bearer_user: no active API token matched the provided hash") return None + # Reject tokens that have passed their optional expiry. + if db_token.expires_at is not None: + now_utc = datetime.now(timezone.utc) + expires_aware = db_token.expires_at + if expires_aware.tzinfo is None: + expires_aware = expires_aware.replace(tzinfo=timezone.utc) + if now_utc > expires_aware: + logger.debug("[AUTH] _resolve_bearer_user: API token id=%s has expired", db_token.id) + return None + logger.debug( "[AUTH] _resolve_bearer_user: matched API token id=%s owner=%s", db_token.id, @@ -331,13 +527,21 @@ async def login(request: Request): get_client_ip(request), ) + error = request.query_params.get("error") + message = request.query_params.get("message") + show_oauth = OAUTH_CONFIGURED + + # SSO Auto Login: redirect directly to SSO provider if configured + if show_oauth and settings.sso_auto_login is True and not error and not message: + return RedirectResponse(url="/oauth-login", status_code=status.HTTP_302_FOUND) + return templates.TemplateResponse( request, "login.html", context={ - "error": request.query_params.get("error"), - "message": request.query_params.get("message"), - "show_oauth": OAUTH_CONFIGURED, + "error": error, + "message": message, + "show_oauth": show_oauth, "oauth_provider_name": OAUTH_PROVIDER_NAME, "social_providers": SOCIAL_PROVIDERS, "app_version": settings.version, @@ -423,6 +627,17 @@ def _normalize_social_userinfo(provider: str, token: dict, raw_userinfo: dict | "picture": userinfo.get("profile_photo_url", ""), } + if provider == "github": + # GitHub returns login, id, name, email, avatar_url + email = userinfo.get("email", "") + return { + "sub": str(userinfo.get("id", "")), + "email": email, + "name": userinfo.get("name", "") or userinfo.get("login", ""), + "preferred_username": userinfo.get("login", email), + "picture": userinfo.get("avatar_url", ""), + } + # Standard OIDC providers (Google, Microsoft, Apple) return { "sub": userinfo.get("sub", ""), diff --git a/app/celery_worker.py b/app/celery_worker.py index cca7083b..e8debec4 100644 --- a/app/celery_worker.py +++ b/app/celery_worker.py @@ -10,6 +10,7 @@ from app import tasks # noqa: F401 - Imports app/tasks.py so Celery can registe # Import the shared Celery instance from app.celery_app import celery from app.config import settings +from app.tasks.automation_tasks import deliver_automation_hook_task # noqa: F401 from app.tasks.backup_tasks import cleanup_old_backups, create_backup # noqa: F401 from app.tasks.batch_tasks import ( # noqa: F401 backfill_missing_metadata, @@ -22,6 +23,7 @@ from app.tasks.batch_tasks import ( # noqa: F401 sync_search_index, ) from app.tasks.check_credentials import check_credentials +from app.tasks.classify_document import classify_document_task # noqa: F401 from app.tasks.compute_embedding import backfill_missing_embeddings, compute_document_embedding # noqa: F401 from app.tasks.convert_to_pdf import convert_to_pdf # noqa: F401 from app.tasks.convert_to_pdfa import convert_to_pdfa # noqa: F401 diff --git a/app/config.py b/app/config.py index 14080ac5..85631fb8 100644 --- a/app/config.py +++ b/app/config.py @@ -13,6 +13,24 @@ class Settings(BaseSettings): database_url: str redis_url: str + + # Database connection-pool tuning (ignored for SQLite, which uses NullPool). + db_pool_size: int = Field( + default=10, + description="Number of persistent connections kept in the pool per worker process.", + ) + db_max_overflow: int = Field( + default=20, + description="Additional connections allowed beyond db_pool_size under burst load.", + ) + db_pool_timeout: int = Field( + default=30, + description="Seconds to wait for a connection from the pool before raising a TimeoutError.", + ) + db_pool_recycle: int = Field( + default=1800, + description="Recycle (close and reopen) connections after this many seconds to avoid stale connections.", + ) openai_api_key: str openai_base_url: str = "https://api.openai.com/v1" # Default to OpenAI's endpoint openai_model: str = "gpt-4o-mini" # Default model @@ -102,6 +120,16 @@ class Settings(BaseSettings): dropbox_app_secret: Optional[str] = None dropbox_folder: Optional[str] = None dropbox_refresh_token: Optional[str] = None + dropbox_allow_global_credentials_for_integrations: bool = Field( + default=False, + description=( + "When True, users may authorize their personal Dropbox integrations using the global " + "DROPBOX_APP_KEY / DROPBOX_APP_SECRET credentials configured by the admin, without " + "needing to create their own Dropbox app. The Dropbox OAuth flow is initiated " + "server-side so the app secret is never exposed to the browser. " + "Default: False (each user must supply their own app credentials)." + ), + ) # Making Nextcloud optional nextcloud_enabled: bool = Field( @@ -165,6 +193,16 @@ class Settings(BaseSettings): google_docai_processor_id: Optional[str] = None google_docai_location: str = "us" # Processor location, e.g. "us" or "eu" external_hostname: str = "localhost" # Default to localhost + public_base_url: Optional[str] = Field( + default=None, + description=( + "The full public base URL of the application, including scheme " + "(e.g., 'https://docuelevate.example.com'). " + "When set, this overrides the auto-detected URL for OAuth redirect URIs. " + "This is required when the application is behind a reverse proxy that does " + "not forward X-Forwarded-Proto headers correctly." + ), + ) # --------------------------------------------------------------------------- # Document Translation Settings @@ -206,6 +244,10 @@ class Settings(BaseSettings): "Useful for admin-configured non-standard durations." ), ) + qr_login_enabled: bool = Field( + default=True, + description="Enable QR code-based login for mobile device authentication (default: True).", + ) qr_login_challenge_ttl_seconds: int = Field( default=120, description="Time-to-live in seconds for QR login challenges (default: 2 minutes).", @@ -267,12 +309,55 @@ class Settings(BaseSettings): authentik_client_secret: Optional[str] = None authentik_config_url: Optional[str] = None oauth_provider_name: Optional[str] = None # Name to display for the OAuth provider + sso_auto_login: bool = Field( + default=False, + description=( + "Automatically redirect to SSO login when authentication is required. " + "When enabled, users are sent directly to the SSO provider instead of " + "seeing the login page. Only effective when OIDC is configured." + ), + ) + + # Keycloak SSO + social_auth_keycloak_enabled: bool = False + social_auth_keycloak_client_id: Optional[str] = None + social_auth_keycloak_client_secret: Optional[str] = None + social_auth_keycloak_server_url: Optional[str] = None + social_auth_keycloak_realm: Optional[str] = None + + # Generic OAuth2 SSO + social_auth_generic_oauth2_enabled: bool = False + social_auth_generic_oauth2_client_id: Optional[str] = None + social_auth_generic_oauth2_client_secret: Optional[str] = None + social_auth_generic_oauth2_authorize_url: Optional[str] = None + social_auth_generic_oauth2_token_url: Optional[str] = None + social_auth_generic_oauth2_userinfo_url: Optional[str] = None + social_auth_generic_oauth2_scope: str = "openid profile email" + social_auth_generic_oauth2_name: str = "OAuth2" + + # SAML2 SSO + social_auth_saml2_enabled: bool = False + social_auth_saml2_entity_id: Optional[str] = None + social_auth_saml2_sso_url: Optional[str] = None + social_auth_saml2_certificate: Optional[str] = None + social_auth_saml2_name: str = "SAML2" # Social Login Providers # Google OAuth2 social_auth_google_enabled: bool = False social_auth_google_client_id: Optional[str] = None social_auth_google_client_secret: Optional[str] = None + social_auth_google_use_global_credentials: bool = Field( + default=False, + description=( + "When True, Google social login uses the global GOOGLE_DRIVE_CLIENT_ID / " + "GOOGLE_DRIVE_CLIENT_SECRET credentials (the Google Drive OAuth integration credentials) " + "instead of requiring separate SOCIAL_AUTH_GOOGLE_CLIENT_ID / " + "SOCIAL_AUTH_GOOGLE_CLIENT_SECRET values. " + "Requires SOCIAL_AUTH_GOOGLE_ENABLED=True and the global Google Drive OAuth credentials to be set. " + "Default: False." + ), + ) # Microsoft OAuth2 (Azure AD / Microsoft Entra ID) social_auth_microsoft_enabled: bool = False @@ -287,6 +372,17 @@ class Settings(BaseSettings): "Default: common." ), ) + social_auth_microsoft_use_global_credentials: bool = Field( + default=False, + description=( + "When True, Microsoft social login uses the global ONEDRIVE_CLIENT_ID / " + "ONEDRIVE_CLIENT_SECRET credentials (the OneDrive integration credentials) " + "instead of requiring separate SOCIAL_AUTH_MICROSOFT_CLIENT_ID / " + "SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET values. " + "Requires SOCIAL_AUTH_MICROSOFT_ENABLED=True and the global OneDrive credentials to be set. " + "Default: False." + ), + ) # Apple Sign-In social_auth_apple_enabled: bool = False @@ -299,6 +395,21 @@ class Settings(BaseSettings): social_auth_dropbox_enabled: bool = False social_auth_dropbox_client_id: Optional[str] = None social_auth_dropbox_client_secret: Optional[str] = None + social_auth_dropbox_use_global_credentials: bool = Field( + default=False, + description=( + "When True, Dropbox social login uses the global DROPBOX_APP_KEY / DROPBOX_APP_SECRET " + "credentials (the storage integration credentials) instead of requiring separate " + "SOCIAL_AUTH_DROPBOX_CLIENT_ID / SOCIAL_AUTH_DROPBOX_CLIENT_SECRET values. " + "Requires SOCIAL_AUTH_DROPBOX_ENABLED=True and the global Dropbox app credentials to be set. " + "Default: False." + ), + ) + + # GitHub OAuth2 + social_auth_github_enabled: bool = False + social_auth_github_client_id: Optional[str] = None + social_auth_github_client_secret: Optional[str] = None # Local user signup allow_local_signup: bool = Field( @@ -796,6 +907,11 @@ class Settings(BaseSettings): ), ) + # Telegram Bot + telegram_bot_token: Optional[str] = None + telegram_chat_id: Optional[str] = None + telegram_enabled: bool = False + # Notification settings notification_urls: Union[List[str], str] = Field( default_factory=list, @@ -830,6 +946,12 @@ class Settings(BaseSettings): description="Enable webhook delivery for document events", ) + # Automation hooks (Zapier / Make.com) + automation_hooks_enabled: bool = Field( + default=True, + description="Enable Zapier / Make.com automation hook subscriptions and delivery", + ) + # ── Backup / restore settings ────────────────────────────────────────────── backup_enabled: bool = Field( default=True, @@ -1115,43 +1237,18 @@ class Settings(BaseSettings): ), ) - # Database Connection Pool Configuration - # Controls SQLAlchemy QueuePool behaviour for PostgreSQL/MySQL. - # SQLite uses NullPool and ignores these settings. - db_pool_size: int = Field( - default=5, - description="Number of persistent connections kept in the pool. Ignored for SQLite.", - ) - db_max_overflow: int = Field( - default=10, - description=("Maximum number of connections that can be opened beyond db_pool_size. Ignored for SQLite."), - ) - db_pool_timeout: int = Field( - default=30, - description="Seconds to wait for a connection from the pool before raising an error. Ignored for SQLite.", - ) - db_pool_recycle: int = Field( - default=1800, - description=( - "Seconds after which a connection is recycled to prevent stale connections. " - "Ignored for SQLite. Default: 1800 (30 minutes)." - ), - ) - - # Per-user upload rate limiting (health-aware limiter) - # Controls how many uploads a single user may submit within a sliding window. + # Per-user upload rate limiting (health-aware, Redis-backed sliding window) upload_rate_limit_per_user: int = Field( default=20, description=( - "Maximum number of uploads allowed per user within the upload_rate_limit_window. " - "The limiter may dynamically reduce this value when Redis queue depth or CPU load is high." + "Maximum number of file uploads allowed per user within the sliding window. " + "The effective limit may be reduced dynamically when the system is under heavy load " + "(high queue depth or CPU usage). Set to 0 to disable per-user upload rate limiting." ), ) upload_rate_limit_window: int = Field( default=60, - description=( - "Sliding window in seconds over which upload_rate_limit_per_user is enforced. Default: 60 seconds." - ), + description="Sliding window size in seconds for per-user upload rate limiting (default: 60).", ) # Rate Limiting Configuration (see SECURITY_AUDIT.md and docs/API.md) @@ -1280,6 +1377,40 @@ class Settings(BaseSettings): ), ) + # --------------------------------------------------------------------------- + # Observability – Sentry Browser JavaScript SDK (client-side) + # --------------------------------------------------------------------------- + # The same SENTRY_DSN is reused for the browser SDK. The DSN is a *public* + # key in Sentry's model and is intentionally embedded in client-side code. + # All three settings below default to 0.0 / disabled so that operators opt-in + # to the level of browser monitoring they want. + # --------------------------------------------------------------------------- + sentry_js_traces_sample_rate: float = Field( + default=0.0, + description=( + "Fraction of browser page-loads captured for client-side performance tracing " + "(0.0 – 1.0). 0.0 disables browser tracing; 1.0 captures every navigation. " + "Only active when SENTRY_DSN is set." + ), + ) + sentry_js_replay_session_sample_rate: float = Field( + default=0.0, + description=( + "Fraction of sessions recorded by Sentry Session Replay (0.0 – 1.0). " + "0.0 disables session recording; 1.0 records every session. " + "Only active when SENTRY_DSN is set." + ), + ) + sentry_js_replay_on_error_sample_rate: float = Field( + default=0.1, + description=( + "Fraction of sessions with an error that will be recorded by Sentry Session " + "Replay (0.0 – 1.0). Defaults to 0.1 (10 %) so that errors are captured " + "with replay context even when session-level recording is disabled. " + "Only active when SENTRY_DSN is set." + ), + ) + @model_validator(mode="before") @classmethod def strip_outer_quotes(cls, data: Any) -> Any: diff --git a/app/database.py b/app/database.py index 06fc7471..701e4673 100644 --- a/app/database.py +++ b/app/database.py @@ -18,22 +18,37 @@ logger = logging.getLogger(__name__) Base = declarative_base() -# Parse the DATABASE_URL +# --------------------------------------------------------------------------- +# Engine construction +# --------------------------------------------------------------------------- DB_URL = settings.database_url -_db_url = make_url(DB_URL) -if _db_url.get_backend_name() == "sqlite": - # SQLite does not benefit from connection pooling; NullPool avoids contention. - engine = create_engine(DB_URL, connect_args={"check_same_thread": False}, poolclass=NullPool) +_parsed_url = make_url(DB_URL) + +_connect_args: dict[str, Any] = {} +_engine_kwargs: dict[str, Any] = { + "pool_pre_ping": True, # detect stale / dropped connections before use +} + +if _parsed_url.get_backend_name() == "sqlite": + # SQLite does not benefit from connection pooling and is prone to + # QueuePool exhaustion under concurrent access. NullPool opens a fresh + # connection for each request and closes it immediately afterwards, + # completely avoiding the "QueuePool limit reached" TimeoutError. + _connect_args["check_same_thread"] = False + _engine_kwargs["poolclass"] = NullPool else: - # PostgreSQL / MySQL / other: use a configurable QueuePool. - engine = create_engine( - DB_URL, - poolclass=QueuePool, - pool_size=settings.db_pool_size, - max_overflow=settings.db_max_overflow, - pool_timeout=settings.db_pool_timeout, - pool_recycle=settings.db_pool_recycle, + # PostgreSQL / MySQL — use a bounded QueuePool with configurable limits. + _engine_kwargs["poolclass"] = QueuePool + _engine_kwargs.update( + { + "pool_size": settings.db_pool_size, + "max_overflow": settings.db_max_overflow, + "pool_timeout": settings.db_pool_timeout, + "pool_recycle": settings.db_pool_recycle, + } ) + +engine = create_engine(DB_URL, connect_args=_connect_args, **_engine_kwargs) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/app/main.py b/app/main.py index 5c9f4fc9..b11b3148 100644 --- a/app/main.py +++ b/app/main.py @@ -189,6 +189,18 @@ async def lifespan(app: FastAPI): finally: db.close() + # Re-register OAuth / social-login providers now that DB settings are + # loaded. auth.py runs its initial registration at import time (before + # the lifespan runs), so providers that are only configured in the + # database would not be registered yet. Calling refresh here ensures + # they are active immediately on startup without any manual restart. + try: + from app.auth import refresh_social_providers + + refresh_social_providers() + except Exception as e: + logging.warning(f"Could not refresh social login providers on startup: {e}") + # Initialize Sentry after DB settings are loaded so that values configured # via the database UI (e.g. SENTRY_DSN) are respected in addition to env vars. init_sentry() @@ -282,16 +294,10 @@ async def lifespan(app: FastAPI): yield # Shutdown: Cleanup tasks - try: - logging.info("Application shutting down") - except Exception: # noqa: S110 - pass # During test teardown, logging streams may already be closed + logging.info("Application shutting down") # Send shutdown notification - try: - notify_shutdown() - except Exception: # noqa: S110 - pass # During test teardown, I/O streams may already be closed + notify_shutdown() app = FastAPI( diff --git a/app/middleware/upload_rate_limit.py b/app/middleware/upload_rate_limit.py new file mode 100644 index 00000000..16b7d907 --- /dev/null +++ b/app/middleware/upload_rate_limit.py @@ -0,0 +1,290 @@ +"""Per-user, health-aware upload rate limiter for DocuElevate. + +This module provides a FastAPI dependency that enforces per-user upload rate +limits using a Redis-backed sliding window counter. The effective limit is +dynamically reduced when the system is under heavy load (high Celery queue +depth or elevated CPU load average), ensuring the server remains responsive +to all users even during bulk-upload scenarios. + +Usage in an endpoint:: + + from app.middleware.upload_rate_limit import require_upload_rate_limit + + @router.post("/ui-upload") + @require_login + async def ui_upload( + request: Request, + _rate_ok: None = Depends(require_upload_rate_limit), + ... + ): + ... + +See ``docs/ConfigurationGuide.md`` for the configuration options +(``UPLOAD_RATE_LIMIT_PER_USER``, ``UPLOAD_RATE_LIMIT_WINDOW``). +""" + +from __future__ import annotations + +import logging +import os +import time +from typing import Any + +import redis +from fastapi import HTTPException, Request, status + +from app.config import settings +from app.utils.user_scope import get_current_owner_id + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Redis key prefix +# --------------------------------------------------------------------------- +_KEY_PREFIX = "docuelevate:upload_rate" + +# --------------------------------------------------------------------------- +# Health-check queue names (Celery defaults used by DocuElevate) +# --------------------------------------------------------------------------- +_CELERY_QUEUES = ("document_processor", "default", "celery") + +# --------------------------------------------------------------------------- +# Singleton Redis client (lazy-initialised; fail-open when unavailable) +# --------------------------------------------------------------------------- +_redis_client: redis.Redis | None = None + + +def _get_redis() -> redis.Redis | None: + """Return a shared Redis client, or *None* when Redis is unavailable.""" + global _redis_client + if _redis_client is not None: + return _redis_client + try: + _redis_client = redis.Redis.from_url( + settings.redis_url, + decode_responses=True, + socket_connect_timeout=2, + socket_timeout=2, + ) + # Quick connectivity check – raises on failure. + _redis_client.ping() + return _redis_client + except Exception: # noqa: BLE001 + logger.debug("Redis unavailable for upload rate limiter – falling back to allow-all", exc_info=True) + _redis_client = None + return None + + +# --------------------------------------------------------------------------- +# Health metrics helpers +# --------------------------------------------------------------------------- + + +def _get_queue_depth(r: redis.Redis) -> int: + """Return the total number of pending tasks across all Celery queues.""" + total = 0 + for queue_name in _CELERY_QUEUES: + try: + total += r.llen(queue_name) + except Exception: # noqa: BLE001, S110 + logger.debug("Could not read queue length for %r", queue_name, exc_info=True) + return total + + +def _get_cpu_load_ratio() -> float: + """Return the 1-minute load average divided by the number of CPU cores. + + Returns ``0.0`` on platforms that do not support :func:`os.getloadavg` + (e.g. Windows) so that the limiter never penalises on those systems. + """ + try: + load_1m = os.getloadavg()[0] + cpu_count = os.cpu_count() or 1 + return load_1m / cpu_count + except (OSError, AttributeError): + return 0.0 + + +def compute_effective_limit( + base_limit: int, + queue_depth: int = 0, + cpu_load_ratio: float = 0.0, +) -> tuple[int, float, str]: + """Compute the effective upload rate limit based on system health. + + The function applies a *reduction factor* (``0.0 < factor ≤ 1.0``) to the + configured base limit. Both queue depth and CPU load contribute + independently; the lowest factor wins. + + Args: + base_limit: The configured maximum uploads per window. + queue_depth: Total pending tasks in Celery queues. + cpu_load_ratio: 1-minute load average divided by CPU count. + + Returns: + A 3-tuple of ``(effective_limit, factor, reason)`` where *reason* + is a human-readable tag for logging. + """ + factor = 1.0 + reason = "normal" + + # --- Queue-depth thresholds --- + if queue_depth > 200: + factor, reason = min(factor, 0.10), f"critical_queue({queue_depth})" + elif queue_depth > 100: + factor, reason = min(factor, 0.25), f"high_queue({queue_depth})" + elif queue_depth > 50: + factor, reason = min(factor, 0.50), f"moderate_queue({queue_depth})" + + # --- CPU-load thresholds --- + if cpu_load_ratio > 3.0: + new_factor = 0.10 + if new_factor < factor: + factor, reason = new_factor, f"critical_cpu({cpu_load_ratio:.1f})" + elif cpu_load_ratio > 2.0: + new_factor = 0.25 + if new_factor < factor: + factor, reason = new_factor, f"high_cpu({cpu_load_ratio:.1f})" + elif cpu_load_ratio > 1.5: + new_factor = 0.50 + if new_factor < factor: + factor, reason = new_factor, f"moderate_cpu({cpu_load_ratio:.1f})" + + effective = max(1, int(base_limit * factor)) + return effective, factor, reason + + +# --------------------------------------------------------------------------- +# Core sliding-window check (Redis sorted set) +# --------------------------------------------------------------------------- + + +def _check_and_record( + r: redis.Redis, + user_id: str, + window: int, + effective_limit: int, +) -> dict[str, Any] | None: + """Atomically check the user's upload count and record the new upload. + + Uses a Redis sorted set where each member is a unique timestamp-based ID + and the score is the Unix timestamp. Entries older than *window* seconds + are pruned on every call so the set never grows unbounded. + + Returns: + ``None`` if the request is allowed, or a ``dict`` with ``count``, + ``limit``, and ``retry_after`` if the limit is exceeded. + """ + key = f"{_KEY_PREFIX}:{user_id}" + now = time.time() + window_start = now - window + + pipe = r.pipeline(transaction=True) + # 1. Remove entries outside the window + pipe.zremrangebyscore(key, "-inf", window_start) + # 2. Count current entries + pipe.zcard(key) + # 3. Retrieve the oldest entry's score (to compute retry_after) + pipe.zrange(key, 0, 0, withscores=True) + results = pipe.execute() + + current_count: int = results[1] + oldest_entries: list = results[2] + + if current_count >= effective_limit: + # Compute how long until the oldest entry expires from the window. + if oldest_entries: + oldest_score = oldest_entries[0][1] + retry_after = max(1, int((oldest_score + window) - now)) + else: + retry_after = max(1, window // 2) + return { + "count": current_count, + "limit": effective_limit, + "retry_after": retry_after, + } + + # 4. Record this upload (unique member = timestamp with random suffix) + member = f"{now}:{os.urandom(4).hex()}" + pipe2 = r.pipeline(transaction=True) + pipe2.zadd(key, {member: now}) + pipe2.expire(key, window + 60) # TTL slightly longer than window + pipe2.execute() + + return None + + +# --------------------------------------------------------------------------- +# FastAPI dependency +# --------------------------------------------------------------------------- + + +async def require_upload_rate_limit(request: Request) -> None: + """FastAPI dependency that enforces per-user upload rate limits. + + The dependency is designed to **fail open**: if Redis is unavailable the + request is allowed through so that uploads are never blocked by a + monitoring outage. + + Raises: + HTTPException: 429 Too Many Requests when the per-user upload limit + is exceeded. The ``Retry-After`` header indicates how many + seconds the client should wait before retrying. + """ + r = _get_redis() + if r is None: + # Redis unavailable – fail open. + return + + # Identify the user (owner_id for multi-user, IP fallback). + user_id = get_current_owner_id(request) + if not user_id: + user_id = f"ip:{request.client.host}" if request.client else "ip:unknown" + + base_limit: int = settings.upload_rate_limit_per_user + window: int = settings.upload_rate_limit_window + + # Gather health metrics and compute effective limit. + try: + queue_depth = _get_queue_depth(r) + except Exception: # noqa: BLE001 + queue_depth = 0 + + cpu_load_ratio = _get_cpu_load_ratio() + effective_limit, factor, health_reason = compute_effective_limit(base_limit, queue_depth, cpu_load_ratio) + + # Sliding-window check. + try: + rejection = _check_and_record(r, user_id, window, effective_limit) + except Exception as exc: # noqa: BLE001 + logger.warning("Upload rate-limit check failed (allowing request): %s", exc) + return + + if rejection is not None: + retry_after = rejection["retry_after"] + logger.warning( + "Upload rate limit exceeded: user=%s count=%d/%d window=%ds health=%s retry_after=%ds", + user_id, + rejection["count"], + rejection["limit"], + window, + health_reason, + retry_after, + ) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=( + f"Upload rate limit exceeded ({rejection['count']}/{rejection['limit']} " + f"in {window}s). Retry after {retry_after}s." + ), + headers={"Retry-After": str(retry_after)}, + ) + + if factor < 1.0: + logger.info( + "Upload allowed with reduced limit: user=%s effective=%d/%d health=%s", + user_id, + effective_limit, + base_limit, + health_reason, + ) diff --git a/app/models.py b/app/models.py index 5f406953..4591a8f2 100644 --- a/app/models.py +++ b/app/models.py @@ -211,6 +211,28 @@ class WebhookConfig(Base): updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) +class AutomationHook(Base): + """Zapier / Make.com compatible webhook subscription for automation triggers. + + External automation platforms subscribe to DocuElevate events via the REST + hooks protocol. When an event fires, DocuElevate POSTs a Zapier-compatible + flat JSON payload to ``target_url``. The ``hook_type`` field records which + platform created the subscription (informational only). + """ + + __tablename__ = "automation_hooks" + + id = Column(Integer, primary_key=True, index=True) + target_url = Column(String, nullable=False) # URL to POST events to + secret = Column(String, nullable=True) # Optional HMAC-SHA256 signing secret + events = Column(Text, nullable=False) # JSON list of subscribed event names + is_active = Column(Boolean, default=True, nullable=False) + hook_type = Column(String(50), nullable=False, default="generic") # zapier | make | generic + description = Column(String, nullable=True) # Optional human-readable label + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + class LocalUser(Base): """A locally-registered user authenticated by email and bcrypt password. @@ -786,6 +808,9 @@ class ApiToken(Base): created_at = Column(DateTime(timezone=True), server_default=func.now()) revoked_at = Column(DateTime(timezone=True), nullable=True) + # Optional expiry: if set, the token is rejected after this timestamp. + expires_at = Column(DateTime(timezone=True), nullable=True) + class SharedLink(Base): """Shareable, time-limited or view-limited document link. @@ -937,6 +962,51 @@ class ScheduledJob(Base): updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) +class ClassificationRuleModel(Base): + """Custom document classification rule. + + Rules are evaluated during the ``classify`` pipeline step to assign a + category to a document. System-wide rules have ``owner_id IS NULL``; + user-specific rules belong to a single owner. + """ + + __tablename__ = "classification_rules" + + id = Column(Integer, primary_key=True, index=True) + + # NULL = system-wide rule visible to all users. + owner_id = Column(String, nullable=True, index=True) + + # Human-readable rule name (unique per owner). + name = Column(String(255), nullable=False) + + # Target category (e.g. "invoice", "contract", "receipt"). + category = Column(String(100), nullable=False, index=True) + + # Rule type: "filename_pattern", "content_keyword", or "metadata_match". + rule_type = Column(String(50), nullable=False) + + # The matching pattern: + # - filename_pattern: a regex + # - content_keyword: pipe-separated keywords + # - metadata_match: "field=value" + pattern = Column(String(1000), nullable=False) + + # Higher priority rules are evaluated first (default 0). + priority = Column(Integer, nullable=False, default=0) + + # Whether pattern matching is case-sensitive. + case_sensitive = Column(Boolean, nullable=False, default=False) + + # Disabled rules are skipped during classification. + enabled = Column(Boolean, nullable=False, default=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + __table_args__ = (UniqueConstraint("owner_id", "name", name="uq_classification_rules_owner_name"),) + + class MobileDevice(Base): """Registered mobile device for push notifications. @@ -1122,3 +1192,97 @@ class PipelineRoutingRule(Base): created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class DocumentComment(Base): + """Threaded comment on a document. + + Supports threaded replies via ``parent_id`` and @mentions via the + ``mentions`` column (comma-separated user identifiers). + """ + + __tablename__ = "document_comments" + + id = Column(Integer, primary_key=True, index=True) + file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True) + user_id = Column(String, nullable=False, index=True) + parent_id = Column(Integer, ForeignKey("document_comments.id"), nullable=True, index=True) + body = Column(Text, nullable=False) + mentions = Column(Text, nullable=True) + is_resolved = Column(Boolean, nullable=False, default=False, server_default="0") + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class DocumentAnnotation(Base): + """Text annotation on a specific page and position of a PDF document. + + Stores the bounding-box coordinates (``x``, ``y``, ``width``, + ``height``) relative to the page dimensions so that the annotation + can be rendered on top of the PDF viewer. + """ + + __tablename__ = "document_annotations" + + id = Column(Integer, primary_key=True, index=True) + file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True) + user_id = Column(String, nullable=False, index=True) + page = Column(Integer, nullable=False) + x = Column(Float, nullable=False) + y = Column(Float, nullable=False) + width = Column(Float, nullable=False, default=0) + height = Column(Float, nullable=False, default=0) + content = Column(Text, nullable=False) + annotation_type = Column(String(50), nullable=False, default="note", server_default="note") + color = Column(String(20), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +# --------------------------------------------------------------------------- +# File sharing +# --------------------------------------------------------------------------- + +# Valid roles for FileShare.role +FILE_SHARE_ROLE_VIEWER = "viewer" +FILE_SHARE_ROLE_EDITOR = "editor" +FILE_SHARE_ROLES = (FILE_SHARE_ROLE_VIEWER, FILE_SHARE_ROLE_EDITOR) + + +class FileShare(Base): + """Grants a named user access to a ``FileRecord`` owned by someone else. + + The ``owner_id`` column records who created the share (must be the file + owner). ``shared_with_user_id`` is the recipient's stable user + identifier (the same kind of string used in ``FileRecord.owner_id``). + + Roles + ----- + ``viewer`` — can read the file, comments, and annotations; may add + comments/annotations; cannot delete or share. + ``editor`` — all viewer rights plus the ability to edit document + metadata; cannot delete or re-share. + + Only the file owner may create, update, or revoke shares. + """ + + __tablename__ = "file_shares" + + id = Column(Integer, primary_key=True, index=True) + + # The document being shared. + file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True) + + # The user who granted the share (must match FileRecord.owner_id). + owner_id = Column(String, nullable=False, index=True) + + # The user receiving the share. + shared_with_user_id = Column(String, nullable=False, index=True) + + # "viewer" or "editor" + role = Column(String(20), nullable=False, default=FILE_SHARE_ROLE_VIEWER) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + __table_args__ = (UniqueConstraint("file_id", "shared_with_user_id", name="uq_file_share_file_user"),) diff --git a/app/tasks/automation_tasks.py b/app/tasks/automation_tasks.py new file mode 100644 index 00000000..18851f48 --- /dev/null +++ b/app/tasks/automation_tasks.py @@ -0,0 +1,44 @@ +"""Celery task for asynchronous automation hook delivery with retry and backoff. + +Uses :class:`~app.tasks.retry_config.BaseTaskWithRetry` so failed deliveries +are automatically retried with exponential backoff (default: 60 s, 300 s, +900 s) and ±20 % jitter. +""" + +import logging +from typing import Any + +from app.celery_app import celery +from app.tasks.retry_config import BaseTaskWithRetry +from app.utils.webhook import deliver_webhook + +logger = logging.getLogger(__name__) + + +@celery.task(base=BaseTaskWithRetry, bind=True, name="automation.deliver_hook") +def deliver_automation_hook_task(self, url: str, payload: dict[str, Any], secret: str | None = None) -> dict[str, Any]: + """Deliver an automation hook payload to *url* with automatic retries. + + Args: + url: Target webhook URL (provided by Zapier / Make.com). + payload: The flat Zapier-compatible payload. + secret: Optional shared secret for HMAC-SHA256 signing. + + Returns: + A dict with ``status`` and ``url`` on success. + + Raises: + RuntimeError: Re-raised to trigger Celery retry on delivery failure. + """ + logger.info( + "Delivering automation hook to %s (attempt %d/%d)", + url, + self.request.retries + 1, + self.max_retries + 1, + ) + + success = deliver_webhook(url, payload, secret) + if success: + return {"status": "delivered", "url": url} + + raise RuntimeError(f"Automation hook delivery to {url} failed") diff --git a/app/tasks/classify_document.py b/app/tasks/classify_document.py new file mode 100644 index 00000000..d87f9f13 --- /dev/null +++ b/app/tasks/classify_document.py @@ -0,0 +1,174 @@ +"""Celery task for rule-based document classification. + +This task is executed as a pipeline step (``step_type="classify"``). It +applies built-in and user-defined classification rules against the document's +filename, OCR text, and existing AI metadata to assign a ``document_type`` +category. + +The result is stored in the ``ai_metadata`` JSON blob on the +:class:`~app.models.FileRecord` (field ``classification``). +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from app.celery_app import celery +from app.database import SessionLocal +from app.models import ClassificationRuleModel, FileRecord +from app.tasks.retry_config import BaseTaskWithRetry +from app.utils import log_task_progress +from app.utils.classification_rules import ( + ClassificationResult, + classify_document, + db_rule_to_engine_rule, +) + +logger = logging.getLogger(__name__) + +STEP_NAME = "classify_document" + + +def _load_custom_rules(owner_id: str | None) -> list[Any]: + """Load enabled custom classification rules from the database. + + Returns engine-level :class:`ClassificationRule` dataclass instances. + Rules are loaded in priority-descending order. System rules + (``owner_id IS NULL``) and the user's own rules are both included. + """ + with SessionLocal() as db: + query = db.query(ClassificationRuleModel).filter(ClassificationRuleModel.enabled.is_(True)) + if owner_id: + query = query.filter( + (ClassificationRuleModel.owner_id.is_(None)) | (ClassificationRuleModel.owner_id == owner_id) + ) + else: + query = query.filter(ClassificationRuleModel.owner_id.is_(None)) + rules = query.order_by(ClassificationRuleModel.priority.desc()).all() + return [db_rule_to_engine_rule(r) for r in rules] + + +@celery.task(base=BaseTaskWithRetry, bind=True) +def classify_document_task( + self: Any, + file_id: int, + owner_id: str | None = None, +) -> dict[str, Any]: + """Classify a document using rule-based matching. + + This task: + 1. Loads the :class:`FileRecord` from the database. + 2. Gathers filename, OCR text, and existing AI metadata. + 3. Loads built-in + user-defined classification rules. + 4. Runs the classification engine. + 5. Persists the result into ``ai_metadata.classification``. + + Args: + file_id: Primary key of the :class:`FileRecord` to classify. + owner_id: Owner identifier for loading user-specific rules. + + Returns: + Dict with ``category``, ``confidence``, and ``matched_rules``. + """ + task_id = self.request.id + + log_task_progress( + task_id, + STEP_NAME, + "in_progress", + f"Starting classification for file {file_id}", + file_id=file_id, + ) + + try: + with SessionLocal() as db: + file_record: FileRecord | None = db.query(FileRecord).filter(FileRecord.id == file_id).first() + if file_record is None: + log_task_progress( + task_id, + STEP_NAME, + "failure", + f"FileRecord {file_id} not found", + file_id=file_id, + ) + return {"status": "error", "detail": "File not found"} + + # Gather inputs + filename = file_record.original_filename or "" + text = file_record.ocr_text or "" + existing_metadata: dict[str, Any] = {} + if file_record.ai_metadata: + try: + existing_metadata = json.loads(file_record.ai_metadata) + except (json.JSONDecodeError, TypeError): + logger.warning("Failed to parse ai_metadata for file %s, starting fresh", file_id) + existing_metadata = {} + + # Load custom rules + effective_owner = owner_id or file_record.owner_id + custom_rules = _load_custom_rules(effective_owner) + + # Run classification engine + result: ClassificationResult = classify_document( + filename=filename, + text=text, + metadata=existing_metadata, + custom_rules=custom_rules, + ) + + # Persist result into ai_metadata + classification_data = { + "category": result.category, + "confidence": result.confidence, + "matched_rules": [ + { + "rule_name": m.rule_name, + "rule_type": m.rule_type, + "category": m.category, + "confidence": m.confidence, + } + for m in result.matched_rules + ], + } + + existing_metadata["classification"] = classification_data + + # If no document_type was set yet, populate it from the classification + if not existing_metadata.get("document_type"): + from app.utils.classification_rules import BUILTIN_CATEGORIES + + existing_metadata["document_type"] = BUILTIN_CATEGORIES.get( + result.category, result.category.replace("_", " ").title() + ) + + file_record.ai_metadata = json.dumps(existing_metadata, ensure_ascii=False) + db.commit() + + log_task_progress( + task_id, + STEP_NAME, + "success", + f"Classified as '{result.category}' with confidence {result.confidence}", + file_id=file_id, + detail=f"Matched {len(result.matched_rules)} rule(s)", + ) + + return { + "status": "success", + "category": result.category, + "confidence": result.confidence, + "matched_rules": len(result.matched_rules), + } + + except Exception as e: + logger.exception("Classification failed for file %s: %s", file_id, e) + log_task_progress( + task_id, + STEP_NAME, + "failure", + f"Classification failed: {e}", + file_id=file_id, + ) + raise diff --git a/app/tasks/upload_to_nextcloud.py b/app/tasks/upload_to_nextcloud.py index b8dc52ba..1bb29c75 100644 --- a/app/tasks/upload_to_nextcloud.py +++ b/app/tasks/upload_to_nextcloud.py @@ -1,141 +1,157 @@ -#!/usr/bin/env python3 - -import logging -import os - -import requests -from requests.auth import HTTPBasicAuth - -from app.celery_app import celery -from app.config import settings -from app.tasks.retry_config import UploadTaskWithRetry -from app.utils import log_task_progress -from app.utils.filename_utils import extract_remote_path, get_unique_filename -from app.utils.network import join_url - -logger = logging.getLogger(__name__) - - -@celery.task(base=UploadTaskWithRetry, bind=True) -def upload_to_nextcloud(self, file_path: str, file_id: int = None, folder_override: str = None): - """ - Upload a file to Nextcloud WebDAV. - - Args: - file_path: Path to the file to upload - file_id: Optional file ID to associate with logs - """ - task_id = self.request.id - logger.info(f"[{task_id}] Starting Nextcloud upload: {file_path}") - log_task_progress( - task_id, - "upload_to_nextcloud", - "in_progress", - f"Uploading to Nextcloud: {os.path.basename(file_path)}", - file_id=file_id, - ) - - if not os.path.exists(file_path): - error_msg = f"File not found: {file_path}" - logger.error(f"[{task_id}] {error_msg}") - log_task_progress(task_id, "upload_to_nextcloud", "failure", error_msg, file_id=file_id) - raise FileNotFoundError(error_msg) - - # For Nextcloud, we need to check for 'nextcloud_upload_url' instead of 'nextcloud_url' - # This is what's shown in your env view - if not ( - getattr(settings, "nextcloud_upload_url", None) - and getattr(settings, "nextcloud_username", None) - and getattr(settings, "nextcloud_password", None) - ): - logger.info(f"[{task_id}] Nextcloud upload skipped: Missing configuration") - log_task_progress(task_id, "upload_to_nextcloud", "success", "Skipped: Not configured", file_id=file_id) - return {"status": "Skipped", "reason": "Nextcloud settings not configured"} - - filename = os.path.basename(file_path) - - try: - # Prepare WebDAV URL - use nextcloud_upload_url instead of nextcloud_url - webdav_url = settings.nextcloud_upload_url - if not webdav_url.endswith("/"): - webdav_url += "/" - - # Calculate remote path based on local file structure - remote_base = ( - folder_override if folder_override is not None else (getattr(settings, "nextcloud_folder", "") or "") - ) - remote_path = extract_remote_path(file_path, settings.workdir, remote_base) - full_url = join_url(webdav_url, remote_path) - - # Function to check if file exists in Nextcloud - def check_exists_in_nextcloud(path): - check_url = join_url(webdav_url, os.path.dirname(path)) - try: - response = requests.request( - "PROPFIND", - check_url, - auth=HTTPBasicAuth(settings.nextcloud_username, settings.nextcloud_password), - headers={"Depth": "1"}, - timeout=10, - ) - - return path in response.text - except Exception: - # If we can't check, assume it doesn't exist - return False - - # Check for potential file collision and get a unique name if needed - remote_path = get_unique_filename(remote_path, check_exists_in_nextcloud) - full_url = join_url(webdav_url, remote_path) - - # Create necessary parent folders - parent_dirs = os.path.dirname(remote_path) - if parent_dirs: - current_path = "" - for folder in parent_dirs.split("/"): - if not folder: - continue - current_path += f"{folder}/" - mkdir_url = join_url(webdav_url, current_path) - - requests.request( - "MKCOL", - mkdir_url, - auth=HTTPBasicAuth(settings.nextcloud_username, settings.nextcloud_password), - timeout=10, - ) - - # Upload the file - logger.info(f"[{task_id}] Uploading {filename} to Nextcloud at {full_url}") - log_task_progress(task_id, "upload_file", "in_progress", f"Uploading to {remote_path}", file_id=file_id) - with open(file_path, "rb") as file_data: - response = requests.put( - full_url, - data=file_data, - auth=HTTPBasicAuth(settings.nextcloud_username, settings.nextcloud_password), - headers={"Content-Type": "application/octet-stream"}, - timeout=settings.http_request_timeout, # Use configured timeout for large files - ) - - if response.status_code in (201, 204): # Created or No Content - logger.info(f"[{task_id}] Successfully uploaded {filename} to Nextcloud at {remote_path}") - log_task_progress( - task_id, "upload_to_nextcloud", "success", f"Uploaded to Nextcloud: {remote_path}", file_id=file_id - ) - return { - "status": "Completed", - "file_path": file_path, - "nextcloud_path": remote_path, - "response_code": response.status_code, - } - else: - error_msg = f"Failed to upload {filename} to Nextcloud: {response.status_code} - {response.text}" - logger.error(f"[{task_id}] {error_msg}") - log_task_progress(task_id, "upload_to_nextcloud", "failure", error_msg, file_id=file_id) - raise Exception(error_msg) - - except Exception as e: - error_msg = f"Failed to upload {filename} to Nextcloud: {str(e)}" - logger.error(f"[{task_id}] {error_msg}") - log_task_progress(task_id, "upload_to_nextcloud", "failure", error_msg, file_id=file_id) - raise Exception(error_msg) +#!/usr/bin/env python3 + +import logging +import os + +import requests +from requests.auth import HTTPBasicAuth + +from app.celery_app import celery +from app.config import settings +from app.tasks.retry_config import UploadTaskWithRetry +from app.utils import log_task_progress +from app.utils.filename_utils import extract_remote_path, get_unique_filename + +logger = logging.getLogger(__name__) + + +@celery.task(base=UploadTaskWithRetry, bind=True) +def upload_to_nextcloud(self, file_path: str, file_id: int = None, folder_override: str = None): + """ + Upload a file to Nextcloud WebDAV. + + Args: + file_path: Path to the file to upload + file_id: Optional file ID to associate with logs + """ + task_id = self.request.id + logger.info(f"[{task_id}] Starting Nextcloud upload: {file_path}") + log_task_progress( + task_id, + "upload_to_nextcloud", + "in_progress", + f"Uploading to Nextcloud: {os.path.basename(file_path)}", + file_id=file_id, + ) + + if not os.path.exists(file_path): + error_msg = f"File not found: {file_path}" + logger.error(f"[{task_id}] {error_msg}") + log_task_progress(task_id, "upload_to_nextcloud", "failure", error_msg, file_id=file_id) + raise FileNotFoundError(error_msg) + + # For Nextcloud, we need to check for 'nextcloud_upload_url' instead of 'nextcloud_url' + # This is what's shown in your env view + if not ( + getattr(settings, "nextcloud_upload_url", None) + and getattr(settings, "nextcloud_username", None) + and getattr(settings, "nextcloud_password", None) + ): + logger.info(f"[{task_id}] Nextcloud upload skipped: Missing configuration") + log_task_progress(task_id, "upload_to_nextcloud", "success", "Skipped: Not configured", file_id=file_id) + return {"status": "Skipped", "reason": "Nextcloud settings not configured"} + + filename = os.path.basename(file_path) + + try: + # Prepare WebDAV URL - use nextcloud_upload_url instead of nextcloud_url + webdav_url = settings.nextcloud_upload_url + if not webdav_url.endswith("/"): + webdav_url += "/" + + # Calculate remote path based on local file structure + remote_base = ( + folder_override if folder_override is not None else (getattr(settings, "nextcloud_folder", "") or "") + ) + remote_path = extract_remote_path(file_path, settings.workdir, remote_base) + full_url = f"{webdav_url}/{remote_path}" + + # Remove any double slashes (except in http://) + full_url = full_url.replace("://", "$PLACEHOLDER$") + while "//" in full_url: + full_url = full_url.replace("//", "/") + full_url = full_url.replace("$PLACEHOLDER$", "://") + + # Function to check if file exists in Nextcloud + def check_exists_in_nextcloud(path): + check_url = f"{webdav_url}{os.path.dirname(path)}" + try: + response = requests.request( + "PROPFIND", + check_url, + auth=HTTPBasicAuth(settings.nextcloud_username, settings.nextcloud_password), + headers={"Depth": "1"}, + timeout=10, + ) + + return path in response.text + except Exception: + # If we can't check, assume it doesn't exist + return False + + # Check for potential file collision and get a unique name if needed + remote_path = get_unique_filename(remote_path, check_exists_in_nextcloud) + full_url = f"{webdav_url}/{remote_path}" + + # Fix double slashes again + full_url = full_url.replace("://", "$PLACEHOLDER$") + while "//" in full_url: + full_url = full_url.replace("//", "/") + full_url = full_url.replace("$PLACEHOLDER$", "://") + + # Create necessary parent folders + parent_dirs = os.path.dirname(remote_path) + if parent_dirs: + current_path = "" + for folder in parent_dirs.split("/"): + if not folder: + continue + current_path += f"{folder}/" + mkdir_url = f"{webdav_url}/{current_path}" + # Fix double slashes + mkdir_url = mkdir_url.replace("://", "$PLACEHOLDER$") + while "//" in mkdir_url: + mkdir_url = mkdir_url.replace("//", "/") + mkdir_url = mkdir_url.replace("$PLACEHOLDER$", "://") + + requests.request( + "MKCOL", + mkdir_url, + auth=HTTPBasicAuth(settings.nextcloud_username, settings.nextcloud_password), + timeout=10, + ) + + # Upload the file + logger.info(f"[{task_id}] Uploading {filename} to Nextcloud at {full_url}") + log_task_progress(task_id, "upload_file", "in_progress", f"Uploading to {remote_path}", file_id=file_id) + with open(file_path, "rb") as file_data: + response = requests.put( + full_url, + data=file_data, + auth=HTTPBasicAuth(settings.nextcloud_username, settings.nextcloud_password), + headers={"Content-Type": "application/octet-stream"}, + timeout=settings.http_request_timeout, # Use configured timeout for large files + ) + + if response.status_code in (201, 204): # Created or No Content + logger.info(f"[{task_id}] Successfully uploaded {filename} to Nextcloud at {remote_path}") + log_task_progress( + task_id, "upload_to_nextcloud", "success", f"Uploaded to Nextcloud: {remote_path}", file_id=file_id + ) + return { + "status": "Completed", + "file_path": file_path, + "nextcloud_path": remote_path, + "response_code": response.status_code, + } + else: + error_msg = f"Failed to upload {filename} to Nextcloud: {response.status_code} - {response.text}" + logger.error(f"[{task_id}] {error_msg}") + log_task_progress(task_id, "upload_to_nextcloud", "failure", error_msg, file_id=file_id) + raise Exception(error_msg) + + except Exception as e: + error_msg = f"Failed to upload {filename} to Nextcloud: {str(e)}" + logger.error(f"[{task_id}] {error_msg}") + log_task_progress(task_id, "upload_to_nextcloud", "failure", error_msg, file_id=file_id) + raise Exception(error_msg) diff --git a/app/utils/allowed_types.py b/app/utils/allowed_types.py index eaa37e6e..7212c0f8 100644 --- a/app/utils/allowed_types.py +++ b/app/utils/allowed_types.py @@ -68,6 +68,8 @@ IMAGE_MIME_TYPES: set[str] = { "image/tiff", "image/webp", "image/svg+xml", + "image/heic", + "image/heif", } # --------------------------------------------------------------------------- @@ -124,6 +126,8 @@ ALLOWED_EXTENSIONS: set[str] = { ".tif", ".webp", ".svg", + ".heic", + ".heif", # Web ".html", ".htm", @@ -234,7 +238,7 @@ FILE_TYPE_CATEGORIES: dict[str, dict] = { }, "images": { "label": "Images", - "description": "Image files (.jpg, .png, .gif, .bmp, .tiff, .webp, .svg)", + "description": "Image files (.jpg, .png, .gif, .bmp, .tiff, .webp, .svg, .heic, .heif)", "mime_types": frozenset( { "image/jpeg", @@ -245,6 +249,8 @@ FILE_TYPE_CATEGORIES: dict[str, dict] = { "image/tiff", "image/webp", "image/svg+xml", + "image/heic", + "image/heif", } ), "extensions": frozenset( @@ -258,6 +264,8 @@ FILE_TYPE_CATEGORIES: dict[str, dict] = { ".tif", ".webp", ".svg", + ".heic", + ".heif", } ), }, diff --git a/app/utils/automation_hooks.py b/app/utils/automation_hooks.py new file mode 100644 index 00000000..23e75046 --- /dev/null +++ b/app/utils/automation_hooks.py @@ -0,0 +1,188 @@ +"""Automation hook utilities for Zapier / Make.com integration. + +Provides helpers to build Zapier-compatible flat payloads, query active +automation hook subscriptions, and fan-out event delivery to all matching +hooks via Celery tasks. + +The payload format is intentionally *flat* (no nested ``data`` key) so that +Zapier and Make.com can map fields without JSONPath expressions. An ``id`` +field is included for Zapier deduplication. +""" + +import json +import logging +import time +import uuid +from typing import Any + +from app.config import settings +from app.database import SessionLocal +from app.models import AutomationHook +from app.utils.webhook import VALID_EVENTS + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Payload helpers +# --------------------------------------------------------------------------- + + +def build_zapier_payload(event: str, data: dict[str, Any]) -> dict[str, Any]: + """Build a flat, Zapier-compatible webhook payload. + + Zapier works best with flat JSON objects that include an ``id`` field + for deduplication. This function merges event metadata into the + top-level object alongside the event-specific *data*. + + Args: + event: The event name (e.g. ``document.processed``). + data: Event-specific key/value pairs. + + Returns: + A flat dictionary suitable for Zapier / Make.com consumption. + """ + return { + "id": f"evt_{uuid.uuid4().hex[:16]}", + "event": event, + "timestamp": time.time(), + **data, + } + + +# --------------------------------------------------------------------------- +# Sample payloads (used by the /triggers/sample endpoint) +# --------------------------------------------------------------------------- + +#: Example payloads that Zapier uses for field-mapping during Zap creation. +SAMPLE_PAYLOADS: dict[str, dict[str, Any]] = { + "document.uploaded": { + "id": "evt_sample0001", + "event": "document.uploaded", + "timestamp": 1710000000.0, + "document_id": 42, + "filename": "invoice_2024.pdf", + "content_type": "application/pdf", + "size_bytes": 204800, + "owner_id": "user@example.com", + }, + "document.processed": { + "id": "evt_sample0002", + "event": "document.processed", + "timestamp": 1710000060.0, + "document_id": 42, + "filename": "invoice_2024.pdf", + "status": "processed", + "title": "Invoice #1234", + "owner_id": "user@example.com", + }, + "document.failed": { + "id": "evt_sample0003", + "event": "document.failed", + "timestamp": 1710000120.0, + "document_id": 42, + "filename": "corrupt.pdf", + "status": "failed", + "error": "Unable to extract text from document", + "owner_id": "user@example.com", + }, + "user.signup": { + "id": "evt_sample0004", + "event": "user.signup", + "timestamp": 1710000180.0, + "user_id": "newuser@example.com", + "display_name": "Jane Doe", + }, + "user.plan_changed": { + "id": "evt_sample0005", + "event": "user.plan_changed", + "timestamp": 1710000240.0, + "user_id": "user@example.com", + "old_tier": "free", + "new_tier": "pro", + }, + "user.payment_issue": { + "id": "evt_sample0006", + "event": "user.payment_issue", + "timestamp": 1710000300.0, + "user_id": "user@example.com", + "issue": "Credit card declined", + }, +} + + +# --------------------------------------------------------------------------- +# Database queries +# --------------------------------------------------------------------------- + + +def get_active_hooks_for_event(event: str) -> list[dict[str, Any]]: + """Return all active automation hooks subscribed to *event*. + + Args: + event: The event name to filter on. + + Returns: + A list of dicts with ``id``, ``target_url``, ``secret``, and + ``events`` keys. + """ + db = SessionLocal() + try: + hooks = db.query(AutomationHook).filter(AutomationHook.is_active.is_(True)).all() + result: list[dict[str, Any]] = [] + for hook in hooks: + try: + subscribed = json.loads(hook.events) + except (json.JSONDecodeError, TypeError): + subscribed = [] + if event in subscribed: + result.append( + { + "id": hook.id, + "target_url": hook.target_url, + "secret": hook.secret, + "events": subscribed, + } + ) + return result + finally: + db.close() + + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- + + +def dispatch_automation_hooks(event: str, data: dict[str, Any]) -> None: + """Fan-out an event to all matching active automation hooks. + + Builds a Zapier-compatible flat payload and queues a Celery task for + each matching hook so delivery is asynchronous with automatic retries. + + Args: + event: Event name (must be in :data:`VALID_EVENTS`). + data: Event-specific payload data. + """ + if not settings.automation_hooks_enabled: + return + + if event not in VALID_EVENTS: + logger.warning("Ignoring unknown automation hook event: %s", event) + return + + hooks = get_active_hooks_for_event(event) + if not hooks: + logger.debug("No active automation hooks for event %s", event) + return + + payload = build_zapier_payload(event, data) + + from app.tasks.automation_tasks import deliver_automation_hook_task + + for hook in hooks: + try: + deliver_automation_hook_task.delay(hook["target_url"], payload, hook["secret"]) + logger.debug("Queued automation hook delivery to %s for event %s", hook["target_url"], event) + except Exception as exc: + logger.error("Failed to queue automation hook to %s: %s", hook["target_url"], exc) diff --git a/app/utils/classification_rules.py b/app/utils/classification_rules.py new file mode 100644 index 00000000..760f03e4 --- /dev/null +++ b/app/utils/classification_rules.py @@ -0,0 +1,378 @@ +""" +Rule-based document classification engine. + +Provides pre-built categories and a rule matcher that classifies documents +using filename patterns, content keywords, and metadata fields. Custom +rules stored in the database are evaluated alongside the built-in defaults. + +Usage:: + + from app.utils.classification_rules import classify_document + + result = classify_document( + filename="2024-03-01_Invoice_Acme.pdf", + text="Invoice total: $1,234.56", + metadata={"absender": "Acme Corp"}, + custom_rules=custom_rules_from_db, + ) + # result -> ClassificationResult(category="invoice", confidence=85, matched_rules=[...]) +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass, field +from typing import Any + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Pre-built categories +# --------------------------------------------------------------------------- + +#: Canonical category names recognized by the system. Users may also define +#: their own categories via custom rules. +BUILTIN_CATEGORIES: dict[str, str] = { + "invoice": "Invoice", + "contract": "Contract", + "receipt": "Receipt", + "letter": "Letter", + "report": "Report", + "bank_statement": "Bank Statement", + "tax_document": "Tax Document", + "insurance": "Insurance Document", + "payslip": "Payslip", + "unknown": "Unknown", +} + +# --------------------------------------------------------------------------- +# Rule type constants +# --------------------------------------------------------------------------- + +RULE_TYPE_FILENAME = "filename_pattern" +RULE_TYPE_CONTENT = "content_keyword" +RULE_TYPE_METADATA = "metadata_match" + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + + +@dataclass +class ClassificationRule: + """A single classification rule.""" + + name: str + category: str + rule_type: str # filename_pattern | content_keyword | metadata_match + pattern: str # regex for filename, keyword(s) for content, "field=value" for metadata + priority: int = 0 # higher = evaluated first + case_sensitive: bool = False + + def __post_init__(self) -> None: + if self.rule_type not in (RULE_TYPE_FILENAME, RULE_TYPE_CONTENT, RULE_TYPE_METADATA): + raise ValueError(f"Invalid rule_type: {self.rule_type!r}") + + +@dataclass +class MatchedRule: + """Records which rule matched and why.""" + + rule_name: str + rule_type: str + category: str + confidence: int + + +@dataclass +class ClassificationResult: + """The outcome of running the classification engine on a document.""" + + category: str + confidence: int # 0 – 100 + matched_rules: list[MatchedRule] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Built-in rules +# --------------------------------------------------------------------------- + +BUILTIN_RULES: list[ClassificationRule] = [ + # ── Invoice ─────────────────────────────────────────────────────────── + ClassificationRule("builtin_invoice_filename", "invoice", RULE_TYPE_FILENAME, r"(?i)invoice|rechnung|facture"), + ClassificationRule( + "builtin_invoice_content", + "invoice", + RULE_TYPE_CONTENT, + "invoice number|invoice total|amount due|rechnung|rechnungsnummer|total amount|bill to", + ), + ClassificationRule("builtin_invoice_metadata", "invoice", RULE_TYPE_METADATA, "document_type=Invoice"), + ClassificationRule( + "builtin_invoice_kommunikationsart", "invoice", RULE_TYPE_METADATA, "kommunikationsart=Rechnung" + ), + # ── Contract ────────────────────────────────────────────────────────── + ClassificationRule("builtin_contract_filename", "contract", RULE_TYPE_FILENAME, r"(?i)contract|vertrag|agreement"), + ClassificationRule( + "builtin_contract_content", + "contract", + RULE_TYPE_CONTENT, + "hereby agrees|terms and conditions|vertrag|agreement between|party agrees|effective date", + ), + ClassificationRule("builtin_contract_metadata", "contract", RULE_TYPE_METADATA, "document_type=Contract"), + ClassificationRule( + "builtin_contract_kommunikationsart", "contract", RULE_TYPE_METADATA, "kommunikationsart=Vertrag" + ), + # ── Receipt ─────────────────────────────────────────────────────────── + ClassificationRule("builtin_receipt_filename", "receipt", RULE_TYPE_FILENAME, r"(?i)receipt|quittung|beleg"), + ClassificationRule( + "builtin_receipt_content", + "receipt", + RULE_TYPE_CONTENT, + "receipt|quittung|payment received|thank you for your purchase|transaction id", + ), + ClassificationRule("builtin_receipt_metadata", "receipt", RULE_TYPE_METADATA, "document_type=Receipt"), + ClassificationRule( + "builtin_receipt_kommunikationsart", "receipt", RULE_TYPE_METADATA, "kommunikationsart=Quittung" + ), + # ── Letter ──────────────────────────────────────────────────────────── + ClassificationRule("builtin_letter_filename", "letter", RULE_TYPE_FILENAME, r"(?i)letter|brief|schreiben"), + ClassificationRule( + "builtin_letter_content", + "letter", + RULE_TYPE_CONTENT, + "dear sir|dear madam|sehr geehrte|to whom it may concern|sincerely|mit freundlichen", + ), + # ── Report ──────────────────────────────────────────────────────────── + ClassificationRule("builtin_report_filename", "report", RULE_TYPE_FILENAME, r"(?i)report|bericht"), + ClassificationRule( + "builtin_report_content", + "report", + RULE_TYPE_CONTENT, + "executive summary|table of contents|annual report|quarterly report|findings", + ), + # ── Bank statement ──────────────────────────────────────────────────── + ClassificationRule( + "builtin_bank_filename", + "bank_statement", + RULE_TYPE_FILENAME, + r"(?i)bank.?statement|kontoauszug", + ), + ClassificationRule( + "builtin_bank_content", + "bank_statement", + RULE_TYPE_CONTENT, + "account statement|kontoauszug|opening balance|closing balance|account number", + ), + ClassificationRule( + "builtin_bank_kommunikationsart", "bank_statement", RULE_TYPE_METADATA, "kommunikationsart=Kontoauszug" + ), + # ── Tax document ────────────────────────────────────────────────────── + ClassificationRule("builtin_tax_filename", "tax_document", RULE_TYPE_FILENAME, r"(?i)tax|steuer|steuerbescheid"), + ClassificationRule( + "builtin_tax_content", + "tax_document", + RULE_TYPE_CONTENT, + "tax return|steuerbescheid|taxable income|finanzamt|tax assessment", + ), + # ── Insurance ───────────────────────────────────────────────────────── + ClassificationRule( + "builtin_insurance_filename", "insurance", RULE_TYPE_FILENAME, r"(?i)insurance|versicherung|police" + ), + ClassificationRule( + "builtin_insurance_content", + "insurance", + RULE_TYPE_CONTENT, + "insurance policy|versicherung|policennummer|coverage|premium|deductible", + ), + # ── Payslip ─────────────────────────────────────────────────────────── + ClassificationRule( + "builtin_payslip_filename", "payslip", RULE_TYPE_FILENAME, r"(?i)payslip|gehaltsabrechnung|lohnabrechnung" + ), + ClassificationRule( + "builtin_payslip_content", + "payslip", + RULE_TYPE_CONTENT, + "gross salary|net salary|gehaltsabrechnung|lohnabrechnung|bruttolohn|nettolohn", + ), +] + + +# --------------------------------------------------------------------------- +# Confidence scoring +# --------------------------------------------------------------------------- + +#: Base confidence for each rule type when it matches. +_CONFIDENCE_MAP: dict[str, int] = { + RULE_TYPE_FILENAME: 60, + RULE_TYPE_CONTENT: 70, + RULE_TYPE_METADATA: 90, +} + +#: Extra confidence per additional matching rule of the same category (capped). +_CONFIDENCE_BONUS_PER_EXTRA_RULE = 10 + + +# --------------------------------------------------------------------------- +# Matching helpers +# --------------------------------------------------------------------------- + + +def _match_filename(rule: ClassificationRule, filename: str) -> bool: + """Return True if *rule.pattern* (regex) matches anywhere in *filename*.""" + if not filename: + return False + flags = 0 if rule.case_sensitive else re.IGNORECASE + return bool(re.search(rule.pattern, filename, flags)) + + +def _match_content(rule: ClassificationRule, text: str) -> bool: + """Return True if any keyword in *rule.pattern* appears in *text*. + + Keywords are separated by ``|`` (pipe). + """ + if not text: + return False + keywords = [kw.strip() for kw in rule.pattern.split("|") if kw.strip()] + text_lower = text if rule.case_sensitive else text.lower() + return any((kw if rule.case_sensitive else kw.lower()) in text_lower for kw in keywords) + + +def _match_metadata(rule: ClassificationRule, metadata: dict[str, Any] | None) -> bool: + """Return True if *rule.pattern* (``field=value``) matches *metadata*. + + Pattern format: ``field_name=expected_value``. + """ + if not metadata: + return False + if "=" not in rule.pattern: + return False + field_name, expected_value = rule.pattern.split("=", 1) + actual = metadata.get(field_name.strip()) + if actual is None: + return False + if rule.case_sensitive: + return str(actual) == expected_value.strip() + return str(actual).lower() == expected_value.strip().lower() + + +_MATCHERS: dict[str, tuple] = { + RULE_TYPE_FILENAME: (_match_filename, "filename"), + RULE_TYPE_CONTENT: (_match_content, "text"), + RULE_TYPE_METADATA: (_match_metadata, "metadata"), +} + + +def _evaluate_rule( + rule: ClassificationRule, + filename: str, + text: str, + metadata: dict[str, Any] | None, +) -> MatchedRule | None: + """Evaluate a single rule against the document. Return a :class:`MatchedRule` on match.""" + entry = _MATCHERS.get(rule.rule_type) + if entry is None: + return None + + matcher, arg_key = entry + arg_map = {"filename": filename, "text": text, "metadata": metadata} + matched = matcher(rule, arg_map[arg_key]) + + if matched: + return MatchedRule( + rule_name=rule.name, + rule_type=rule.rule_type, + category=rule.category, + confidence=_CONFIDENCE_MAP.get(rule.rule_type, 50), + ) + return None + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def classify_document( + filename: str = "", + text: str = "", + metadata: dict[str, Any] | None = None, + custom_rules: list[ClassificationRule] | None = None, +) -> ClassificationResult: + """Classify a document by evaluating built-in and custom rules. + + Rules are evaluated in priority order (highest first, then built-in before + custom for the same priority). The category with the most rule matches + wins; ties are broken by cumulative confidence. + + Args: + filename: Original filename of the document. + text: Extracted / OCR text of the document. + metadata: Previously-extracted AI metadata dict (e.g. from ``ai_metadata``). + custom_rules: Optional list of user-defined :class:`ClassificationRule` objects. + + Returns: + A :class:`ClassificationResult` with the best matching category, + overall confidence score, and the list of rules that fired. + """ + all_rules = list(BUILTIN_RULES) + if custom_rules: + all_rules.extend(custom_rules) + + # Sort by priority descending (higher priority first) + all_rules.sort(key=lambda r: r.priority, reverse=True) + + matches: list[MatchedRule] = [] + for rule in all_rules: + result = _evaluate_rule(rule, filename, text, metadata) + if result is not None: + matches.append(result) + + if not matches: + return ClassificationResult(category="unknown", confidence=0, matched_rules=[]) + + # Aggregate by category: pick the one with the most matches, then highest + # cumulative confidence as tiebreaker. + category_scores: dict[str, list[MatchedRule]] = {} + for m in matches: + category_scores.setdefault(m.category, []).append(m) + + best_category = max( + category_scores, + key=lambda cat: (len(category_scores[cat]), sum(m.confidence for m in category_scores[cat])), + ) + + best_matches = category_scores[best_category] + base_confidence = max(m.confidence for m in best_matches) + bonus = min( + (len(best_matches) - 1) * _CONFIDENCE_BONUS_PER_EXTRA_RULE, + 100 - base_confidence, + ) + final_confidence = min(base_confidence + bonus, 100) + + return ClassificationResult( + category=best_category, + confidence=final_confidence, + matched_rules=best_matches, + ) + + +def db_rule_to_engine_rule(db_rule: Any) -> ClassificationRule: + """Convert a database ``ClassificationRuleModel`` row to an engine :class:`ClassificationRule`. + + Args: + db_rule: A SQLAlchemy model instance with ``name``, ``category``, + ``rule_type``, ``pattern``, ``priority``, and ``case_sensitive`` attributes. + + Returns: + A :class:`ClassificationRule` dataclass instance. + """ + return ClassificationRule( + name=db_rule.name, + category=db_rule.category, + rule_type=db_rule.rule_type, + pattern=db_rule.pattern, + priority=db_rule.priority, + case_sensitive=getattr(db_rule, "case_sensitive", False), + ) diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index 75c64481..e5d37985 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -8,7 +8,6 @@ This module provides functionality to: """ import logging -import os from typing import Any, Dict, List, Optional, Tuple from sqlalchemy.exc import SQLAlchemyError @@ -40,6 +39,50 @@ SETTING_METADATA = { "required": True, "restart_required": True, }, + "db_pool_size": { + "category": "Core", + "description": ( + "Number of persistent database connections kept in the pool per worker process. " + "Ignored for SQLite (which uses NullPool). Default: 10." + ), + "type": "integer", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "db_max_overflow": { + "category": "Core", + "description": ( + "Additional database connections allowed beyond db_pool_size under burst load. " + "Ignored for SQLite. Default: 20." + ), + "type": "integer", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "db_pool_timeout": { + "category": "Core", + "description": ( + "Seconds to wait for a database connection from the pool before raising a TimeoutError. " + "Ignored for SQLite. Default: 30." + ), + "type": "integer", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "db_pool_recycle": { + "category": "Core", + "description": ( + "Recycle (close and reopen) database connections after this many seconds " + "to avoid stale connections. Ignored for SQLite. Default: 1800." + ), + "type": "integer", + "sensitive": False, + "required": False, + "restart_required": True, + }, "workdir": { "category": "Core", "description": "Working directory for file storage and processing", @@ -56,6 +99,18 @@ SETTING_METADATA = { "required": True, # Required for OAuth redirects and external URLs "restart_required": True, }, + "public_base_url": { + "category": "Core", + "description": ( + "Full public base URL including scheme (e.g., https://docuelevate.example.com). " + "When set, overrides auto-detected URLs for OAuth redirect URIs. " + "Required when behind a reverse proxy that does not forward X-Forwarded-Proto." + ), + "type": "string", + "sensitive": False, + "required": False, + "restart_required": True, + }, "debug": { "category": "Core", "description": "Enable debug mode for verbose logging", @@ -151,6 +206,14 @@ SETTING_METADATA = { "required": False, "restart_required": True, }, + "qr_login_enabled": { + "category": "Authentication", + "description": "Enable QR code-based login for mobile device authentication.", + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": False, + }, "qr_login_challenge_ttl_seconds": { "category": "Authentication", "description": "Time-to-live in seconds for QR login challenges (default 120).", @@ -207,6 +270,17 @@ SETTING_METADATA = { "required": False, "restart_required": True, }, + "sso_auto_login": { + "category": "Authentication", + "description": ( + "Automatically redirect to SSO login when authentication is required. " + "Skips the login page and sends users directly to the configured SSO provider." + ), + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": False, + }, # Social Login Providers "social_auth_google_enabled": { "category": "Social Login", @@ -237,6 +311,20 @@ SETTING_METADATA = { "required": False, "restart_required": True, }, + "social_auth_google_use_global_credentials": { + "category": "Social Login", + "description": ( + "When True, Google social login uses the global GOOGLE_DRIVE_CLIENT_ID / " + "GOOGLE_DRIVE_CLIENT_SECRET credentials (the Google Drive OAuth integration) " + "instead of requiring separate SOCIAL_AUTH_GOOGLE_CLIENT_ID / " + "SOCIAL_AUTH_GOOGLE_CLIENT_SECRET values. " + "Requires SOCIAL_AUTH_GOOGLE_ENABLED=True and global Google Drive OAuth credentials to be set." + ), + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": True, + }, "social_auth_microsoft_enabled": { "category": "Social Login", "description": ( @@ -279,6 +367,20 @@ SETTING_METADATA = { "required": False, "restart_required": True, }, + "social_auth_microsoft_use_global_credentials": { + "category": "Social Login", + "description": ( + "When True, Microsoft social login uses the global ONEDRIVE_CLIENT_ID / " + "ONEDRIVE_CLIENT_SECRET credentials (the OneDrive integration credentials) " + "instead of requiring separate SOCIAL_AUTH_MICROSOFT_CLIENT_ID / " + "SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET values. " + "Requires SOCIAL_AUTH_MICROSOFT_ENABLED=True and global OneDrive credentials to be set." + ), + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": True, + }, "social_auth_apple_enabled": { "category": "Social Login", "description": ( @@ -327,6 +429,19 @@ SETTING_METADATA = { "required": False, "restart_required": True, }, + "social_auth_dropbox_use_global_credentials": { + "category": "Social Login", + "description": ( + "When True, Dropbox social login uses the global DROPBOX_APP_KEY / DROPBOX_APP_SECRET " + "credentials instead of requiring separate SOCIAL_AUTH_DROPBOX_CLIENT_ID / " + "SOCIAL_AUTH_DROPBOX_CLIENT_SECRET values. " + "Requires SOCIAL_AUTH_DROPBOX_ENABLED=True and global Dropbox credentials to be set." + ), + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": True, + }, "social_auth_dropbox_enabled": { "category": "Social Login", "description": ( @@ -354,6 +469,182 @@ SETTING_METADATA = { "required": False, "restart_required": True, }, + "social_auth_github_enabled": { + "category": "Social Login", + "description": ( + "Enable GitHub Sign-In. Requires SOCIAL_AUTH_GITHUB_CLIENT_ID and " + "SOCIAL_AUTH_GITHUB_CLIENT_SECRET from GitHub Developer Settings." + ), + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": True, + "help_link": "https://github.com/settings/developers", + "help_link_label": "GitHub Developer Settings", + }, + "social_auth_github_client_id": { + "category": "Social Login", + "description": "GitHub OAuth2 client ID from GitHub Developer Settings.", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "social_auth_github_client_secret": { + "category": "Social Login", + "description": "GitHub OAuth2 client secret from GitHub Developer Settings.", + "type": "string", + "sensitive": True, + "required": False, + "restart_required": True, + }, + # Keycloak SSO + "social_auth_keycloak_enabled": { + "category": "Social Login", + "description": "Enable Keycloak SSO. Requires server URL, realm, client ID, and client secret.", + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "social_auth_keycloak_client_id": { + "category": "Social Login", + "description": "Keycloak OAuth2 client ID.", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "social_auth_keycloak_client_secret": { + "category": "Social Login", + "description": "Keycloak OAuth2 client secret.", + "type": "string", + "sensitive": True, + "required": False, + "restart_required": True, + }, + "social_auth_keycloak_server_url": { + "category": "Social Login", + "description": "Keycloak server base URL (e.g. https://keycloak.example.com).", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "social_auth_keycloak_realm": { + "category": "Social Login", + "description": "Keycloak realm name.", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": True, + }, + # Generic OAuth2 SSO + "social_auth_generic_oauth2_enabled": { + "category": "Social Login", + "description": "Enable a generic OAuth2 SSO provider.", + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "social_auth_generic_oauth2_client_id": { + "category": "Social Login", + "description": "Generic OAuth2 client ID.", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "social_auth_generic_oauth2_client_secret": { + "category": "Social Login", + "description": "Generic OAuth2 client secret.", + "type": "string", + "sensitive": True, + "required": False, + "restart_required": True, + }, + "social_auth_generic_oauth2_authorize_url": { + "category": "Social Login", + "description": "Generic OAuth2 authorization URL.", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "social_auth_generic_oauth2_token_url": { + "category": "Social Login", + "description": "Generic OAuth2 token endpoint URL.", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "social_auth_generic_oauth2_userinfo_url": { + "category": "Social Login", + "description": "Generic OAuth2 userinfo endpoint URL.", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "social_auth_generic_oauth2_scope": { + "category": "Social Login", + "description": "Space-separated list of OAuth2 scopes to request (default: openid profile email).", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "social_auth_generic_oauth2_name": { + "category": "Social Login", + "description": "Display name for the generic OAuth2 provider button on the login page.", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": True, + }, + # SAML2 SSO + "social_auth_saml2_enabled": { + "category": "Social Login", + "description": "Enable SAML2 SSO authentication.", + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "social_auth_saml2_entity_id": { + "category": "Social Login", + "description": "SAML2 Identity Provider Entity ID.", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "social_auth_saml2_sso_url": { + "category": "Social Login", + "description": "SAML2 Identity Provider SSO URL.", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "social_auth_saml2_certificate": { + "category": "Social Login", + "description": "SAML2 Identity Provider X.509 certificate (PEM format).", + "type": "string", + "sensitive": True, + "required": False, + "restart_required": True, + }, + "social_auth_saml2_name": { + "category": "Social Login", + "description": "Display name for the SAML2 provider.", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": True, + }, # AI Services "openai_api_key": { "category": "AI Services", @@ -720,6 +1011,18 @@ SETTING_METADATA = { "required": False, "restart_required": False, }, + "dropbox_allow_global_credentials_for_integrations": { + "category": "Storage Providers", + "description": ( + "When True, users may authorize their personal Dropbox integrations using the global " + "DROPBOX_APP_KEY / DROPBOX_APP_SECRET credentials configured by the admin, without " + "needing to create their own Dropbox app." + ), + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": False, + }, # Storage Providers - Nextcloud "nextcloud_enabled": { "category": "Storage Providers", @@ -1872,6 +2175,30 @@ SETTING_METADATA = { "required": False, "restart_required": False, }, + "telegram_enabled": { + "category": "Notifications", + "description": "Enable Telegram bot notifications.", + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": False, + }, + "telegram_bot_token": { + "category": "Notifications", + "description": "Telegram Bot API token from @BotFather.", + "type": "string", + "sensitive": True, + "required": False, + "restart_required": False, + }, + "telegram_chat_id": { + "category": "Notifications", + "description": "Telegram chat ID to send notifications to.", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": False, + }, # Notifications Settings "notification_urls": { "category": "Notifications", @@ -1970,6 +2297,18 @@ SETTING_METADATA = { "required": False, "restart_required": False, }, + "automation_hooks_enabled": { + "category": "Feature Flags", + "description": ( + "Enable Zapier / Make.com automation hook subscriptions and delivery. " + "When enabled, external automation platforms can subscribe to DocuElevate events " + "via the REST hooks protocol. Default: True." + ), + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": False, + }, "compliance_enabled": { "category": "Feature Flags", "description": ( @@ -2558,51 +2897,6 @@ SETTING_METADATA = { "required": False, "restart_required": False, }, - # Database Connection Pool - "db_pool_size": { - "category": "Core", - "description": ( - "Number of persistent connections kept in the SQLAlchemy QueuePool. " - "Has no effect for SQLite databases. Default: 5." - ), - "type": "integer", - "sensitive": False, - "required": False, - "restart_required": True, - }, - "db_max_overflow": { - "category": "Core", - "description": ( - "Maximum extra connections that can be opened beyond db_pool_size. " - "Has no effect for SQLite databases. Default: 10." - ), - "type": "integer", - "sensitive": False, - "required": False, - "restart_required": True, - }, - "db_pool_timeout": { - "category": "Core", - "description": ( - "Seconds to wait for a connection from the pool before raising an error. " - "Has no effect for SQLite databases. Default: 30." - ), - "type": "integer", - "sensitive": False, - "required": False, - "restart_required": True, - }, - "db_pool_recycle": { - "category": "Core", - "description": ( - "Seconds after which idle connections are recycled to prevent stale connections. " - "Has no effect for SQLite databases. Default: 1800 (30 minutes)." - ), - "type": "integer", - "sensitive": False, - "required": False, - "restart_required": True, - }, # Per-user upload rate limiting "upload_rate_limit_per_user": { "category": "Security", @@ -2938,6 +3232,43 @@ SETTING_METADATA = { "required": False, "restart_required": True, }, + "sentry_js_traces_sample_rate": { + "category": "Observability", + "description": ( + "Fraction of browser page-loads captured for client-side Sentry performance tracing (0.0–1.0). " + "0.0 (default) disables browser tracing; 1.0 captures every navigation. " + "Only active when SENTRY_DSN is set." + ), + "type": "float", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "sentry_js_replay_session_sample_rate": { + "category": "Observability", + "description": ( + "Fraction of sessions recorded by Sentry Session Replay (0.0–1.0). " + "0.0 (default) disables session recording; 1.0 records every session. " + "Only active when SENTRY_DSN is set." + ), + "type": "float", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "sentry_js_replay_on_error_sample_rate": { + "category": "Observability", + "description": ( + "Fraction of error sessions recorded by Sentry Session Replay (0.0–1.0). " + "Defaults to 0.1 (10%) so that errors are captured with replay context " + "even when session-level recording is disabled. " + "Only active when SENTRY_DSN is set." + ), + "type": "float", + "sensitive": False, + "required": False, + "restart_required": True, + }, } @@ -3372,60 +3703,3 @@ def get_settings_for_export(db: Session, source: str = "db") -> Dict[str, str]: # DB only db_settings = get_all_settings_from_db(db) return {k.upper(): v for k, v in sorted(db_settings.items()) if v is not None} - - -def update_env_file(env_path: str, settings_to_update: dict[str, str]) -> bool: - """ - Update an .env file with new settings. - - Reads the file, updates matching settings (even if commented), - appends any that weren't found, and writes the result back. - - Args: - env_path: Path to the .env file - settings_to_update: Dictionary mapping setting names (e.g. 'GOOGLE_DRIVE_USE_OAUTH') to string values - - Returns: - True if the file was successfully updated, False otherwise (e.g. file not found or write error) - """ - try: - if not os.path.exists(env_path): - logger.warning(f".env file not found at {env_path}, skipping file update") - return False - - logger.info(f"Updating settings in {env_path}") - - # Read the current .env file - with open(env_path, "r") as f: - env_lines = f.readlines() - - # Process each line and update or add settings - updated = set() - new_env_lines = [] - for line in env_lines: - stripped_line = line.rstrip() - is_updated = False - for key, value in settings_to_update.items(): - if stripped_line.startswith(f"{key}=") or stripped_line.startswith(f"# {key}="): - # Uncomment if commented out - check the original stripped line - new_env_lines.append(f"{key}={value}") - updated.add(key) - is_updated = True - break - if not is_updated: - new_env_lines.append(stripped_line) - - # Add any settings that weren't updated (they weren't in the file) - for key, value in settings_to_update.items(): - if key not in updated: - new_env_lines.append(f"{key}={value}") - - # Write the updated .env file - with open(env_path, "w") as f: - f.write("\n".join(new_env_lines) + "\n") - - logger.info(f"Successfully updated settings in {env_path}") - return True - except Exception as e: - logger.warning(f"Failed to update {env_path}: {str(e)}") - return False diff --git a/app/utils/settings_sync.py b/app/utils/settings_sync.py index 3bcca417..aeeb9a62 100644 --- a/app/utils/settings_sync.py +++ b/app/utils/settings_sync.py @@ -71,6 +71,16 @@ def notify_settings_updated() -> None: except Exception as exc: logger.warning(f"Could not reload in-process settings: {exc}") + # Re-register OAuth / social-login providers so that any provider whose + # credentials were just saved (or updated) in the database is active + # immediately on the login page — no restart required. + try: + from app.auth import refresh_social_providers + + refresh_social_providers() + except Exception as exc: + logger.warning(f"Could not refresh social login providers after settings update: {exc}") + # Re-check OCR language availability in the background whenever settings # are updated. This ensures that if a user changes tesseract_language or # easyocr_languages via the UI, the new language data is downloaded without diff --git a/app/utils/user_scope.py b/app/utils/user_scope.py index a2d169c7..fe44c948 100644 --- a/app/utils/user_scope.py +++ b/app/utils/user_scope.py @@ -11,14 +11,21 @@ import logging from fastapi import Request from sqlalchemy import or_ -from sqlalchemy.orm import Query +from sqlalchemy.orm import Query, Session from sqlalchemy.sql import false from app.config import settings -from app.models import FileRecord +from app.models import FILE_SHARE_ROLE_EDITOR, FILE_SHARE_ROLE_VIEWER, FileRecord, FileShare logger = logging.getLogger(__name__) +# Role hierarchy: higher index = more rights +_ROLE_RANK: dict[str, int] = { + FILE_SHARE_ROLE_VIEWER: 1, + FILE_SHARE_ROLE_EDITOR: 2, + "owner": 3, +} + def _owner_id_from_user(user: dict) -> str | None: """Extract the owner identifier from a user dict. @@ -93,8 +100,9 @@ def apply_owner_filter(query: Query, request: Request) -> Query: """Conditionally filter a ``FileRecord`` query by the current user. When multi-user mode is enabled, only files whose ``owner_id`` - matches the authenticated user are returned. Admin users bypass - the filter and see all documents. + matches the authenticated user are returned, **plus** any files that + have been explicitly shared with the user via ``FileShare``. Admin + users bypass the filter and see all documents. When ``unowned_docs_visible_to_all`` is ``True`` (default), documents with ``owner_id IS NULL`` (unclaimed) are also included for every @@ -122,11 +130,89 @@ def apply_owner_filter(query: Query, request: Request) -> Query: # No authenticated user — return empty result set return query.filter(false()) - # Build filter: user's own documents + # Build filter: user's own documents + documents shared with them conditions = [FileRecord.owner_id == owner_id] + # Include files explicitly shared with this user + from sqlalchemy import select as sa_select + + conditions.append(FileRecord.id.in_(sa_select(FileShare.file_id).where(FileShare.shared_with_user_id == owner_id))) + # Optionally include unclaimed (owner_id IS NULL) documents if settings.unowned_docs_visible_to_all: conditions.append(FileRecord.owner_id.is_(None)) return query.filter(or_(*conditions)) + + +def get_file_role(file_record: FileRecord, user_id: str | None, db: Session) -> str | None: + """Return the effective role a user has on a ``FileRecord``. + + Roles (in descending order of privilege): + + ``"owner"`` — the user's ``owner_id`` matches ``file_record.owner_id``, + or multi-user mode is disabled (everyone is effectively an + owner in single-user mode). + ``"editor"`` — the user has an explicit ``FileShare`` with role=editor. + ``"viewer"`` — the user has an explicit ``FileShare`` with role=viewer, + or the file is unclaimed (``owner_id IS NULL``) and + ``unowned_docs_visible_to_all`` is True. + ``None`` — no access. + + Args: + file_record: The ``FileRecord`` to check. + user_id: The stable identifier of the requesting user. + db: An active SQLAlchemy session. + + Returns: + One of ``"owner"``, ``"editor"``, ``"viewer"``, or ``None``. + """ + if not settings.multi_user_enabled: + # Single-user mode: full access for everyone + return "owner" + + if user_id is None: + return None + + # Owner always has full access + if file_record.owner_id == user_id: + return "owner" + + # Unclaimed document — limited access when setting allows it + if file_record.owner_id is None and settings.unowned_docs_visible_to_all: + return FILE_SHARE_ROLE_VIEWER + + # Check for an explicit share + share = ( + db.query(FileShare) + .filter(FileShare.file_id == file_record.id, FileShare.shared_with_user_id == user_id) + .first() + ) + if share: + return share.role + + return None + + +def has_file_role( + file_record: FileRecord, + user_id: str | None, + db: Session, + minimum_role: str = FILE_SHARE_ROLE_VIEWER, +) -> bool: + """Return ``True`` if the user's effective role meets the minimum required. + + Args: + file_record: The document to check. + user_id: Requesting user's stable identifier. + db: Active SQLAlchemy session. + minimum_role: The minimum role required (``"viewer"``, ``"editor"``, + or ``"owner"``). + + Returns: + ``True`` when the user's role rank is >= the minimum rank. + """ + role = get_file_role(file_record, user_id, db) + if role is None: + return False + return _ROLE_RANK.get(role, 0) >= _ROLE_RANK.get(minimum_role, 0) diff --git a/app/utils/webhook.py b/app/utils/webhook.py index 3bc54f82..0d181e71 100644 --- a/app/utils/webhook.py +++ b/app/utils/webhook.py @@ -145,6 +145,8 @@ def dispatch_webhook_event(event: str, data: dict[str, Any]) -> None: It delegates to :func:`deliver_webhook_task` (Celery) for each matching webhook so delivery happens asynchronously with automatic retries. + Also dispatches to automation hooks (Zapier / Make.com) if enabled. + Args: event: Event name (must be in :data:`VALID_EVENTS`). data: Event-specific payload data. @@ -156,16 +158,23 @@ def dispatch_webhook_event(event: str, data: dict[str, Any]) -> None: webhooks = get_active_webhooks_for_event(event) if not webhooks: logger.debug("No active webhooks for event %s", event) - return + else: + payload = build_payload(event, data) - payload = build_payload(event, data) + # Import here to avoid circular dependency with celery_app + from app.tasks.webhook_tasks import deliver_webhook_task - # Import here to avoid circular dependency with celery_app - from app.tasks.webhook_tasks import deliver_webhook_task + for wh in webhooks: + try: + deliver_webhook_task.delay(wh["url"], payload, wh["secret"]) + logger.debug("Queued webhook delivery to %s for event %s", wh["url"], event) + except Exception as exc: + logger.error("Failed to queue webhook to %s: %s", wh["url"], exc) - for wh in webhooks: - try: - deliver_webhook_task.delay(wh["url"], payload, wh["secret"]) - logger.debug("Queued webhook delivery to %s for event %s", wh["url"], event) - except Exception as exc: - logger.error("Failed to queue webhook to %s: %s", wh["url"], exc) + # Also fan-out to Zapier / Make.com automation hooks + try: + from app.utils.automation_hooks import dispatch_automation_hooks + + dispatch_automation_hooks(event, data) + except Exception as exc: + logger.error("Failed to dispatch automation hooks for event %s: %s", event, exc) diff --git a/app/views/base.py b/app/views/base.py index 4f730fc6..f3ffa157 100644 --- a/app/views/base.py +++ b/app/views/base.py @@ -96,6 +96,21 @@ def _inject_global_context(ctx: dict) -> None: ) ctx.setdefault("enable_factory_reset", getattr(settings, "enable_factory_reset", False)) + # Sentry Browser SDK config (injected into every page so the JS SDK can initialise) + # Normalize empty-string DSN to None so the {% if sentry_dsn %} template guard works correctly. + _raw_dsn = getattr(settings, "sentry_dsn", None) + ctx.setdefault("sentry_dsn", _raw_dsn if _raw_dsn else None) + ctx.setdefault("sentry_environment", getattr(settings, "sentry_environment", "production")) + ctx.setdefault("sentry_js_traces_sample_rate", getattr(settings, "sentry_js_traces_sample_rate", 0.0)) + ctx.setdefault( + "sentry_js_replay_session_sample_rate", + getattr(settings, "sentry_js_replay_session_sample_rate", 0.0), + ) + ctx.setdefault( + "sentry_js_replay_on_error_sample_rate", + getattr(settings, "sentry_js_replay_on_error_sample_rate", 0.1), + ) + req = ctx.get("request") if req is not None: # CSRF token diff --git a/app/views/dropbox.py b/app/views/dropbox.py index f623a7c6..5723650a 100644 --- a/app/views/dropbox.py +++ b/app/views/dropbox.py @@ -14,6 +14,19 @@ from app.views.base import APIRouter, Depends, get_db, require_login, settings, router = APIRouter() +def _get_dropbox_callback_url(request: Request) -> str: + """Return the Dropbox OAuth callback URL. + + Uses ``PUBLIC_BASE_URL`` when configured so that the redirect URI displayed + to the user (and registered in the Dropbox developer console) matches the + one used in the OAuth authorization request. Falls back to deriving the URL + from the incoming request when ``PUBLIC_BASE_URL`` is not set. + """ + if settings.public_base_url: + return settings.public_base_url.rstrip("/") + "/dropbox-callback" + return f"{request.url.scheme}://{request.url.netloc}/dropbox-callback" + + @router.get("/dropbox-setup") @require_login async def dropbox_setup_page( @@ -30,6 +43,8 @@ async def dropbox_setup_page( path from the integration's existing config is pre-populated; global admin credentials are never exposed in this mode. """ + callback_url = _get_dropbox_callback_url(request) + if integration_id is not None: owner_id = get_current_owner_id(request) integration = ( @@ -46,6 +61,12 @@ async def dropbox_setup_page( cfg = {} # Support both "folder" (DROPBOX destination) and "folder_path" (WATCH_FOLDER source) folder_path = cfg.get("folder", cfg.get("folder_path", "")) + # Determine if global credentials are available for users to reuse + global_creds_available = bool( + settings.dropbox_allow_global_credentials_for_integrations + and settings.dropbox_app_key + and settings.dropbox_app_secret + ) return templates.TemplateResponse( "dropbox.html", { @@ -56,9 +77,12 @@ async def dropbox_setup_page( "integration_name": integration.name, "integration_type": integration.integration_type, "folder_path": folder_path, - "app_key_value": "", + # Only expose the public app key (not the secret) when global creds are allowed + "app_key_value": settings.dropbox_app_key if global_creds_available else "", "app_secret_value": "", "refresh_token_value": "", + "global_creds_available": global_creds_available, + "callback_url": callback_url, }, ) @@ -78,6 +102,7 @@ async def dropbox_setup_page( "integration_id": integration_id, "integration_name": None, "integration_type": None, + "callback_url": callback_url, }, ) @@ -108,5 +133,6 @@ async def dropbox_callback(request: Request, code: str = None, error: str = None "app_key_value": "", # The callback will prioritize sessionStorage values "app_secret_value": "", # The callback will prioritize sessionStorage values "folder_path": "", # The callback will prioritize sessionStorage values + "callback_url": _get_dropbox_callback_url(request), }, ) diff --git a/app/views/files.py b/app/views/files.py index aa1ee577..3d44efcc 100644 --- a/app/views/files.py +++ b/app/views/files.py @@ -19,6 +19,43 @@ router = APIRouter() _FILE_NOT_FOUND = "File not found" +def _resolve_owner_context(request: Request, file_record, db: Session) -> dict: + """Return owner display info and the current user's effective role. + + Returns a dict with: + - ``current_user_role``: one of "owner" / "editor" / "viewer" / None + - ``owner_display``: human-readable owner string (display_name or user_id) + - ``multi_user_enabled``: whether multi-user mode is active + """ + from app.config import settings + from app.models import UserProfile + from app.utils.user_scope import get_current_owner_id, get_file_role + + multi_user_enabled = settings.multi_user_enabled + + current_owner_id = get_current_owner_id(request) + user_session = request.session.get("user") + is_admin = isinstance(user_session, dict) and bool(user_session.get("is_admin")) + + if is_admin: + current_user_role: str | None = "owner" + else: + current_user_role = get_file_role(file_record, current_owner_id, db) + + # Build a human-readable owner label + if file_record.owner_id: + profile = db.query(UserProfile).filter(UserProfile.user_id == file_record.owner_id).first() + owner_display: str | None = profile.display_name if profile and profile.display_name else file_record.owner_id + else: + owner_display = None # No owner (unowned) + + return { + "current_user_role": current_user_role, + "owner_display": owner_display, + "multi_user_enabled": multi_user_enabled, + } + + @router.get("/files") @require_login def files_page( @@ -206,10 +243,93 @@ def files_page( @router.get("/files/{file_id}") @require_login +def file_summary_page(request: Request, file_id: int, db: Session = Depends(get_db)): + """ + Return the file summary page — a concise overview with links to detail, processing, and annotations views. + """ + try: + import json + import os + + from app.models import FileRecord + + file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + + if not file_record: + return templates.TemplateResponse( + "file_summary.html", + {"request": request, "file": None, "error": f"File with ID {file_id} not found"}, + ) + + from app.config import settings + + workdir = os.path.realpath(settings.workdir) + + def _safe_exists(path: str | None) -> bool: + """Return True only when *path* exists and resides within workdir.""" + if not path: + return False + resolved = os.path.realpath(path) + try: + common = os.path.commonpath([resolved, workdir]) + except ValueError: + return False + return common == workdir and os.path.exists(resolved) + + original_file_exists = _safe_exists(file_record.original_file_path) + processed_file_exists = _safe_exists(file_record.processed_file_path) + + # Load AI metadata — JSON sidecar file first, then DB column + gpt_metadata = None + if file_record.processed_file_path: + metadata_path = os.path.splitext(os.path.realpath(file_record.processed_file_path))[0] + ".json" + if _safe_exists(metadata_path): + try: + with open(metadata_path, "r", encoding="utf-8") as f: + gpt_metadata = json.load(f) + except Exception as e: + logger.warning(f"Failed to load metadata sidecar for file {file_id}: {e}") + + if gpt_metadata is None and file_record.ai_metadata: + try: + gpt_metadata = json.loads(file_record.ai_metadata) + except Exception as e: + logger.warning(f"Failed to parse ai_metadata for file {file_id}: {e}") + + # Quick processing status + try: + from app.utils.step_manager import get_step_summary as _get_step_summary + + step_summary = _get_step_summary(db, file_id) + except Exception: + step_summary = None + + pipeline_info = _resolve_pipeline(db, file_record) + owner_ctx = _resolve_owner_context(request, file_record, db) + + return templates.TemplateResponse( + "file_summary.html", + { + "request": request, + "file": file_record, + "gpt_metadata": gpt_metadata, + "original_file_exists": original_file_exists, + "processed_file_exists": processed_file_exists, + "step_summary": step_summary, + "pipeline_info": pipeline_info, + **owner_ctx, + }, + ) + except Exception as e: + logger.error(f"Error retrieving file summary {file_id}: {str(e)}") + return templates.TemplateResponse("file_summary.html", {"request": request, "file": None, "error": str(e)}) + + +@router.get("/files/{file_id}/detail") +@require_login def file_view_page(request: Request, file_id: int, db: Session = Depends(get_db)): """ - Return the document view page — document-centric view with metadata, preview, and extracted text. - Process-oriented details are available via /files/{file_id}/detail. + Return the document detail page — document-centric view with metadata, preview, and extracted text. """ try: import json @@ -272,6 +392,7 @@ def file_view_page(request: Request, file_id: int, db: Session = Depends(get_db) # Resolve the pipeline assigned to this file (explicit or system default) pipeline_info = _resolve_pipeline(db, file_record) + owner_ctx = _resolve_owner_context(request, file_record, db) return templates.TemplateResponse( "file_view.html", @@ -283,6 +404,7 @@ def file_view_page(request: Request, file_id: int, db: Session = Depends(get_db) "processed_file_exists": processed_file_exists, "step_summary": step_summary, "pipeline_info": pipeline_info, + **owner_ctx, }, ) except Exception as e: @@ -290,11 +412,11 @@ def file_view_page(request: Request, file_id: int, db: Session = Depends(get_db) return templates.TemplateResponse("file_view.html", {"request": request, "file": None, "error": str(e)}) -@router.get("/files/{file_id}/detail") +@router.get("/files/{file_id}/process") @require_login def file_detail_page(request: Request, file_id: int, db: Session = Depends(get_db)): """ - Return the file detail page showing processing history and file information + Return the file processing page showing processing history and pipeline information. """ try: import json @@ -375,6 +497,77 @@ def file_detail_page(request: Request, file_id: int, db: Session = Depends(get_d return templates.TemplateResponse("file_detail.html", {"request": request, "file": None, "error": str(e)}) +@router.get("/files/{file_id}/annotations") +@require_login +def file_annotations_page(request: Request, file_id: int, db: Session = Depends(get_db)): + """ + Return the comments & annotations page for a file. + """ + try: + import os + + from app.models import FileRecord + + file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + + if not file_record: + return templates.TemplateResponse( + "file_annotations.html", + {"request": request, "file": None, "error": f"File with ID {file_id} not found"}, + ) + + from app.config import settings + + workdir = os.path.realpath(settings.workdir) + + def _safe_exists(path: str | None) -> bool: + """Return True only when *path* exists and resides within workdir.""" + if not path: + return False + resolved = os.path.realpath(path) + try: + common = os.path.commonpath([resolved, workdir]) + except ValueError: + return False + return common == workdir and os.path.exists(resolved) + + original_file_exists = _safe_exists(file_record.original_file_path) + processed_file_exists = _safe_exists(file_record.processed_file_path) + + # Determine whether the file is a PDF (for EmbedPDF viewer) + mime = file_record.mime_type or "" + is_pdf = mime == "application/pdf" or (file_record.original_filename or "").lower().endswith(".pdf") + + # Determine the current user's role on this file (and owner display info) + owner_ctx = _resolve_owner_context(request, file_record, db) + + return templates.TemplateResponse( + "file_annotations.html", + { + "request": request, + "file": file_record, + "original_file_exists": original_file_exists, + "processed_file_exists": processed_file_exists, + "is_pdf": is_pdf, + **owner_ctx, + }, + ) + except Exception as e: + logger.error(f"Error retrieving annotations for file {file_id}: {str(e)}") + return templates.TemplateResponse("file_annotations.html", {"request": request, "file": None, "error": str(e)}) + + +@router.get("/files/{file_id}/comments") +@require_login +def file_comments_redirect(request: Request, file_id: int): + """ + Redirect /files/{file_id}/comments to /files/{file_id}/annotations. + """ + from starlette.responses import RedirectResponse + + return RedirectResponse(url=f"/files/{file_id}/annotations", status_code=302) + + # --------------------------------------------------------------------------- # Pipeline ↔ Celery-log stage mapping # --------------------------------------------------------------------------- @@ -396,9 +589,7 @@ _STEP_TYPE_TO_STAGES: dict[str, list[str]] = { "embed_metadata": ["embed_metadata_into_pdf"], "compute_embedding": ["compute_embedding"], "send_to_destinations": ["finalize_document_storage", "send_to_all_destinations"], - # "classify" is defined in PIPELINE_STEP_TYPES but has no Celery log stages yet. - # When a classify task is implemented, add its stage key(s) here. - "classify": [], + "classify": ["classify_document"], } # These internal bookkeeping stages are always shown in the flow regardless of diff --git a/app/views/google_drive.py b/app/views/google_drive.py index f6bde9c0..ec70f92c 100644 --- a/app/views/google_drive.py +++ b/app/views/google_drive.py @@ -45,6 +45,9 @@ async def google_drive_setup_page( except (json.JSONDecodeError, TypeError): cfg = {} folder_id = cfg.get("folder_id", "") + # Provide system-wide OAuth credentials when available so users can + # authorize without registering their own Google Cloud app. + has_system_credentials = bool(settings.google_drive_client_id and settings.google_drive_client_secret) return templates.TemplateResponse( "google_drive.html", { @@ -58,10 +61,13 @@ async def google_drive_setup_page( "use_oauth": True, "oauth_configured": bool(integration.credentials), "sa_configured": False, - "client_id": False, - "client_id_value": "", - "client_secret": False, - "client_secret_value": "", + "has_system_credentials": has_system_credentials, + "client_id": bool(settings.google_drive_client_id) if has_system_credentials else False, + "client_id_value": (settings.google_drive_client_id or "" if has_system_credentials else ""), + "client_secret": bool(settings.google_drive_client_secret) if has_system_credentials else False, + "client_secret_value": ( + settings.google_drive_client_secret or "" if has_system_credentials else "" + ), "refresh_token": False, "refresh_token_value": "", "has_credentials_json": False, @@ -90,6 +96,7 @@ async def google_drive_setup_page( "use_oauth": use_oauth, "oauth_configured": oauth_configured, "sa_configured": sa_configured, + "has_system_credentials": bool(settings.google_drive_client_id and settings.google_drive_client_secret), "client_id": bool(settings.google_drive_client_id), "client_id_value": settings.google_drive_client_id or "", "client_secret": bool(settings.google_drive_client_secret), diff --git a/app/views/onedrive.py b/app/views/onedrive.py index 4b8b3763..d9fd5e6e 100644 --- a/app/views/onedrive.py +++ b/app/views/onedrive.py @@ -44,6 +44,9 @@ async def onedrive_setup_page( cfg = {} # Support both "folder_path" (WATCH_FOLDER / ONEDRIVE destination) folder_path = cfg.get("folder_path", cfg.get("folder", "")) + # Provide system-wide app credentials when available so users can + # authorize without registering their own Azure/OneDrive app. + has_system_credentials = bool(settings.onedrive_client_id and settings.onedrive_client_secret) return templates.TemplateResponse( "onedrive.html", { @@ -54,11 +57,12 @@ async def onedrive_setup_page( "integration_name": integration.name, "integration_type": integration.integration_type, "folder_path": folder_path, - "client_id": False, - "client_id_value": "", - "client_secret": False, - "client_secret_value": "", - "tenant_id": "common", + "has_system_credentials": has_system_credentials, + "client_id": bool(settings.onedrive_client_id) if has_system_credentials else False, + "client_id_value": settings.onedrive_client_id or "" if has_system_credentials else "", + "client_secret": bool(settings.onedrive_client_secret) if has_system_credentials else False, + "client_secret_value": (settings.onedrive_client_secret or "" if has_system_credentials else ""), + "tenant_id": settings.onedrive_tenant_id or "common", "refresh_token": False, "refresh_token_value": "", }, @@ -75,6 +79,7 @@ async def onedrive_setup_page( "request": request, "user_mode": False, "is_configured": is_configured, + "has_system_credentials": bool(settings.onedrive_client_id and settings.onedrive_client_secret), "client_id": bool(settings.onedrive_client_id), "client_id_value": settings.onedrive_client_id or "", "client_secret": bool(settings.onedrive_client_secret), diff --git a/app/views/settings.py b/app/views/settings.py index 1e520e33..0c7acdd5 100644 --- a/app/views/settings.py +++ b/app/views/settings.py @@ -195,6 +195,346 @@ async def credentials_page(request: Request, db: Session = Depends(get_db)): ) +@router.get("/admin/connections") +@require_login +@require_admin_access +async def connections_page(request: Request, db: Session = Depends(get_db)): + """ + Connections management page - admin only. + + Allows administrators to configure external authentication providers, + SSO settings, and service integrations through a wizard-like interface. + """ + try: + db_settings = get_all_settings_from_db(db) + + def _get_effective(key: str): + """Return DB value if present, else fall back to settings attr.""" + if key in db_settings and db_settings[key] is not None: + return db_settings[key] + return getattr(settings, key, None) + + def _is_truthy(val) -> bool: + if isinstance(val, bool): + return val + if isinstance(val, str): + return val.lower() in ("true", "1", "yes") + return bool(val) + + # Build service status list + services = [] + + # --- SSO (Authentik / OIDC) --- + _oidc_linked = bool(_get_effective("authentik_client_id") and _get_effective("authentik_client_secret")) + services.append( + { + "key": "oidc", + "name": _get_effective("oauth_provider_name") or "Single Sign-On", + "icon": "fas fa-lock", + "type": "SSO", + "linked": _oidc_linked, + "description": "OpenID Connect SSO provider", + "settings_keys": [ + "authentik_client_id", + "authentik_client_secret", + "authentik_config_url", + "oauth_provider_name", + ], + } + ) + + # --- Google --- + _google_id = _get_effective("social_auth_google_client_id") + _google_secret = _get_effective("social_auth_google_client_secret") + if _is_truthy(_get_effective("social_auth_google_use_global_credentials")) and not ( + _google_id and _google_secret + ): + _google_id = _google_id or _get_effective("google_drive_client_id") + _google_secret = _google_secret or _get_effective("google_drive_client_secret") + _google_linked = bool( + _is_truthy(_get_effective("social_auth_google_enabled")) and _google_id and _google_secret + ) + services.append( + { + "key": "google", + "name": "Google", + "icon": "fab fa-google", + "type": "Sign-in authentication", + "linked": _google_linked, + "description": "Sign-in authentication", + "settings_keys": [ + "social_auth_google_enabled", + "social_auth_google_client_id", + "social_auth_google_client_secret", + "social_auth_google_use_global_credentials", + ], + } + ) + + # --- GitHub --- + _github_linked = bool( + _is_truthy(_get_effective("social_auth_github_enabled")) + and _get_effective("social_auth_github_client_id") + and _get_effective("social_auth_github_client_secret") + ) + services.append( + { + "key": "github", + "name": "GitHub", + "icon": "fab fa-github", + "type": "Sign-in authentication", + "linked": _github_linked, + "description": "Sign-in authentication", + "settings_keys": [ + "social_auth_github_enabled", + "social_auth_github_client_id", + "social_auth_github_client_secret", + ], + } + ) + + # --- Microsoft --- + _ms_id = _get_effective("social_auth_microsoft_client_id") + _ms_secret = _get_effective("social_auth_microsoft_client_secret") + if _is_truthy(_get_effective("social_auth_microsoft_use_global_credentials")) and not (_ms_id and _ms_secret): + _ms_id = _ms_id or _get_effective("onedrive_client_id") + _ms_secret = _ms_secret or _get_effective("onedrive_client_secret") + _microsoft_linked = bool(_is_truthy(_get_effective("social_auth_microsoft_enabled")) and _ms_id and _ms_secret) + services.append( + { + "key": "microsoft", + "name": "Microsoft", + "icon": "fab fa-microsoft", + "type": "Sign-in authentication", + "linked": _microsoft_linked, + "description": "Sign-in authentication", + "settings_keys": [ + "social_auth_microsoft_enabled", + "social_auth_microsoft_client_id", + "social_auth_microsoft_client_secret", + "social_auth_microsoft_tenant", + "social_auth_microsoft_use_global_credentials", + ], + } + ) + + # --- Apple --- + _apple_linked = bool( + _is_truthy(_get_effective("social_auth_apple_enabled")) + and _get_effective("social_auth_apple_client_id") + and _get_effective("social_auth_apple_team_id") + ) + services.append( + { + "key": "apple", + "name": "Apple", + "icon": "fab fa-apple", + "type": "Sign-in authentication", + "linked": _apple_linked, + "description": "Sign-in authentication", + "settings_keys": [ + "social_auth_apple_enabled", + "social_auth_apple_client_id", + "social_auth_apple_team_id", + "social_auth_apple_key_id", + "social_auth_apple_private_key", + ], + } + ) + + # --- Dropbox --- + _dbx_id = _get_effective("social_auth_dropbox_client_id") + _dbx_secret = _get_effective("social_auth_dropbox_client_secret") + if _is_truthy(_get_effective("social_auth_dropbox_use_global_credentials")) and not (_dbx_id and _dbx_secret): + _dbx_id = _dbx_id or _get_effective("dropbox_app_key") + _dbx_secret = _dbx_secret or _get_effective("dropbox_app_secret") + _dropbox_linked = bool(_is_truthy(_get_effective("social_auth_dropbox_enabled")) and _dbx_id and _dbx_secret) + services.append( + { + "key": "dropbox", + "name": "Dropbox", + "icon": "fab fa-dropbox", + "type": "Sign-in authentication", + "linked": _dropbox_linked, + "description": "Sign-in authentication", + "settings_keys": [ + "social_auth_dropbox_enabled", + "social_auth_dropbox_client_id", + "social_auth_dropbox_client_secret", + "social_auth_dropbox_use_global_credentials", + ], + } + ) + + # --- Keycloak --- + _keycloak_linked = bool( + _is_truthy(_get_effective("social_auth_keycloak_enabled")) + and _get_effective("social_auth_keycloak_client_id") + and _get_effective("social_auth_keycloak_client_secret") + and _get_effective("social_auth_keycloak_server_url") + and _get_effective("social_auth_keycloak_realm") + ) + services.append( + { + "key": "keycloak", + "name": "Keycloak", + "icon": "fas fa-key", + "type": "SSO", + "linked": _keycloak_linked, + "description": "SSO", + "settings_keys": [ + "social_auth_keycloak_enabled", + "social_auth_keycloak_client_id", + "social_auth_keycloak_client_secret", + "social_auth_keycloak_server_url", + "social_auth_keycloak_realm", + ], + } + ) + + # --- Generic OAuth2 --- + _generic_oauth2_linked = bool( + _is_truthy(_get_effective("social_auth_generic_oauth2_enabled")) + and _get_effective("social_auth_generic_oauth2_client_id") + and _get_effective("social_auth_generic_oauth2_client_secret") + and _get_effective("social_auth_generic_oauth2_authorize_url") + and _get_effective("social_auth_generic_oauth2_token_url") + ) + services.append( + { + "key": "generic_oauth2", + "name": "Generic OAuth2", + "icon": "fas fa-sign-in-alt", + "type": "SSO", + "linked": _generic_oauth2_linked, + "description": "SSO", + "settings_keys": [ + "social_auth_generic_oauth2_enabled", + "social_auth_generic_oauth2_client_id", + "social_auth_generic_oauth2_client_secret", + "social_auth_generic_oauth2_authorize_url", + "social_auth_generic_oauth2_token_url", + "social_auth_generic_oauth2_userinfo_url", + "social_auth_generic_oauth2_scope", + "social_auth_generic_oauth2_name", + ], + } + ) + + # --- SAML2 --- + _saml2_configured = bool( + _is_truthy(_get_effective("social_auth_saml2_enabled")) + and _get_effective("social_auth_saml2_sso_url") + and _get_effective("social_auth_saml2_entity_id") + ) + services.append( + { + "key": "saml2", + "name": settings.social_auth_saml2_name or "SAML2", + "icon": "fas fa-id-badge", + "type": "SSO (SAML)", + "linked": _saml2_configured, + "description": "SSO (SAML)", + "settings_keys": [ + "social_auth_saml2_enabled", + "social_auth_saml2_entity_id", + "social_auth_saml2_sso_url", + "social_auth_saml2_certificate", + "social_auth_saml2_name", + ], + } + ) + + # --- SMTP Mail --- + _smtp_configured = bool(_get_effective("email_host") and _get_effective("email_username")) + services.append( + { + "key": "smtp", + "name": "SMTP Mail", + "icon": "fas fa-envelope", + "type": "Email Notifications", + "linked": _smtp_configured, + "description": "Email Notifications", + "settings_keys": [ + "email_host", + "email_port", + "email_username", + "email_password", + "email_use_tls", + "email_sender", + ], + } + ) + + # --- Telegram Bot --- + _telegram_configured = bool( + _is_truthy(_get_effective("telegram_enabled")) and _get_effective("telegram_bot_token") + ) + services.append( + { + "key": "telegram", + "name": "Telegram Bot", + "icon": "fab fa-telegram", + "type": "Notifications", + "linked": _telegram_configured, + "description": "Configure Telegram bot connectivity, access controls, and feedback behavior.", + "settings_keys": [ + "telegram_enabled", + "telegram_bot_token", + "telegram_chat_id", + ], + } + ) + + # Get setting details for the modal forms + service_settings = {} + for svc in services: + svc_settings = [] + for skey in svc["settings_keys"]: + meta = get_setting_metadata(skey) + # Get current effective value + val = _get_effective(skey) + display_val = val + if meta.get("sensitive") and val: + display_val = mask_sensitive_value(val) + svc_settings.append( + { + "key": skey, + "value": val, + "display_value": display_val if display_val is not None else "", + "metadata": meta, + } + ) + service_settings[svc["key"]] = svc_settings + + # Feature toggles + sso_auto_login = _is_truthy(_get_effective("sso_auto_login")) + qr_login_enabled = _is_truthy(_get_effective("qr_login_enabled")) + frontend_url_configured = bool(_get_effective("public_base_url")) + + return templates.TemplateResponse( + "admin_connections.html", + { + "request": request, + "services": services, + "service_settings": service_settings, + "sso_auto_login": sso_auto_login, + "oauth_configured": _oidc_linked, + "qr_login_enabled": qr_login_enabled, + "frontend_url_configured": frontend_url_configured, + "app_version": settings.version, + }, + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error loading connections page: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to load connections page", + ) + + @router.get("/admin/settings/audit-log") @require_login @require_admin_access diff --git a/docs/API.md b/docs/API.md index 29a3dc7a..d6a36c7b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -27,9 +27,11 @@ DocuElevate implements rate limiting to protect against abuse and DoS attacks. R ### Default Limits - **Default endpoints**: 100 requests per minute -- **File upload**: 600 requests per minute +- **File upload**: 600 requests per minute (global) + 20 per user per 60 s (per-user, health-aware) - **Authentication**: 10 requests per minute +**Per-user upload rate limiting**: Upload endpoints (`/api/ui-upload`, `/api/process-url`) enforce a per-user sliding-window limit that adapts to system load. Under heavy queue depth or high CPU usage, the effective limit is reduced automatically. See the [Configuration Guide](ConfigurationGuide.md#per-user-upload-rate-limiting) for details. + **Note**: Document processing endpoints (OCR, metadata extraction) use built-in queue throttling to control processing rates and prevent upstream API overloads. No additional API-level rate limit is applied to processing endpoints. ### Rate Limit Headers @@ -53,6 +55,10 @@ RATE_LIMITING_ENABLED=true RATE_LIMIT_DEFAULT=100/minute RATE_LIMIT_UPLOAD=600/minute RATE_LIMIT_AUTH=10/minute + +# Per-user upload rate limiting (health-aware) +UPLOAD_RATE_LIMIT_PER_USER=20 # Max uploads per user per window +UPLOAD_RATE_LIMIT_WINDOW=60 # Sliding window in seconds ``` See [Configuration Guide](ConfigurationGuide.md) for more details. @@ -115,7 +121,8 @@ curl -X GET "http:///api/files" \ |--------|----------|-------------| | `POST` | `/api/api-tokens/` | Create a new token | | `GET` | `/api/api-tokens/` | List all your tokens | -| `DELETE` | `/api/api-tokens/{id}` | Revoke a token | +| `DELETE` | `/api/api-tokens/{id}` | Revoke (active) or permanently delete (revoked) a token | +| `POST` | `/api/api-tokens/{id}/reactivate` | Reactivate a revoked token | ### Session Authentication @@ -235,17 +242,33 @@ The DocuElevate browser extension uses this endpoint to send files directly from **POST** `/api/ui-upload` -Upload one or more files from your computer for processing. +Upload a file from your computer for processing. **Request**: -- Multipart form data with file(s) +- Multipart form data with a single `file` field -**Response**: +**Response** (new file): ```json { - "success": true, - "file_ids": [123, 124], - "message": "Files uploaded and queued for processing" + "task_id": "abc-123", + "status": "queued", + "original_filename": "invoice.pdf", + "stored_filename": "a1b2c3d4.pdf" +} +``` + +**Response** (exact duplicate, when `ENABLE_DEDUPLICATION=True`): +```json +{ + "status": "duplicate", + "original_filename": "invoice.pdf", + "stored_filename": "e5f6a7b8.pdf", + "duplicate_of": { + "duplicate_type": "exact", + "original_file_id": 42, + "original_filename": "invoice.pdf", + "message": "This file is an exact duplicate of an already-processed document. It has not been queued for processing again." + } } ``` @@ -1356,7 +1379,7 @@ Test an integration connection without saving. Useful for "Test connection" UI b {"success": true, "message": "IMAP connection successful"} ``` -Supported connection tests: `IMAP`, `S3`, `WEBDAV`, `NEXTCLOUD`. Other types return a message that testing is not yet supported. +Supported connection tests: `DROPBOX`, `IMAP`, `S3`, `WEBDAV`, `NEXTCLOUD`. Other types return a message that testing is not yet supported. ### GET /api/integrations/quota/ @@ -1381,6 +1404,55 @@ Get the current user's integration quota usage. } ``` +## Cloud Provider Folder Browser + +Browse folders in connected cloud storage providers. These endpoints are used by the OAuth callback pages to let users select a target folder after authorization. + +### POST /api/dropbox/list-folders + +List folders in a Dropbox account. Requires a short-lived OAuth access token obtained during the authorization flow. + +**Request (form-data):** + +| Field | Type | Required | Description | +|----------------|--------|----------|--------------------------------------| +| `access_token` | string | Yes | Dropbox OAuth access token | +| `path` | string | No | Folder path to list (default: root) | + +**Response (200):** +```json +{ + "folders": [ + { "name": "Documents", "path": "/Documents", "id": "id:abc123" }, + { "name": "Photos", "path": "/Photos", "id": "id:def456" } + ], + "path": "/", + "has_more": false +} +``` + +### POST /api/onedrive/list-folders + +List folders in a OneDrive account. Requires a short-lived OAuth access token obtained during the authorization flow. + +**Request (form-data):** + +| Field | Type | Required | Description | +|----------------|--------|----------|--------------------------------------| +| `access_token` | string | Yes | Microsoft Graph access token | +| `path` | string | No | Folder path to list (default: root) | + +**Response (200):** +```json +{ + "folders": [ + { "name": "Documents", "path": "/Documents", "id": "abc123", "child_count": 5 }, + { "name": "Pictures", "path": "/Pictures", "id": "def456", "child_count": 12 } + ], + "path": "/" +} +``` + ## Webhooks Manage webhook configurations for notifying external systems when document events occur. All webhook endpoints require admin access. @@ -1571,6 +1643,47 @@ Lightweight endpoint returning the total number of queued + in-progress items. D ## Diagnostic +### GET /api/diagnostic/healthz/live + +Lightweight liveness probe for Kubernetes. Returns **200 OK** as long as the process is running. This endpoint does **not** check external dependencies and is intentionally cheap. + +**Authentication:** None (designed for kubelet probes) + +**Response (200 OK):** +```json +{ + "status": "ok" +} +``` + +### GET /api/diagnostic/healthz/ready + +Readiness probe for Kubernetes. Verifies that the application can serve traffic by checking database and Redis connectivity. + +**Authentication:** None (designed for kubelet probes) + +**Response (200 OK) – ready to serve traffic:** +```json +{ + "status": "ready", + "checks": { + "database": {"status": "ok"}, + "redis": {"status": "ok"} + } +} +``` + +**Response (503 Service Unavailable) – database unreachable:** +```json +{ + "status": "not_ready", + "checks": { + "database": {"status": "error", "detail": "..."}, + "redis": {"status": "ok"} + } +} +``` + ### GET /api/diagnostic/health System health endpoint designed for monitoring tools such as Grafana, Uptime Kuma, Prometheus blackbox exporter, or any HTTP-based health checker. @@ -2127,12 +2240,14 @@ Usage tracking records when each token was last used and from which IP address. ### POST /api/api-tokens/ -Create a new API token. +Create a new API token. Optionally specify a lifetime in days via +`expires_in_days` (1–3650). If omitted the token never expires. **Request:** ```json { - "name": "CI Pipeline" + "name": "CI Pipeline", + "expires_in_days": 90 } ``` @@ -2147,7 +2262,8 @@ Create a new API token. "last_used_at": null, "last_used_ip": null, "created_at": "2026-03-08T12:00:00Z", - "revoked_at": null + "revoked_at": null, + "expires_at": "2026-06-06T12:00:00Z" } ``` @@ -2169,15 +2285,20 @@ List all tokens for the authenticated user. The full token value is never includ "last_used_at": "2026-03-08T15:30:00Z", "last_used_ip": "203.0.113.42", "created_at": "2026-03-08T12:00:00Z", - "revoked_at": null + "revoked_at": null, + "expires_at": "2026-06-06T12:00:00Z" } ] ``` ### DELETE /api/api-tokens/{token_id} -Revoke a token. The token is soft-deleted (kept for audit purposes) and can no -longer be used for authentication. +Revoke or permanently delete a token: + +* **Active token** – soft-revoked (kept for audit purposes, marked inactive). + Response: `{"detail": "Token revoked"}` +* **Already-revoked token** – permanently deleted from the database. + Response: `{"detail": "Token deleted"}` **Response (200):** ```json @@ -2186,6 +2307,13 @@ longer be used for authentication. } ``` +### POST /api/api-tokens/{token_id}/reactivate + +Reactivate a previously revoked token. Clears `revoked_at` and sets +`is_active` back to `true`. + +**Response (200):** The updated `TokenResponse` object. + ### Using API Tokens Include the token in the `Authorization` header of any API request: @@ -2214,6 +2342,316 @@ print(response.json()) ``` +## Classification Rules + +The classification rules API lets you manage custom document classification rules. Rules are evaluated during the `classify` pipeline step to assign a category to each document based on filename patterns, content keywords, and metadata fields. + +### Built-in Categories + +```bash +GET /api/classification-rules/categories +``` + +Returns the pre-built classification categories. + +**Response (200):** +```json +{ + "invoice": "Invoice", + "contract": "Contract", + "receipt": "Receipt", + "letter": "Letter", + "report": "Report", + "bank_statement": "Bank Statement", + "tax_document": "Tax Document", + "insurance": "Insurance Document", + "payslip": "Payslip", + "unknown": "Unknown" +} +``` + +### Rule Types + +```bash +GET /api/classification-rules/rule-types +``` + +Returns the supported rule types with descriptions. + +**Response (200):** +```json +[ + { + "type": "filename_pattern", + "label": "Filename Pattern", + "description": "Regex pattern matched against the original filename." + }, + { + "type": "content_keyword", + "label": "Content Keyword", + "description": "Pipe-separated keywords matched against the OCR text." + }, + { + "type": "metadata_match", + "label": "Metadata Match", + "description": "field=value pattern matched against existing AI metadata." + } +] +``` + +### List Rules + +```bash +GET /api/classification-rules/ +``` + +List all classification rules visible to the current user (system rules + own rules). + +**Response (200):** +```json +[ + { + "id": 1, + "owner_id": "user@example.com", + "name": "German Invoice Filename", + "category": "invoice", + "rule_type": "filename_pattern", + "pattern": "(?i)rechnung", + "priority": 10, + "case_sensitive": false, + "enabled": true + } +] +``` + +### Create Rule + +```bash +POST /api/classification-rules/ +``` + +Create a new custom classification rule. + +**Request:** +```json +{ + "name": "German Invoice Filename", + "category": "invoice", + "rule_type": "filename_pattern", + "pattern": "(?i)rechnung", + "priority": 10, + "case_sensitive": false, + "enabled": true +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Unique rule name (per user) | +| `category` | string | Yes | Target category (e.g. `invoice`, `contract`, or custom) | +| `rule_type` | string | Yes | One of: `filename_pattern`, `content_keyword`, `metadata_match` | +| `pattern` | string | Yes | Regex (filename), pipe-separated keywords (content), or `field=value` (metadata) | +| `priority` | integer | No | Higher priority rules are evaluated first (default: 0) | +| `case_sensitive` | boolean | No | Case-sensitive matching (default: false) | +| `enabled` | boolean | No | Whether the rule is active (default: true) | + +**Response (201):** +```json +{ + "id": 1, + "owner_id": "user@example.com", + "name": "German Invoice Filename", + "category": "invoice", + "rule_type": "filename_pattern", + "pattern": "(?i)rechnung", + "priority": 10, + "case_sensitive": false, + "enabled": true +} +``` + +### Get Rule + +```bash +GET /api/classification-rules/{rule_id} +``` + +### Update Rule + +```bash +PUT /api/classification-rules/{rule_id} +``` + +**Request (partial update):** +```json +{ + "priority": 20, + "enabled": false +} +``` + +### Delete Rule + +```bash +DELETE /api/classification-rules/{rule_id} +``` + +**Response:** `204 No Content` + + + +## Automation (Zapier / Make.com) + +Manage automation hook subscriptions for integrating DocuElevate with external platforms like Zapier and Make.com. All endpoints require API token authentication (`Authorization: Bearer `). + +### Supported Events + +The automation system shares event types with the [Webhooks](#webhooks) subsystem: + +| Event | Description | +|-------|-------------| +| `document.uploaded` | A new document has been ingested | +| `document.processed` | A document finished processing successfully | +| `document.failed` | Document processing failed | +| `user.signup` | A new user account was created | +| `user.plan_changed` | A user's subscription plan changed | +| `user.payment_issue` | A payment issue was reported for a user | + +### GET /api/automation/events + +List all valid event types that automation hooks can subscribe to. + +**Response (200):** +```json +["document.failed", "document.processed", "document.uploaded", "user.payment_issue", "user.plan_changed", "user.signup"] +``` + +### POST /api/automation/hooks/subscribe + +Subscribe to DocuElevate events. Zapier and Make.com call this endpoint to register a webhook URL that receives event notifications. + +**Request:** +```bash +curl -X POST "http://your-instance/api/automation/hooks/subscribe" \ + -H "Authorization: Bearer de_your_token_here" \ + -H "Content-Type: application/json" \ + -d '{ + "target_url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/", + "events": ["document.processed", "document.uploaded"], + "hook_type": "zapier", + "secret": "optional-signing-secret", + "description": "My Zap for processed documents" + }' +``` + +**Response (201):** +```json +{ + "id": 1, + "target_url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/", + "events": ["document.processed", "document.uploaded"], + "is_active": true, + "hook_type": "zapier", + "description": "My Zap for processed documents", + "has_secret": true +} +``` + +### GET /api/automation/hooks + +List all automation hook subscriptions. + +**Response (200):** +```json +[ + { + "id": 1, + "target_url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/", + "events": ["document.processed", "document.uploaded"], + "is_active": true, + "hook_type": "zapier", + "description": "My Zap for processed documents", + "has_secret": true + } +] +``` + +### DELETE /api/automation/hooks/{hook_id} + +Unsubscribe an automation hook. Zapier calls this when a Zap is turned off or deleted. + +**Response (204):** No content. + +### GET /api/automation/triggers/sample/{event} + +Get sample trigger data for Zapier field mapping. Zapier uses this during Zap setup to discover available fields. + +**Request:** +```bash +curl "http://your-instance/api/automation/triggers/sample/document.processed" \ + -H "Authorization: Bearer de_your_token_here" +``` + +**Response (200):** +```json +[ + { + "id": "evt_sample0002", + "event": "document.processed", + "timestamp": 1710000060.0, + "document_id": 42, + "filename": "invoice_2024.pdf", + "status": "processed", + "title": "Invoice #1234", + "owner_id": "user@example.com" + } +] +``` + +### POST /api/automation/actions/upload + +Upload a document from an automation platform. This incoming action endpoint allows Zapier or Make.com to push documents into DocuElevate for processing. + +**Request:** +```bash +curl -X POST "http://your-instance/api/automation/actions/upload" \ + -H "Authorization: Bearer de_your_token_here" \ + -F "file=@/path/to/document.pdf" +``` + +**Response (200):** +```json +{ + "status": "accepted", + "filename": "document.pdf", + "task_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" +} +``` + +### Zapier-Compatible Payload Format + +When events fire, automation hooks receive a **flat JSON payload** (no nested `data` key) that Zapier and Make.com can easily map: + +```json +{ + "id": "evt_a1b2c3d4e5f67890", + "event": "document.processed", + "timestamp": 1710000060.0, + "document_id": 42, + "filename": "invoice_2024.pdf", + "status": "processed", + "title": "Invoice #1234", + "owner_id": "user@example.com" +} +``` + +The `id` field is unique per event and is used by Zapier for deduplication. If a `secret` was provided during subscription, an `X-Webhook-Signature` header with an HMAC-SHA256 signature is included. + +### Retry Behavior + +Automation hook deliveries follow the same retry policy as regular webhooks: up to 3 retries with exponential backoff (60 s, 300 s, 900 s) and ±20% jitter. + + ## Further Assistance For additional help with the API, please contact our support team or refer to the [Development Guide](../CONTRIBUTING.md). @@ -2268,9 +2706,14 @@ List all registered push-notification devices for the current user. ### DELETE /api/mobile/devices/{device_id} -Deactivate a push-notification device. The device will no longer receive push notifications. +Deactivate or permanently delete a push-notification device: -**Response (204 No Content)** +* **Active device** – soft-deactivated (record kept, will no longer receive push notifications). + Response: `{"detail": "Device deactivated"}` +* **Already-inactive device** – permanently deleted from the database. + Response: `{"detail": "Device deleted"}` + +**Response (200)** ### GET /api/mobile/whoami @@ -2466,3 +2909,340 @@ Move original files to a reimport folder, wipe everything, and configure the rei } } ``` + +--- + +## Comments & Annotations + +Threaded comments and PDF annotations for document collaboration. + +### List Comments + +**GET** `/api/files/{file_id}/comments` + +Returns all comments for a document, organized into a threaded tree. + +**Response (200):** +```json +{ + "file_id": 1, + "comments": [ + { + "id": 1, + "file_id": 1, + "user_id": "alice", + "parent_id": null, + "body": "Please review section 3.", + "mentions": ["bob"], + "is_resolved": false, + "created_at": "2026-03-21T12:00:00+00:00", + "updated_at": "2026-03-21T12:00:00+00:00", + "replies": [ + { + "id": 2, + "file_id": 1, + "user_id": "bob", + "parent_id": 1, + "body": "Done!", + "mentions": [], + "is_resolved": false, + "created_at": "2026-03-21T12:05:00+00:00", + "updated_at": "2026-03-21T12:05:00+00:00", + "replies": [] + } + ] + } + ], + "total": 2 +} +``` + +### Create Comment + +**POST** `/api/files/{file_id}/comments` + +Create a new comment on a document. @mentions are automatically extracted from the body. + +**Request:** +```json +{ + "body": "Hey @bob, please review this section.", + "parent_id": null +} +``` + +**Response (201):** +```json +{ + "id": 3, + "file_id": 1, + "user_id": "alice", + "parent_id": null, + "body": "Hey @bob, please review this section.", + "mentions": ["bob"], + "is_resolved": false, + "created_at": "2026-03-21T12:10:00+00:00", + "updated_at": "2026-03-21T12:10:00+00:00" +} +``` + +### Update Comment + +**PUT** `/api/files/{file_id}/comments/{comment_id}` + +Update the body of an existing comment. Only the comment author may update it. + +**Request:** +```json +{ + "body": "Updated comment text @charlie" +} +``` + +### Delete Comment + +**DELETE** `/api/files/{file_id}/comments/{comment_id}` + +Delete a comment. Only the comment author may delete it. + +**Response:** `204 No Content` + +### Resolve / Unresolve Comment + +**PATCH** `/api/files/{file_id}/comments/{comment_id}/resolve` + +Mark a comment thread as resolved or unresolved. + +**Request:** +```json +{ + "is_resolved": true +} +``` + +### List Annotations + +**GET** `/api/files/{file_id}/annotations` + +Returns all PDF page annotations for a document, ordered by page then creation time. + +**Response (200):** +```json +{ + "file_id": 1, + "annotations": [ + { + "id": 1, + "file_id": 1, + "user_id": "alice", + "page": 1, + "x": 100.0, + "y": 200.0, + "width": 150.0, + "height": 20.0, + "content": "Important paragraph", + "annotation_type": "highlight", + "color": "#ffff00", + "created_at": "2026-03-21T12:00:00+00:00", + "updated_at": "2026-03-21T12:00:00+00:00" + } + ], + "total": 1 +} +``` + +### Create Annotation + +**POST** `/api/files/{file_id}/annotations` + +Create a new annotation on a PDF page. + +**Request:** +```json +{ + "page": 1, + "x": 100.0, + "y": 200.0, + "width": 150.0, + "height": 20.0, + "content": "Important paragraph", + "annotation_type": "highlight", + "color": "#ffff00" +} +``` + +Allowed `annotation_type` values: `note`, `highlight`, `underline`, `strikethrough`. + +### Update Annotation + +**PUT** `/api/files/{file_id}/annotations/{annotation_id}` + +Update an existing annotation. Only the annotation author may update it. + +**Request** (all fields optional): +```json +{ + "content": "Updated note", + "color": "#00ff00" +} +``` + +### Delete Annotation + +**DELETE** `/api/files/{file_id}/annotations/{annotation_id}` + +Delete an annotation. Only the annotation author may delete it. + +**Response:** `204 No Content` + +### List Mentionable Users + +**GET** `/api/users/mentionable` + +Returns all non-blocked user profiles for the @mention autocomplete. + +**Response (200):** +```json +[ + { "user_id": "alice", "display_name": "Alice Anderson" }, + { "user_id": "bob", "display_name": "Bob Baker" } +] +``` + +--- + +## File Sharing & Permissions + +DocuElevate supports per-user document sharing with role-based access control. + +### Roles + +| Role | View | Comment / Annotate | Edit metadata | Delete | Share | +|----------|------|--------------------|---------------|--------|-------| +| `owner` | ✓ | ✓ | ✓ | ✓ | ✓ | +| `editor` | ✓ | ✓ | ✓ | ✗ | ✗ | +| `viewer` | ✓ | ✓ | ✗ | ✗ | ✗ | + +- Only the **file owner** can share a document, change roles, or delete the document. +- When a user is **@mentioned** in a comment they are automatically granted `viewer` access to the document (multi-user mode only). + +--- + +### List Shares + +**GET** `/api/files/{file_id}/shares` + +Returns all active shares for a document. Only the file owner (or an admin) may call this endpoint. + +**Response (200):** +```json +[ + { + "id": 1, + "file_id": 42, + "owner_id": "alice", + "shared_with_user_id": "bob", + "role": "viewer", + "created_at": "2026-03-22T10:00:00+00:00", + "updated_at": "2026-03-22T10:00:00+00:00" + } +] +``` + +**Error Responses:** +- `403`: Not the file owner +- `404`: File not found + +--- + +### Create Share + +**POST** `/api/files/{file_id}/shares` + +Share a document with another user. Only the file owner may call this endpoint. If the user already has a share, their role is updated. + +**Request:** +```json +{ + "shared_with_user_id": "bob", + "role": "viewer" +} +``` + +`role` must be `"viewer"` (default) or `"editor"`. + +**Response (201):** +```json +{ + "id": 1, + "file_id": 42, + "owner_id": "alice", + "shared_with_user_id": "bob", + "role": "viewer", + "created_at": "2026-03-22T10:00:00+00:00", + "updated_at": "2026-03-22T10:00:00+00:00" +} +``` + +**Error Responses:** +- `403`: Not the file owner +- `422`: Invalid role, empty user ID, or sharing with self + +--- + +### Update Share Role + +**PUT** `/api/files/{file_id}/shares/{share_id}` + +Change the role of an existing share. Only the file owner may call this endpoint. + +**Request:** +```json +{ + "role": "editor" +} +``` + +**Response (200):** Updated share object. + +**Error Responses:** +- `403`: Not the file owner +- `404`: Share not found +- `422`: Invalid role + +--- + +### Revoke Share + +**DELETE** `/api/files/{file_id}/shares/{share_id}` + +Remove a user's access to a document. Only the file owner may revoke shares. + +**Response (200):** +```json +{ "status": "success", "message": "Share revoked successfully" } +``` + +**Error Responses:** +- `403`: Not the file owner +- `404`: Share not found + +--- + +### List Shared With + +**GET** `/api/files/{file_id}/shared-with` + +Returns who a document is shared with. Accessible to any user with at least `viewer` access (owner, editors, and viewers can all call this). + +**Response (200):** +```json +[ + { + "share_id": 1, + "user_id": "bob", + "display_name": "Bob Baker", + "role": "viewer" + } +] +``` diff --git a/docs/AppleAppStoreCompliance.md b/docs/AppleAppStoreCompliance.md new file mode 100644 index 00000000..2df05a2a --- /dev/null +++ b/docs/AppleAppStoreCompliance.md @@ -0,0 +1,285 @@ +# Apple App Store Compliance Audit Report + +This document details the findings from a comprehensive audit of the DocuElevate mobile app against Apple's App Store Review Guidelines, Human Interface Guidelines (HIG), and privacy requirements. It covers all areas of compliance, risks for rejection, and recommendations. + +> **Last Audited:** March 2026 +> **App Version:** 1.0.0 +> **Expo SDK:** 54.0.0 +> **Bundle ID:** `org.docuelevate.mobile` + +--- + +## Executive Summary + +The DocuElevate mobile app is broadly compliant with Apple's App Store requirements. The following issues were identified and resolved as part of this audit: + +| Issue | Severity | Status | +|-------|----------|--------| +| Unused `fetch` background mode declared | High | ✅ Fixed | +| Missing privacy manifest for required reason APIs | High | ✅ Fixed | +| No account deletion option (Guideline 5.1.1(v)) | Critical | ✅ Fixed | +| No Privacy Policy / Terms of Service links in-app | High | ✅ Fixed | +| Emoji used as UI icons instead of platform-native icons | Medium | ✅ Fixed | +| Missing app version display | Low | ✅ Fixed | +| Unused `Switch` import in ProfileScreen | Low | ✅ Fixed | + +--- + +## 1. Human Interface Guidelines (HIG) + +### 1.1 Navigation & Tab Bar ✅ + +- The app uses a standard bottom tab bar with three tabs: Upload, Files, and Profile. +- Tab icons use **Ionicons** (an icon set that closely maps to Apple's SF Symbols). +- Active/inactive tab colors follow iOS conventions (`#1e40af` active, `#9ca3af` inactive). +- Header styling uses a solid color background with white text, consistent with iOS navigation bar patterns. + +### 1.2 Icons & Visual Assets ✅ + +- **App icon:** Custom `icon.png` provided at root level; Expo handles generating all required sizes. +- **Splash screen:** Uses branded splash with `contain` resize mode and matching background color. +- **Adaptive icon (Android):** Properly configured with foreground image and background color. +- **Action buttons:** Previously used emoji characters (📷, 🖼️, 📄) which render inconsistently across iOS versions. **Fixed:** Now using Ionicons (`camera-outline`, `images-outline`, `document-outline`). +- **Status indicators:** Previously used emoji (✅, ❌, ⏳, ⚙️). **Fixed:** Now using Ionicons with semantic colors. + +### 1.3 Typography & Colors ✅ + +- Uses system fonts (default React Native text rendering uses San Francisco on iOS). +- Color palette (`#1e40af` primary blue, semantic reds/greens/grays) provides sufficient contrast ratios. +- Text sizes follow iOS recommended minimums (body text ≥ 13pt). + +### 1.4 Touch Targets ✅ + +- All interactive elements have `minHeight: 44` or `minHeight: 48` (meets Apple's 44×44pt minimum). +- Back links, cancel buttons, and retry buttons all meet minimum touch target requirements. + +### 1.5 Safe Areas ✅ + +- The app uses `react-native-safe-area-context` (`SafeAreaProvider`) to respect device notches, Dynamic Island, and home indicator. + +### 1.6 Dark Mode ✅ + +- `userInterfaceStyle: "automatic"` is set in `app.json`, enabling automatic dark mode support. + +--- + +## 2. Privacy & Data Usage + +### 2.1 Permission Descriptions ✅ + +All iOS permission strings (Info.plist keys) are present and provide clear, specific descriptions of why each permission is needed: + +| Permission | Key | Description | +|-----------|-----|-------------| +| Camera | `NSCameraUsageDescription` | "DocuElevate uses the camera to scan QR codes for login and to capture documents for upload." | +| Photo Library (Read) | `NSPhotoLibraryUsageDescription` | "DocuElevate accesses your photo library to select documents for upload." | +| Photo Library (Write) | `NSPhotoLibraryAddUsageDescription` | "DocuElevate saves scanned documents to your photo library." | + +**Assessment:** All descriptions clearly explain the purpose, which is a requirement for App Review approval. + +### 2.2 Push Notifications ✅ + +- Push notification permission is requested at runtime (not at launch) when the user enters the authenticated area. +- The app works gracefully without push notifications if permission is denied. +- Device tokens are registered via a dedicated backend endpoint. + +### 2.3 Background Modes ✅ (Fixed) + +- **Previous state:** `UIBackgroundModes` included `["fetch", "remote-notification"]`. +- **Issue:** The app does not implement background fetch (`application:performFetchWithCompletionHandler:`). Apple may reject apps that declare background modes they don't actively use (Guideline 2.5.4). +- **Fix:** Removed `fetch` from `UIBackgroundModes`. Only `remote-notification` remains, which is required for push notification delivery. + +### 2.4 Privacy Manifest ✅ (Fixed) + +Starting in Spring 2024, Apple requires a privacy manifest (`PrivacyInfo.xcprivacy`) for apps using specific APIs. The following required reason APIs are used by the app's dependencies: + +| API Category | Reason Code | Justification | +|-------------|-------------|---------------| +| `NSPrivacyAccessedAPICategoryUserDefaults` | `CA92.1` | Used by `@react-native-async-storage/async-storage` for user preferences | +| `NSPrivacyAccessedAPICategoryFileTimestamp` | `C617.1` | Used by `expo-file-system` to read file metadata | +| `NSPrivacyAccessedAPICategoryDiskSpace` | `E174.1` | Used by Expo runtime for storage space checks | +| `NSPrivacyAccessedAPICategorySystemBootTime` | `35F9.1` | Used by React Native's timing APIs | + +The privacy manifest is configured via `expo-build-properties` plugin in `app.json`, which ensures it is included in the generated Xcode project during EAS Build. + +### 2.5 Tracking & Analytics ✅ + +- `NSPrivacyTracking: false` — the app does **not** track users. +- `NSPrivacyCollectedDataTypes: []` — no data types are collected for tracking. +- No analytics SDKs (Firebase Analytics, Amplitude, Mixpanel, etc.) are included. +- No App Tracking Transparency (ATT) prompt is needed. + +### 2.6 Encryption Declaration ✅ + +- `ITSAppUsesNonExemptEncryption: false` — the app uses only standard HTTPS/TLS for network communication, which is exempt from export compliance requirements. + +### 2.7 Data Storage Security ✅ + +- API tokens are stored in the device keychain via `expo-secure-store` (uses iOS Keychain Services). +- No sensitive data is stored in `AsyncStorage` or `UserDefaults`. +- Server URL is stored in secure storage, not in plain text files. + +--- + +## 3. App Store Review Guidelines Compliance + +### 3.1 Functionality (Guideline 2.x) ✅ + +- **2.1 App Completeness:** The app provides a complete, functional experience. All advertised features (camera capture, file upload, document list, push notifications) work as described. +- **2.3 Accurate Metadata:** App name ("DocuElevate"), description, and screenshots should accurately reflect the app's functionality. +- **2.5.4 Background Modes:** Only `remote-notification` is declared, which is actively used. ✅ Fixed. + +### 3.2 Content & Intellectual Property (Guideline 3.x) ✅ + +- No third-party trademarked content is used. +- The app does not display user-generated content publicly (documents are private to each user). +- No copyrighted content is bundled with the app. + +### 3.3 Business (Guideline 3.1.x) ✅ + +- The app does not include in-app purchases, subscriptions, or payment processing. +- No physical goods or services are sold through the app. +- Authentication is handled via self-hosted or enterprise SSO — no Apple Sign-In requirement applies (Apple Sign-In is required only when third-party social login options like Google/Facebook are offered as the primary login method; enterprise SSO to a self-hosted server is exempt). + +### 3.4 Safety & Privacy (Guideline 5.x) ✅ + +- **5.1.1 Data Collection and Storage:** The app collects only what is necessary for its functionality (server URL, auth token, push token). +- **5.1.1(v) Account Deletion:** ✅ Fixed. Users can now initiate account deletion from the Profile screen, which opens the server's account deletion page in the browser. +- **5.1.2 Data Use and Sharing:** No data is shared with third parties or used for advertising. + +### 3.5 Privacy Policy ✅ (Fixed) + +- **Requirement:** Apple requires all apps to have an accessible privacy policy. +- **Fix:** Privacy Policy and Terms of Service links are now accessible from the Profile screen, opening the server's hosted policy pages. +- **App Store Connect:** The privacy policy URL must also be provided in App Store Connect during submission. + +### 3.6 Login & Authentication ✅ + +- Two login methods are available: SSO (browser-based OAuth) and QR code scanning. +- Both methods provide clear error messages on failure. +- The app correctly handles authentication cancellation. +- Session restoration on app launch is implemented. +- **Demo Account:** For App Review, a demo account may need to be provided in App Store Connect's review notes. Ensure the review team can access a test server. + +--- + +## 4. Technical Compliance + +### 4.1 API Usage ✅ + +- No private APIs are used (all functionality comes from Expo SDK and React Native public APIs). +- No deprecated APIs are used that would trigger rejection. + +### 4.2 Network Security ✅ + +- The app validates server URLs require `http://` or `https://` scheme. +- All API calls use Bearer token authentication over HTTPS. +- App Transport Security (ATS) is not explicitly disabled — default iOS ATS rules apply. + +### 4.3 Deep Linking ✅ + +- Custom URL scheme `docuelevate://` is properly registered. +- Deep link handling for QR login (`docuelevate://qr-login`) and file sharing is implemented correctly. +- `WebBrowser.openAuthSessionAsync` is used for OAuth, which properly handles the authentication session lifecycle. + +### 4.4 Document Handling ✅ + +- `CFBundleDocumentTypes` properly declares supported file types. +- `LSSupportsOpeningDocumentsInPlace: false` ensures iOS copies shared files to the app's accessible Inbox directory, avoiding security-scoped URL issues. +- The `+not-found.tsx` handler correctly intercepts iOS "Open In…" file paths. +- `UploadScreen` uses `expo-file-system` to copy external files to cache before uploading for reliable file access. + +### 4.5 Crash Resistance ✅ + +- All network calls are wrapped in try/catch blocks. +- Error states are displayed to users with actionable recovery options (retry buttons). +- Permission denials are handled gracefully with explanatory messages. + +--- + +## 5. Onboarding & First-Run Experience + +### 5.1 Welcome Screen ✅ + +- Clean, informative welcome screen with app branding and feature highlights. +- Clear "Get Started" call-to-action leading to the login screen. +- No misleading claims or functionality promises. + +### 5.2 Login Flow ✅ + +- Server URL entry with input validation. +- Two clear authentication options (SSO and QR code). +- Error handling with user-friendly alert dialogs. +- Back navigation available from all auth screens. + +### 5.3 First-Run Permissions ✅ + +- Camera permission is requested at the point of use (when tapping Camera button), not at launch. +- Photo library permission is requested at the point of use. +- Push notification permission is requested after authentication, not before. +- All permission requests include clear usage descriptions. + +--- + +## 6. Remaining Recommendations + +### 6.1 App Store Connect Preparation + +Before submission, ensure the following are configured in App Store Connect: + +- [ ] **Privacy Policy URL** — must point to the server's `/privacy` endpoint +- [ ] **App Store description** — accurate description of features +- [ ] **Screenshots** — for iPhone and iPad (since `supportsTablet: true`) +- [ ] **App category** — "Business" or "Productivity" +- [ ] **Age rating** — complete the questionnaire (likely 4+) +- [ ] **Review notes** — provide demo server URL and test credentials for the Apple review team +- [ ] **Privacy Nutrition Labels** — declare data types collected (device ID for push notifications, authentication tokens) + +### 6.2 Accessibility Enhancements (Recommended) + +While the app includes `accessibilityRole` and `accessibilityLabel` on interactive elements, consider: + +- Adding `accessibilityHint` to buttons where the action isn't immediately obvious. +- Testing with VoiceOver to ensure all screens are fully navigable. +- Ensuring all status changes are announced to screen readers. + +### 6.3 iPad Support + +The app declares `supportsTablet: true`. Ensure: + +- UI scales appropriately on iPad screen sizes. +- Split View and Slide Over multitasking work correctly. +- Touch targets remain accessible on larger screens. + +### 6.4 Localization (Future Enhancement) + +- The app currently uses English-only strings. +- For broader App Store reach, consider localizing the app name, description, and in-app strings. + +--- + +## 7. Compliance Checklist Summary + +| Area | Status | Notes | +|------|--------|-------| +| Human Interface Guidelines | ✅ Pass | Ionicons used for platform-consistent iconography | +| App Icons & Visual Assets | ✅ Pass | All required assets provided | +| Device Data Usage | ✅ Pass | Camera, photos, notifications properly handled | +| Privacy Disclosures | ✅ Pass | Info.plist keys and privacy manifest configured | +| Background Modes | ✅ Pass | Only `remote-notification` declared | +| Restricted APIs | ✅ Pass | No private or deprecated APIs used | +| Content Standards | ✅ Pass | No misleading or inappropriate content | +| Functionality | ✅ Pass | Complete, functional app experience | +| Business Model | ✅ Pass | No IAP conflicts | +| Safety & Privacy | ✅ Pass | Account deletion available, privacy policy linked | +| Onboarding | ✅ Pass | Clear, permission-respectful first-run experience | +| Privacy Manifest | ✅ Pass | Required reason APIs declared | + +--- + +## References + +- [Apple App Store Review Guidelines](https://developer.apple.com/app-store/review/guidelines/) +- [Apple Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/) +- [Apple Privacy Manifest Requirements](https://developer.apple.com/documentation/bundleresources/privacy_manifest_files) +- [App Store Connect Help](https://developer.apple.com/help/app-store-connect/) diff --git a/docs/AuthenticationSetup.md b/docs/AuthenticationSetup.md index c77f48b3..359fe497 100644 --- a/docs/AuthenticationSetup.md +++ b/docs/AuthenticationSetup.md @@ -15,6 +15,7 @@ This guide explains how to configure authentication for DocuElevate to secure yo | `AUTHENTIK_CLIENT_SECRET` | Client secret for OpenID Connect authentication | | `AUTHENTIK_CONFIG_URL` | OpenID Connect discovery URL | | `OAUTH_PROVIDER_NAME` | Display name for the OAuth provider button | +| `SSO_AUTO_LOGIN` | Auto-redirect to SSO login (skips the login page) | For a complete list of configuration options, see the [Configuration Guide](ConfigurationGuide.md). @@ -24,7 +25,12 @@ DocuElevate supports multiple authentication methods that can be used independen 1. **Simple Authentication** - Basic username/password authentication managed by DocuElevate 2. **OpenID Connect** - Integration with identity providers like Authentik, Keycloak, or Auth0 -3. **Social Login** - Sign in with Google, Microsoft, Apple, or Dropbox accounts (see [Social Login Setup Guide](SocialLoginSetup.md)) +3. **Social Login** - Sign in with Google, Microsoft, Apple, Dropbox, or GitHub accounts (see [Social Login Setup Guide](SocialLoginSetup.md)) +4. **Keycloak SSO** - Self-hosted identity management via Keycloak +5. **Generic OAuth2** - Any OAuth2-compatible identity provider +6. **SAML2** - Enterprise SSO via SAML 2.0 + +> **Admin Connections Page:** You can configure all authentication providers through the admin **Connections** page at `/admin/connections`. ## Session Security diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index 4e68e91a..39d906dd 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -11,10 +11,15 @@ Configuration is primarily done through environment variables specified in a `.e | **Variable** | **Description** | **Example** | |------------------------|----------------------------------------------------------|--------------------------------| | `DATABASE_URL` | Path/URL to the SQLite database (or other SQL backend). Use the [Database Wizard](/database-wizard) for guided setup. See [Database Configuration](DatabaseConfiguration.md). | `sqlite:///./app/database.db` | +| `DB_POOL_SIZE` | Number of persistent connections in the pool per worker (PostgreSQL/MySQL only; ignored for SQLite). | `10` | +| `DB_MAX_OVERFLOW` | Additional connections beyond `DB_POOL_SIZE` under burst load (PostgreSQL/MySQL only). | `20` | +| `DB_POOL_TIMEOUT` | Seconds to wait for a pool connection before raising `TimeoutError` (PostgreSQL/MySQL only). | `30` | +| `DB_POOL_RECYCLE` | Recycle connections after this many seconds to avoid stale connections (PostgreSQL/MySQL only). | `1800` | | `REDIS_URL` | URL for Redis, used by Celery for broker & result store. | `redis://redis:6379/0` | | `WORKDIR` | Working directory for the application. | `/workdir` | | `GOTENBERG_URL` | Gotenberg PDF processing URL. | `http://gotenberg:3000` | | `EXTERNAL_HOSTNAME` | The external hostname for the application. | `docuelevate.example.com` | +| `PUBLIC_BASE_URL` | Full public base URL including scheme (e.g., `https://docuelevate.example.com`). When set, overrides auto-detected URLs used for OAuth redirect URIs. **Required when your reverse proxy does not forward `X-Forwarded-Proto` headers.** | *(not set)* | | `ALLOW_FILE_DELETE` | Enable file deletion in the web interface (`true`/`false`). | `true` | | `COMPLIANCE_ENABLED` | Enable the compliance templates dashboard (GDPR, HIPAA, SOC 2). | `true` | | `FACTORY_RESET_ON_STARTUP` | Wipe all user data on every startup (demo/testing). | `false` | @@ -81,6 +86,28 @@ Control how the web UI queues and paces file uploads to avoid overwhelming the b **Example**: With `UPLOAD_CONCURRENCY=3` and `UPLOAD_QUEUE_DELAY_MS=500`, a directory of 5,000 files is uploaded ≈ 3 at a time with 500 ms pacing – the backend processes files at its own rate while the queue drains in the background without triggering API rate limits. +### Per-User Upload Rate Limiting + +Server-side rate limiting that prevents any single user from overwhelming the system with bulk uploads. The limiter uses a Redis-backed sliding window and dynamically adjusts limits based on system health. + +| **Variable** | **Description** | **Default** | +|--------------------------------|------------------------------------------------------------------------------------------------------------------------------|-------------| +| `UPLOAD_RATE_LIMIT_PER_USER` | Maximum uploads allowed per user within the sliding window. Effective limit may be reduced under load. | `20` | +| `UPLOAD_RATE_LIMIT_WINDOW` | Sliding window size in seconds. | `60` | + +**Health-aware dynamic limiting**: The effective per-user limit is automatically reduced when the system is under heavy load: + +| **System condition** | **Effective limit** | **Trigger** | +|--------------------------------|---------------------|--------------------------------| +| Normal | 100 % of base | Queue < 50, CPU load normal | +| Moderate load | 50 % of base | Queue 50–100 or CPU > 1.5× | +| High load | 25 % of base | Queue 100–200 or CPU > 2× | +| Critical load | 10 % of base | Queue > 200 or CPU > 3× | + +When a user exceeds the limit, the server returns **HTTP 429 Too Many Requests** with a `Retry-After` header. The browser client (see *Client-Side Upload Throttling* above) automatically pauses and retries. + +> **Note**: The limiter fails open — if Redis is unavailable, all uploads are allowed through so that a monitoring outage never blocks document processing. + ### File Upload Size Limits **Security Feature**: Control file upload sizes to prevent resource exhaustion attacks. See [SECURITY_AUDIT.md](../SECURITY_AUDIT.md#5-file-upload-size-limits) for security details. @@ -379,7 +406,7 @@ Credentials are encrypted at rest using Fernet encryption. ### Social Login Providers -Social login lets users sign in with their existing Google, Microsoft, Apple, or Dropbox accounts. Each provider is independently enabled and configured. For detailed setup instructions see the [Social Login Setup Guide](SocialLoginSetup.md). +Social login lets users sign in with their existing Google, Microsoft, Apple, Dropbox, or GitHub accounts. Each provider is independently enabled and configured. For detailed setup instructions see the [Social Login Setup Guide](SocialLoginSetup.md). | **Variable** | **Description** | **Default** | |---|---|---| @@ -398,6 +425,33 @@ Social login lets users sign in with their existing Google, Microsoft, Apple, or | `SOCIAL_AUTH_DROPBOX_ENABLED` | Enable Dropbox Sign-In. | `false` | | `SOCIAL_AUTH_DROPBOX_CLIENT_ID` | Dropbox OAuth2 App Key. | *(empty)* | | `SOCIAL_AUTH_DROPBOX_CLIENT_SECRET` | Dropbox OAuth2 App Secret. | *(empty)* | +| `SOCIAL_AUTH_GITHUB_ENABLED` | Enable GitHub Sign-In. | `false` | +| `SOCIAL_AUTH_GITHUB_CLIENT_ID` | GitHub OAuth2 client ID from GitHub Developer Settings. | *(empty)* | +| `SOCIAL_AUTH_GITHUB_CLIENT_SECRET` | GitHub OAuth2 client secret. | *(empty)* | +| `SSO_AUTO_LOGIN` | Automatically redirect to SSO login when authentication is required. | `false` | + +### SSO Providers + +| **Variable** | **Description** | **Default** | +|---|---|---| +| `SOCIAL_AUTH_KEYCLOAK_ENABLED` | Enable Keycloak SSO. | `false` | +| `SOCIAL_AUTH_KEYCLOAK_CLIENT_ID` | Keycloak OAuth2 client ID. | *(empty)* | +| `SOCIAL_AUTH_KEYCLOAK_CLIENT_SECRET` | Keycloak OAuth2 client secret. | *(empty)* | +| `SOCIAL_AUTH_KEYCLOAK_SERVER_URL` | Keycloak server base URL (e.g. `https://keycloak.example.com`). | *(empty)* | +| `SOCIAL_AUTH_KEYCLOAK_REALM` | Keycloak realm name. | *(empty)* | +| `SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED` | Enable a generic OAuth2 SSO provider. | `false` | +| `SOCIAL_AUTH_GENERIC_OAUTH2_CLIENT_ID` | Generic OAuth2 client ID. | *(empty)* | +| `SOCIAL_AUTH_GENERIC_OAUTH2_CLIENT_SECRET` | Generic OAuth2 client secret. | *(empty)* | +| `SOCIAL_AUTH_GENERIC_OAUTH2_AUTHORIZE_URL` | Generic OAuth2 authorization URL. | *(empty)* | +| `SOCIAL_AUTH_GENERIC_OAUTH2_TOKEN_URL` | Generic OAuth2 token endpoint URL. | *(empty)* | +| `SOCIAL_AUTH_GENERIC_OAUTH2_USERINFO_URL` | Generic OAuth2 userinfo endpoint URL. | *(empty)* | +| `SOCIAL_AUTH_GENERIC_OAUTH2_SCOPE` | Space-separated list of OAuth2 scopes. | `openid profile email` | +| `SOCIAL_AUTH_GENERIC_OAUTH2_NAME` | Display name for the provider button. | `OAuth2` | +| `SOCIAL_AUTH_SAML2_ENABLED` | Enable SAML2 SSO authentication. | `false` | +| `SOCIAL_AUTH_SAML2_ENTITY_ID` | SAML2 Identity Provider Entity ID. | *(empty)* | +| `SOCIAL_AUTH_SAML2_SSO_URL` | SAML2 Identity Provider SSO URL. | *(empty)* | +| `SOCIAL_AUTH_SAML2_CERTIFICATE` | SAML2 Identity Provider X.509 certificate (PEM format). | *(empty)* | +| `SOCIAL_AUTH_SAML2_NAME` | Display name for the SAML2 provider. | `SAML2` | ### Multi-User Mode @@ -738,7 +792,7 @@ SECURITY_HEADER_CSP_VALUE="default-src 'self'; script-src 'self'; style-src 'sel SECURITY_HEADER_CSP_VALUE="default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline';" ``` -**Note:** The default policy includes `'unsafe-inline'` for compatibility with Tailwind CSS and inline JavaScript. For stricter security, use nonces or hashes. +**Note:** The default policy includes `'unsafe-inline'` for compatibility with inline JavaScript. Tailwind CSS v3 is compiled at build time into a static file served from `'self'`, so no external style CDN is needed. #### X-Frame-Options @@ -1348,6 +1402,9 @@ For detailed setup instructions, see the [Amazon S3 Setup Guide](AmazonS3Setup.m | `NOTIFY_ON_USER_SIGNUP` | Send admin notification when a new user signs up (`True`/`False`, default `True`) | | `NOTIFY_ON_PLAN_CHANGE` | Send admin notification when a user changes their subscription plan (`True`/`False`, default `True`) | | `NOTIFY_ON_PAYMENT_ISSUE` | Send admin notification when a payment issue is reported for a user (`True`/`False`, default `True`) | +| `TELEGRAM_ENABLED` | Enable Telegram bot notifications. | `false` | +| `TELEGRAM_BOT_TOKEN` | Telegram Bot API token from @BotFather. | *(empty)* | +| `TELEGRAM_CHAT_ID` | Telegram chat ID to send notifications to. | *(empty)* | #### User-Event Notifications @@ -1427,6 +1484,26 @@ Configurations are stored in the database and managed through the API (see [API Webhook URLs, secrets, and subscribed events are configured per-webhook via the `/api/webhooks/` endpoints (admin access required). Each delivery includes an optional HMAC-SHA256 signature for verification and is retried with exponential backoff on failure. +### Automation Hooks (Zapier / Make.com) + +Automation hooks enable integration with external automation platforms such as +[Zapier](https://zapier.com) and [Make.com](https://make.com) (formerly Integromat). + +| **Variable** | **Description** | **Default** | +|----------------------------|------------------------------------------------------------------------------------------------|-------------| +| `AUTOMATION_HOOKS_ENABLED` | Enable or disable Zapier / Make.com automation hook subscriptions and delivery (`True`/`False`) | `True` | + +When enabled, external platforms can: + +- **Subscribe** to DocuElevate events via `POST /api/automation/hooks/subscribe` (outgoing triggers) +- **Send documents** to DocuElevate via `POST /api/automation/actions/upload` (incoming actions) +- **Discover fields** via `GET /api/automation/triggers/sample/{event}` (Zapier field mapping) + +Automation hooks share the same event types as webhooks (`document.uploaded`, `document.processed`, +`document.failed`, `user.signup`, `user.plan_changed`, `user.payment_issue`) and use a flat +Zapier-compatible JSON payload format. See the [API docs](API.md#automation-zapier--makecom) for +endpoint details and payload examples. + ### Backup & Restore DocuElevate automatically backs up the database on a scheduled basis. @@ -1528,6 +1605,8 @@ No additional configuration is required — the auto-fill uses the authenticated DocuElevate integrates with [Sentry](https://sentry.io) for real-time error tracking and performance monitoring. See [SentrySetup.md](./SentrySetup.md) for a full setup guide. +### Server-side (Python SDK) + | Variable | Description | Default | |---|---|---| | `SENTRY_DSN` | Sentry DSN URL. When set, error reporting and performance tracing are enabled automatically. Leave blank to disable. | *(unset)* | @@ -1536,18 +1615,33 @@ DocuElevate integrates with [Sentry](https://sentry.io) for real-time error trac | `SENTRY_PROFILES_SAMPLE_RATE` | Fraction of profiled transactions sent to Sentry (0.0 – 1.0). Only active when traces > 0. | `0.0` | | `SENTRY_SEND_DEFAULT_PII` | Attach PII (IP addresses, user agents) to Sentry events. Disabled by default for GDPR/CCPA compliance. | `false` | +### Browser SDK (JavaScript) + +The Sentry Browser SDK is loaded automatically on every rendered page when `SENTRY_DSN` is set. The same DSN is used for both server and browser — the DSN is a *public* key in Sentry's security model and is intentionally embedded in client-side code. + +| Variable | Description | Default | +|---|---|---| +| `SENTRY_JS_TRACES_SAMPLE_RATE` | Fraction of browser page-loads captured for client-side performance tracing (0.0 – 1.0). | `0.0` | +| `SENTRY_JS_REPLAY_SESSION_SAMPLE_RATE` | Fraction of sessions recorded by [Sentry Session Replay](https://docs.sentry.io/product/session-replay/) (0.0 – 1.0). | `0.0` | +| `SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE` | Fraction of error sessions captured with session replay context (0.0 – 1.0). | `0.1` | + ```bash -# Minimal example +# Minimal example (server + browser) SENTRY_DSN=https://@o.ingest.sentry.io/ SENTRY_ENVIRONMENT=production -# Optional tuning +# Optional server-side tuning SENTRY_TRACES_SAMPLE_RATE=0.1 SENTRY_PROFILES_SAMPLE_RATE=0.0 SENTRY_SEND_DEFAULT_PII=false + +# Optional browser-side tuning +SENTRY_JS_TRACES_SAMPLE_RATE=0.1 +SENTRY_JS_REPLAY_SESSION_SAMPLE_RATE=0.0 +SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE=0.1 ``` -> **Note:** Sentry is completely opt-in — if `SENTRY_DSN` is not set, the SDK is never initialised and no data leaves your infrastructure. +> **Note:** Sentry is completely opt-in — if `SENTRY_DSN` is not set, neither SDK is initialised and no data leaves your infrastructure. ## Duplicate Document Detection @@ -1555,14 +1649,30 @@ DocuElevate detects and flags documents that share the same content, even if the ### Exact Duplicate Detection (SHA-256) -When `ENABLE_DEDUPLICATION=True` (the default), each new document is hashed with SHA-256 before processing begins. If the hash matches an existing file record the new document is stored as a duplicate (`is_duplicate=True`, `duplicate_of_id=`) and no further processing is performed. +When `ENABLE_DEDUPLICATION=True` (the default), each new document is hashed with SHA-256 before processing begins. If the hash matches an existing file record the upload is rejected immediately — no processing task is created, and the temporary file is removed from disk. The `/api/ui-upload` response returns `"status": "duplicate"` together with a `duplicate_of` object that identifies the original file. + +If the same file somehow reaches the Celery worker (e.g. via a watch-folder ingest) it is still caught there and stored as a duplicate (`is_duplicate=True`, `duplicate_of_id=`) with no further processing. | Variable | Description | Default | |---|---|---| | `ENABLE_DEDUPLICATION` | Hash-based exact duplicate detection on ingest. | `True` | | `SHOW_DEDUPLICATION_STEP` | Show the "Check for Duplicates" step in the processing timeline UI. | `True` | -An immediate duplicate warning is also included in the `/api/ui-upload` JSON response so the frontend can alert the user before the pipeline completes. +When the upload is an exact duplicate the `/api/ui-upload` response looks like: + +```json +{ + "status": "duplicate", + "original_filename": "invoice.pdf", + "stored_filename": "abc-123.pdf", + "duplicate_of": { + "duplicate_type": "exact", + "original_file_id": 42, + "original_filename": "invoice.pdf", + "message": "This file is an exact duplicate of an already-processed document. It has not been queued for processing again." + } +} +``` ### Near-Duplicate Detection (Content Similarity) diff --git a/docs/DatabaseConfiguration.md b/docs/DatabaseConfiguration.md index 4930820f..06fd2916 100644 --- a/docs/DatabaseConfiguration.md +++ b/docs/DatabaseConfiguration.md @@ -337,18 +337,26 @@ The Helm chart includes a pre-install and pre-upgrade Job hook that runs `alembi ## Connection Pooling -SQLAlchemy manages a connection pool automatically. The defaults are suitable for most deployments. For high-concurrency or Kubernetes deployments you may want to tune: +SQLAlchemy manages a connection pool automatically. DocuElevate selects the pool +strategy based on the database backend: + +- **SQLite** — uses `NullPool` (a fresh connection per request, closed immediately). + This avoids the `QueuePool limit reached` `TimeoutError` that can occur under + concurrent load because SQLite does not benefit from persistent connection pooling. +- **PostgreSQL / MySQL** — uses a bounded `QueuePool` whose size is configurable + via environment variables. ```bash -# Optional — these are set via environment variables if you extend app/database.py -# Typical production values: -DB_POOL_SIZE=10 # Number of persistent connections per worker -DB_MAX_OVERFLOW=20 # Additional connections allowed beyond pool_size -DB_POOL_TIMEOUT=30 # Seconds to wait for a connection from the pool -DB_POOL_RECYCLE=1800 # Recycle connections after 30 minutes (avoids stale connections) +# Tune these for PostgreSQL / MySQL (ignored when using SQLite): +DB_POOL_SIZE=10 # Number of persistent connections per worker (default: 10) +DB_MAX_OVERFLOW=20 # Additional connections allowed beyond pool_size (default: 20) +DB_POOL_TIMEOUT=30 # Seconds to wait for a connection from the pool (default: 30) +DB_POOL_RECYCLE=1800 # Recycle connections after 30 minutes (default: 1800) ``` -> **Note:** These environment variables are not exposed in the default `app/config.py`. If you need to tune them, extend the database engine creation in `app/database.py`. +All backends also enable `pool_pre_ping`, which sends a lightweight health-check +before each connection is handed out. This detects stale or dropped connections +and transparently reconnects. For **PgBouncer** (external connection pooling), point `DATABASE_URL` at your PgBouncer instance and use transaction-mode pooling: @@ -512,4 +520,13 @@ Then retry `alembic upgrade head`. Either increase `max_connections` in `postgresql.conf` or add PgBouncer in front of PostgreSQL. The default PostgreSQL `max_connections` is `100`; reduce `DB_POOL_SIZE` per worker to stay within this limit. +### "QueuePool limit reached" TimeoutError (SQLite) + +If you see `TimeoutError: QueuePool limit of size 5 overflow 10 reached`, your +deployment is still running an older version of DocuElevate that used a bounded +connection pool for SQLite. Upgrade to the latest release — SQLite now uses +`NullPool`, which eliminates this error entirely. If you are already on the +latest version and are still seeing pool exhaustion, ensure you are not +overriding the engine creation manually. + For more help, see the [Troubleshooting Guide](Troubleshooting.md). diff --git a/docs/DeploymentGuide.md b/docs/DeploymentGuide.md index 94dc634f..43345465 100644 --- a/docs/DeploymentGuide.md +++ b/docs/DeploymentGuide.md @@ -349,16 +349,18 @@ workdir: ## Scaling +DocuElevate is designed for horizontal scaling. Both API and worker pods are stateless and can be scaled independently. + ### Docker Compose -Add more worker containers: +Scale workers (task processing) and API pods (request handling) independently: -```yaml -worker: - deploy: - replicas: 3 +```bash +docker compose up -d --scale worker=3 --scale api=2 ``` +> **Note:** The `beat` service (Celery Beat scheduler) must always run as exactly **one** instance. Do not scale it. It publishes periodic tasks to the Redis broker; workers pick them up. + ### Kubernetes / Helm Enable HPA: @@ -377,13 +379,15 @@ worker: maxReplicas: 10 ``` +The Helm chart deploys a separate **beat** pod (always 1 replica, `Recreate` strategy) so that scheduled tasks are never duplicated when workers scale. + --- ## Monitoring - **Docker Compose**: `docker-compose logs -f`, `docker stats` - **Kubernetes**: `kubectl logs -l app.kubernetes.io/component=api -f` -- **Prometheus / Grafana**: Scrape the `/api/health` endpoint for readiness; add custom metrics as needed. +- **Prometheus / Grafana**: Scrape the `/api/diagnostic/healthz/ready` endpoint for readiness; add custom metrics as needed. - **Uptime Kuma**: Set `UPTIME_KUMA_URL` to your push URL for heartbeat monitoring. --- diff --git a/docs/DropboxSetup.md b/docs/DropboxSetup.md index 8923a595..39989735 100644 --- a/docs/DropboxSetup.md +++ b/docs/DropboxSetup.md @@ -28,9 +28,10 @@ End users authorize their own Dropbox integration from the **Integrations** dash 1. Navigate to `/integrations` and click **+ Add Destination** (or **+ Add Source** for Watch Folder). 2. Create a Dropbox destination integration (or a Watch Folder with `source_type = dropbox`). 3. Click the **Authorize** button next to the integration — it links directly to the OAuth wizard pre-loaded with your integration's configuration. -4. Enter your Dropbox App Key and App Secret in the wizard (or use the global admin credentials if pre-configured). +4. If the administrator has configured system-wide Dropbox app credentials (`DROPBOX_APP_KEY` / `DROPBOX_APP_SECRET`), the wizard defaults to using them — no need to register your own Dropbox app. Uncheck the toggle to use custom credentials if needed. 5. Click **Start Authentication Flow**, authorize access in Dropbox, and the refresh token is automatically saved to your personal integration record. -6. The page redirects back to `/integrations` on success. Re-authorization is available at any time via the **Re-Authorize** button. +6. After authorization, an interactive **folder browser** lets you select the target folder directly from your Dropbox — no need to manually type folder paths. +7. The page redirects to `/integrations` on success. Re-authorization is available at any time via the **Re-Authorize** button. > **Note:** Your credentials are stored encrypted per-integration and are never mixed with other users' data. Each user can have multiple Dropbox integrations with independent tokens. @@ -128,7 +129,31 @@ If you encounter issues with Dropbox integration: 1. **Authentication Errors**: Make sure your App Key and App Secret are correct 2. **Token Expired**: Click "Refresh Token" button on the setup page to obtain a new token 3. **Folder Permissions**: Ensure your app has the correct permissions enabled for file operations -4. **Invalid Redirect URI**: Verify that the redirect URI in your app settings matches the one used in the authentication flow +4. **Invalid Redirect URI**: See section below for the most common cause and fix. 5. **Rate Limiting**: Dropbox API has rate limits; if exceeded, wait and try again +### Fixing "Invalid redirect_uri" Error + +This error appears on the Dropbox authorization page when the redirect URI in the OAuth request does not match any URI registered in your Dropbox app console. + +**Most common cause**: The application is deployed behind a reverse proxy (Traefik, Nginx, Caddy) that does **not** forward the `X-Forwarded-Proto: https` header to DocuElevate. Without this header, the server cannot determine that it is being accessed over HTTPS and may construct an `http://` redirect URI, while the registered URI in Dropbox is `https://`. + +**Fix**: + +Option 1 – Configure your proxy to forward `X-Forwarded-Proto`: + +```nginx +proxy_set_header X-Forwarded-Proto $scheme; +``` + +Option 2 – Set `PUBLIC_BASE_URL` in your environment (recommended for most deployments): + +```bash +PUBLIC_BASE_URL=https://docuelevate.example.com +``` + +When `PUBLIC_BASE_URL` is set, DocuElevate uses it directly for all OAuth redirect URIs instead of trying to infer the scheme from request headers. This is the most reliable option. + +After setting `PUBLIC_BASE_URL`, ensure the Dropbox app console redirect URI matches exactly (e.g., `https://docuelevate.example.com/dropbox-callback`). The setup wizard at `/dropbox-setup` will show you the exact URI to register. + For more general configuration issues, see the [Configuration Troubleshooting Guide](ConfigurationTroubleshooting.md). diff --git a/docs/GoogleDriveSetup.md b/docs/GoogleDriveSetup.md index e85addcc..bd0d59d5 100644 --- a/docs/GoogleDriveSetup.md +++ b/docs/GoogleDriveSetup.md @@ -30,7 +30,7 @@ End users can authorize their own Google Drive integration directly from the **I 1. Navigate to `/integrations` and click **+ Add Destination** (or **+ Add Source** for Watch Folder). 2. Create a Google Drive destination integration (or a Watch Folder with `source_type = google_drive`). 3. Click the **Authorize** button — it opens the OAuth wizard pre-loaded with your integration's configuration. -4. Enter your Google OAuth Client ID and Client Secret in the wizard. +4. If the administrator has configured system-wide Google Drive app credentials (`GOOGLE_DRIVE_CLIENT_ID` / `GOOGLE_DRIVE_CLIENT_SECRET`), the wizard defaults to using them — no need to register your own Google Cloud app. Uncheck the toggle to use custom credentials if needed. 5. Click **Start Authentication Flow** and authorize access in Google. 6. Credentials are saved automatically to your personal integration record; the page redirects back to `/integrations`. 7. Re-authorization is available at any time via the **Re-Authorize** button. diff --git a/docs/KubernetesDeployment.md b/docs/KubernetesDeployment.md index 94247873..c23ddc81 100644 --- a/docs/KubernetesDeployment.md +++ b/docs/KubernetesDeployment.md @@ -373,6 +373,8 @@ worker: replicaCount: 4 ``` +> **Beat scheduler:** The Helm chart deploys a dedicated `beat` pod (always exactly 1 replica with `Recreate` strategy) that publishes periodic tasks to the Redis broker. Workers consume these tasks — scaling workers does **not** duplicate scheduled jobs. + ### Horizontal Pod Autoscaler ```yaml @@ -433,24 +435,30 @@ externalRedis: ### Kubernetes Probes -The Helm chart configures liveness and readiness probes on the API pods via `/api/health`. Default settings: +The Helm chart configures **unauthenticated** liveness and readiness probes on the API pods so kubelet can reach them without credentials. Default settings: ```yaml api: livenessProbe: httpGet: - path: /api/health + path: /api/diagnostic/healthz/live port: 8000 initialDelaySeconds: 30 - periodSeconds: 30 + periodSeconds: 20 readinessProbe: httpGet: - path: /api/health + path: /api/diagnostic/healthz/ready port: 8000 - initialDelaySeconds: 10 + initialDelaySeconds: 15 periodSeconds: 10 ``` +| Endpoint | Auth | Purpose | +|----------|------|---------| +| `/api/diagnostic/healthz/live` | None | Lightweight liveness check — returns 200 if the process is running | +| `/api/diagnostic/healthz/ready` | None | Readiness check — verifies database and Redis connectivity (503 when DB is down) | +| `/api/diagnostic/health` | Required | Full health status for monitoring dashboards (Grafana, Uptime Kuma) | + ### Prometheus Scraping Add annotations to expose metrics (if using a Prometheus-compatible exporter): diff --git a/docs/MobileApp.md b/docs/MobileApp.md index 8b8e6f79..07ff077b 100644 --- a/docs/MobileApp.md +++ b/docs/MobileApp.md @@ -12,9 +12,14 @@ DocuElevate includes a native mobile application for iOS and Android built with | Auto-generated API token | ✅ | ✅ | | Camera capture → upload | ✅ | ✅ | | File picker upload | ✅ | ✅ | +| Multi-image selection from library | ✅ | ✅ | | Share Sheet / Share Intent | ✅ | ✅ | | Push notifications | ✅ | ✅ | -| Document list | ✅ | ✅ | +| Document list with search | ✅ | ✅ | +| File detail view with processing logs | ✅ | ✅ | +| Pre-login legal pages (GDPR) | ✅ | ✅ | +| Localization (EN, DE, ES, FR, IT) | ✅ | ✅ | +| Language selection | ✅ | ✅ | | Dark mode | ✅ | ✅ | ## Getting Started (Development) @@ -171,8 +176,8 @@ curl -X DELETE -H "Authorization: Bearer " https://your-server/api/mobile 1. Open the **Upload** tab. 2. Tap **Photos**. -3. Select an existing photo from the device's photo library. -4. The image is uploaded and queued for processing. +3. Select one or more photos from the device's photo library (multi-selection is supported). +4. All selected images are uploaded and queued for processing. ### File Picker @@ -198,6 +203,32 @@ The app registers itself as a share target so any file can be sent directly to D The URL may arrive as a standard `file://` path **or** under the app's custom `docuelevate://` scheme (e.g. `docuelevate://private/var/mobile/Library/…/file.pdf`). The root layout detects the custom-scheme form and rewrites it to a `file://` URL before forwarding it to the Upload screen through `ShareContext`. +##### Handling "unmatched route" errors from "Open In…" + +iOS sometimes delivers the file path under the `docuelevate://` scheme, e.g.: + +``` +docuelevate://private/var/mobile/Library/Mobile Documents/…/Invoice.pdf +``` + +expo-router strips the scheme and tries to match `/private/var/mobile/…` as an in-app route. Because no such route exists, it previously threw an **"unmatched route docuelevate://"** error and the upload never completed. + +The fix is a catch-all `+not-found.tsx` route (see `mobile/app/+not-found.tsx`). When expo-router cannot match the path, it renders this screen instead. The screen detects that the path is a filesystem path rather than a real in-app route, adds the file directly to `ShareContext`, and redirects to the Upload tab. `UploadScreen` picks up the pending file and begins uploading automatically. The `Linking` listener in the root layout may also fire for the same URL; `ShareContext.addPendingFile` deduplicates by URI so the file is only uploaded once. + +##### File accessibility and local caching + +Shared files may reference paths outside the app's sandbox or use security-scoped URLs that React Native's `fetch` cannot read directly. To guarantee reliable uploads: + +- **`LSSupportsOpeningDocumentsInPlace`** is set to `false` in `app.json`, which tells iOS to copy shared files into the app's `Documents/Inbox` directory before handing them to the app. +- **`UploadScreen`** uses `expo-file-system` (`FileSystem.copyAsync`) to copy any `file://` URI that is outside the app's cache/documents directory to a local cache path before uploading. This ensures the file is readable regardless of its origin. +- **MIME type inference**: Both `+not-found.tsx` and the `Linking` handler in `_layout.tsx` infer the MIME type from the file extension (e.g. `.pdf` → `application/pdf`) so the server receives a correct `Content-Type` instead of `application/octet-stream`. + +##### iOS Action / Share Extension (future enhancement) + +Apps like DeepL ("Translate in DeepL") and Microsoft Word ("Convert to Word") appear as **Action Extensions** in the iOS share sheet — a system-level feature that requires a separate Xcode target built with Swift or Objective-C. A proper Action Extension runs in its own process and must share authentication credentials with the main app via an iOS **App Group** (shared keychain / shared container). + +This level of iOS-native integration is a planned future enhancement. Until it is available, the recommended workflow is the current one: tap **Share → DocuElevate** (the app appears in the "Open With" row of the share sheet via `CFBundleDocumentTypes`). + #### Android implementation `app.json` declares `ACTION_SEND` and `ACTION_SEND_MULTIPLE` intent filters for `mimeType: "*/*"` in the `android.intentFilters` section. Incoming content URIs are received the same way as on iOS. @@ -215,6 +246,75 @@ If a file upload fails (e.g. due to network issues or a server error), the faile The retry re-uses the original file URI so no re-selection is needed. +## Document Search + +The **Files** tab includes a search bar at the top that lets users search through their processed documents by filename. Searches are debounced (400ms) to avoid excessive API calls. Clear the search with the ✕ button to return to the full list. + +## File Detail View + +Tapping any document in the **Files** tab opens a detail view showing: + +- **File metadata**: filename, file size, MIME type, upload date, and file hash +- **Processing status**: current status with a colour-coded icon +- **Processing log**: chronological list of processing steps with individual status indicators and timestamps + +Pull-to-refresh updates the detail view. This replicates the web interface at `/files/{id}` and `/files/{id}/detail` in a mobile-friendly layout. + +## Legal & Compliance + +### GDPR & Apple App Store Compliance + +Privacy Policy, Terms of Service, and Imprint links are accessible **before login** from both the **Welcome Screen** and the **Login Screen**. This ensures compliance with: + +- **GDPR** (General Data Protection Regulation) – users must be able to review the privacy policy before providing personal data +- **Apple App Store Review Guidelines** – apps must provide accessible privacy information before account creation + +Post-login, the same links are available in the **Profile** tab under the "Legal" section. + +## Localization (i18n) + +The mobile app supports five languages with automatic device-locale detection: + +| Language | Code | Status | +|----------|------|--------| +| English | `en` | ✅ Complete | +| German (Deutsch) | `de` | ✅ Complete | +| Spanish (Español) | `es` | ✅ Complete | +| French (Français) | `fr` | ✅ Complete | +| Italian (Italiano) | `it` | ✅ Complete | + +### How it works + +Language priority (highest to lowest): + +1. **Server preference** — `preferred_language` returned by `GET /api/mobile/whoami` on login or app resume. Allows a language set on the desktop web interface to propagate to mobile automatically. +2. **AsyncStorage** — the last language explicitly selected on the device, used as an offline fallback when the server is unreachable. +3. **Device locale** — detected via `expo-localization` on first launch. +4. **English** — final fallback when none of the above match a supported locale. + +When a user selects a language on mobile the choice is: +- Applied immediately to all screens (via `LocaleContext`) +- Persisted locally to AsyncStorage +- Synced to the server via `POST /api/i18n/language` (fire-and-forget), so the next desktop login reflects the same preference. + +> **Note**: If the server's preferred language is not supported by the mobile app (e.g. a locale added to the web frontend but not yet translated for mobile), the mobile app falls back to the next priority in the list above. + +### Adding a new language + +1. Create a new translation file in `mobile/src/i18n/` (e.g. `pt.json` for Portuguese) +2. Copy the structure from `en.json` and translate all values +3. Import the new file in `mobile/src/i18n/index.ts` +4. Add it to the `translations` object and `getSupportedLanguages()` array + +## User Settings + +The **Profile** tab includes a **Settings** section where users can: + +- **Change language**: Select from the supported languages (English, German, Spanish, French, Italian) +- View server connection details +- Access legal documents (Privacy Policy, Terms of Service, Imprint) +- Sign out or delete their account + ## Mobile API Endpoints The backend exposes a dedicated `/api/mobile/` namespace: @@ -225,7 +325,8 @@ The backend exposes a dedicated `/api/mobile/` namespace: | `POST` | `/api/mobile/register-device` | Bearer | Register Expo push token | | `GET` | `/api/mobile/devices` | Bearer | List registered devices | | `DELETE` | `/api/mobile/devices/{id}` | Bearer | Deactivate a device | -| `GET` | `/api/mobile/whoami` | Bearer | Get current user profile | +| `GET` | `/api/mobile/whoami` | Bearer | Get current user profile (includes `preferred_language`) | +| `POST` | `/api/i18n/language` | Bearer | Sync language preference to server | All other API endpoints (file upload, file listing, etc.) work with Bearer token authentication. @@ -269,7 +370,7 @@ Re-registering the same token is safe (idempotent). ### GET /api/mobile/whoami -Returns the current user's profile. +Returns the current user's profile, including the server-stored language preference. **Response (200):** ```json @@ -278,10 +379,15 @@ Returns the current user's profile. "display_name": "John Doe", "email": "john@example.com", "avatar_url": "https://www.gravatar.com/avatar/...", - "is_admin": false + "is_admin": false, + "preferred_language": "de" } ``` +`preferred_language` is `null` when no preference has been saved. The mobile +app applies this value on login / app resume, falling back to AsyncStorage and +then the device locale when it is `null` or unsupported. + ## Configuration No server-side configuration is required to enable the mobile app. The Expo push notification routing does not need FCM or APNs credentials on the server. @@ -320,8 +426,20 @@ mobile/ │ ├── LoginScreen.tsx # Server URL + SSO button + QR code scanner │ ├── QRScannerScreen.tsx # Camera-based QR code scanner for login │ ├── UploadScreen.tsx # Camera capture + photo library + file picker - │ ├── FilesScreen.tsx # Processed document list - │ └── ProfileScreen.tsx # User profile + sign out + │ ├── FilesScreen.tsx # Processed document list with search + │ ├── FileDetailScreen.tsx # File detail view with processing logs + │ ├── ProfileScreen.tsx # User profile + settings + sign out + │ └── WelcomeScreen.tsx # Pre-login welcome with legal links + ├── i18n/ # Localization (i18n) + │ ├── index.ts # i18n module (locale detection, t() function) + │ ├── en.json # English translations + │ ├── de.json # German translations + │ ├── es.json # Spanish translations + │ ├── fr.json # French translations + │ └── it.json # Italian translations + ├── utils/ + │ ├── mimeTypes.ts # MIME type mapping for file extensions + │ └── normalizeUri.ts # URI normalization for deduplication └── services/ └── api.ts # DocuElevate REST API client ``` @@ -408,3 +526,4 @@ eas build --platform ios - [API Documentation](./API.md) - [Configuration Guide](./ConfigurationGuide.md) - [Deployment Guide](./DeploymentGuide.md) +- [Apple App Store Compliance Audit](./AppleAppStoreCompliance.md) diff --git a/docs/OneDriveSetup.md b/docs/OneDriveSetup.md index e9a555f4..ba2fc873 100644 --- a/docs/OneDriveSetup.md +++ b/docs/OneDriveSetup.md @@ -29,9 +29,10 @@ End users authorize their own OneDrive integration from the **Integrations** das 1. Navigate to `/integrations` and click **+ Add Destination** (or **+ Add Source** for Watch Folder). 2. Create a OneDrive destination integration (or a Watch Folder with `source_type = onedrive`). 3. Click the **Authorize** button next to the integration — it links directly to the OAuth wizard pre-loaded with your integration's configuration. -4. Enter your Azure AD Client ID and Client Secret in the wizard. +4. If the administrator has configured system-wide OneDrive app credentials (`ONEDRIVE_CLIENT_ID` / `ONEDRIVE_CLIENT_SECRET`), the wizard defaults to using them — no need to register your own Azure AD app. Uncheck the toggle to use custom credentials if needed. 5. Click **Start Authentication Flow**, authorize access via Microsoft, and the refresh token is automatically saved to your personal integration record. -6. The page redirects back to `/integrations` on success. Re-authorization is available at any time via the **Re-Authorize** button. +6. After authorization, an interactive **folder browser** lets you select the target folder directly from your OneDrive — no need to manually type folder paths. +7. The page redirects to `/integrations` on success. Re-authorization is available at any time via the **Re-Authorize** button. > **Note:** Your credentials are stored encrypted per-integration and are never mixed with other users' data. Each user can have multiple OneDrive integrations with independent tokens. diff --git a/docs/ProductionReadiness.md b/docs/ProductionReadiness.md index 2f93c7c5..e900e76c 100644 --- a/docs/ProductionReadiness.md +++ b/docs/ProductionReadiness.md @@ -34,7 +34,7 @@ Use this checklist to track readiness before going live. - [ ] **Redis** — Running and accessible only from internal network - [ ] **Meilisearch** — Running and accessible only from internal network - [ ] **Worker replicas** — At least 2 workers configured for redundancy -- [ ] **Monitoring** — `/api/health` polled by uptime checker +- [ ] **Monitoring** — `/api/diagnostic/health` polled by uptime checker - [ ] **Backups** — Automated backup of database, workdir, and Meilisearch data - [ ] **Log retention** — Logs shipped to a persistent store or aggregator - [ ] **Secrets management** — API keys not committed to source control @@ -157,7 +157,7 @@ Recommended headers to configure at the proxy level: #### Content-Security-Policy Notes -DocuElevate's frontend uses Tailwind CSS loaded from CDN in development mode. In production, ensure your CSP allows loading scripts and styles from your configured static file origin. A starting point: +DocuElevate's frontend uses Tailwind CSS v3 compiled at Docker build time. No external CDN requests are needed for CSS. In production, your CSP does not need to allow any external style sources beyond your own static file origin. A starting point: ``` Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; @@ -285,22 +285,24 @@ For SSO/OIDC (Authentik, Keycloak, Auth0, etc.) see the [Authentication Setup Gu ### Docker Compose -Use the `deploy.replicas` setting (requires Docker Swarm mode) or simply run multiple workers: - -```yaml -worker: - deploy: - replicas: 3 -``` - -Or scale after deployment: +Scale workers independently: ```bash -docker-compose up -d --scale worker=3 +docker compose up -d --scale worker=3 ``` Each worker processes tasks from the Celery queue independently. Ensure the shared `workdir` volume is accessible from all worker containers. +> **Important:** The `beat` service (Celery Beat scheduler) must always run as exactly **one** instance. It is defined as a dedicated service in `docker-compose.yaml` with a fixed `container_name`. Do not scale it. + +### Scaling the API + +API pods are fully stateless (sessions use encrypted cookies, not server-side state) and can be scaled behind a load balancer: + +```bash +docker compose up -d --scale api=3 +``` + ### Kubernetes (Helm) ```yaml @@ -339,11 +341,32 @@ celery -A app.celery_worker worker -Q default,celery --concurrency=2 ### Health Check Endpoint -DocuElevate exposes `/api/health` for readiness probing. Configure your uptime monitor to poll this endpoint: +DocuElevate exposes three health-related endpoints: + +| Endpoint | Auth | Purpose | +|----------|------|---------| +| `GET /api/diagnostic/healthz/live` | None | Lightweight liveness probe — returns 200 if the process is running | +| `GET /api/diagnostic/healthz/ready` | None | Readiness probe — checks database and Redis (503 when DB is down) | +| `GET /api/diagnostic/health` | Required | Full status for monitoring dashboards (Grafana, Uptime Kuma) | + +For **Kubernetes probes**, use the unauthenticated endpoints: + +```yaml +livenessProbe: + httpGet: + path: /api/diagnostic/healthz/live + port: 8000 +readinessProbe: + httpGet: + path: /api/diagnostic/healthz/ready + port: 8000 +``` + +For **uptime monitors** (Uptime Kuma, Grafana, etc.), use the authenticated endpoint: ```bash -curl http://docuelevate.example.com/api/health -# Expected: {"status": "ok", ...} +curl http://docuelevate.example.com/api/diagnostic/health +# Expected: {"status": "healthy", ...} ``` Set `UPTIME_KUMA_URL` to your Uptime Kuma push URL for heartbeat monitoring: @@ -502,4 +525,4 @@ For a dedicated Kubernetes deployment guide, including architecture diagrams, PV - **Image Pull Policy**: Use `IfNotPresent` in production with pinned image tags (not `latest`) for reproducible deployments. -- **Liveness & Readiness Probes**: Already configured in the Helm chart via `/api/health`. Verify they are tuned to your startup time. +- **Liveness & Readiness Probes**: Already configured in the Helm chart via unauthenticated endpoints (`/api/diagnostic/healthz/live` and `/api/diagnostic/healthz/ready`). Verify they are tuned to your startup time. diff --git a/docs/SentrySetup.md b/docs/SentrySetup.md index 357345ac..b6215f07 100644 --- a/docs/SentrySetup.md +++ b/docs/SentrySetup.md @@ -2,13 +2,13 @@ DocuElevate ships with first-class support for [Sentry](https://sentry.io) — an open-source observability platform that provides real-time error tracking and performance monitoring. -When a **Sentry DSN** is configured, every unhandled exception in the FastAPI web process and Celery worker is automatically captured and sent to your Sentry project. Performance transactions (request traces, database queries, background task durations) are also recorded, giving you end-to-end visibility into your deployment. +When a **Sentry DSN** is configured, every unhandled exception in the FastAPI web process and Celery worker is automatically captured and sent to your Sentry project. The **Sentry Browser SDK** is also injected into every rendered page, capturing client-side JavaScript errors, browser performance transactions, and (optionally) session replays. Together these give you full-stack, end-to-end visibility into your deployment. --- ## Quick Start -1. **Create a Sentry project** at (or your self-hosted Sentry instance). Choose the **Python** platform. +1. **Create a Sentry project** at (or your self-hosted Sentry instance). Choose the **Python** platform (the same project and DSN are used for both the server and browser SDKs). 2. Copy the **DSN** from *Project → Settings → Client Keys (DSN)*. It looks like: ``` https://@o.ingest.sentry.io/ @@ -17,12 +17,14 @@ When a **Sentry DSN** is configured, every unhandled exception in the FastAPI we ```bash SENTRY_DSN=https://@o.ingest.sentry.io/ ``` -4. Restart DocuElevate. Sentry initialises automatically on startup — you will see a log line confirming activation. +4. Restart DocuElevate. Sentry initialises automatically on startup — you will see a log line confirming activation. The Sentry Browser SDK ` +{% endblock %} diff --git a/frontend/templates/api_tokens.html b/frontend/templates/api_tokens.html index 5e4633cf..b4facee8 100644 --- a/frontend/templates/api_tokens.html +++ b/frontend/templates/api_tokens.html @@ -33,6 +33,20 @@ aria-required="true" /> +
+ + +
+ + + + @@ -209,9 +258,10 @@ function apiTokens() { tokens: [], loading: true, creating: false, - revoking: null, + acting: null, error: null, newTokenName: '', + newTokenExpiresDays: null, newlyCreatedToken: null, copied: false, baseUrl: window.location.origin, @@ -238,13 +288,15 @@ function apiTokens() { this.error = null; this.newlyCreatedToken = null; try { + const body = { name: this.newTokenName.trim() }; + if (this.newTokenExpiresDays) body.expires_in_days = parseInt(this.newTokenExpiresDays); const res = await fetch('/api/api-tokens/', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken, }, - body: JSON.stringify({ name: this.newTokenName.trim() }), + body: JSON.stringify(body), }); if (!res.ok) { const data = await res.json().catch(() => ({})); @@ -253,6 +305,7 @@ function apiTokens() { const data = await res.json(); this.newlyCreatedToken = data.token; this.newTokenName = ''; + this.newTokenExpiresDays = null; await this.loadTokens(); } catch (e) { this.error = e.message; @@ -263,7 +316,7 @@ function apiTokens() { async revokeToken(token) { if (!confirm(`Revoke token "${token.name}"? This cannot be undone.`)) return; - this.revoking = token.id; + this.acting = token.id; this.error = null; try { const res = await fetch(`/api/api-tokens/${token.id}`, { @@ -278,10 +331,66 @@ function apiTokens() { } catch (e) { this.error = e.message; } finally { - this.revoking = null; + this.acting = null; } }, + async reactivateToken(token) { + if (!confirm({{ _("api_tokens.reactivate_confirm") | tojson }})) return; + this.acting = token.id; + this.error = null; + try { + const res = await fetch(`/api/api-tokens/${token.id}/reactivate`, { + method: 'POST', + headers: { 'X-CSRF-Token': csrfToken }, + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || 'Failed to reactivate token'); + } + await this.loadTokens(); + } catch (e) { + this.error = e.message; + } finally { + this.acting = null; + } + }, + + async deleteToken(token) { + if (!confirm({{ _("api_tokens.delete_confirm") | tojson }})) return; + this.acting = token.id; + this.error = null; + try { + const res = await fetch(`/api/api-tokens/${token.id}`, { + method: 'DELETE', + headers: { 'X-CSRF-Token': csrfToken }, + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || 'Failed to delete token'); + } + await this.loadTokens(); + } catch (e) { + this.error = e.message; + } finally { + this.acting = null; + } + }, + + tokenStatusClass(token) { + if (!token.is_active) return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'; + if (token.expires_at && new Date(token.expires_at) < new Date()) + return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'; + return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'; + }, + + tokenStatusLabel(token) { + if (!token.is_active) return '{{ _("api_tokens.status_revoked") }}'; + if (token.expires_at && new Date(token.expires_at) < new Date()) + return '{{ _("api_tokens.status_expired") }}'; + return '{{ _("api_tokens.status_active") }}'; + }, + copyToken() { if (this.newlyCreatedToken) { navigator.clipboard.writeText(this.newlyCreatedToken); diff --git a/frontend/templates/base.html b/frontend/templates/base.html index b7e47e44..57167f89 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -21,8 +21,6 @@ {% block head_css %} - - + {# ── Sentry Browser SDK ───────────────────────────────────────────────── + Loaded only when SENTRY_DSN is configured. The DSN is a *public* + Sentry key and is intentionally embedded in client-side code. + Update the version pin at browser.sentry-cdn.com/releases when + upgrading the SDK. + ──────────────────────────────────────────────────────────────────────── #} + {% if sentry_dsn %} + + + {% endif %} {{ _("nav.credentials") }} + + {{ _("nav.connections") }} + {{ _("nav.file_manager") }} @@ -429,6 +458,9 @@ {{ _("nav.credentials") }} + + {{ _("nav.connections") }} + {{ _("nav.file_manager") }} diff --git a/frontend/templates/devices.html b/frontend/templates/devices.html index 40e0a255..b640d950 100644 --- a/frontend/templates/devices.html +++ b/frontend/templates/devices.html @@ -3,7 +3,7 @@ {% block title %}{{ _("devices.page_title") }}{% endblock %} {% block content %} -
+
@@ -48,55 +48,85 @@ - - - - - - + + + + + + @@ -182,16 +212,31 @@ x-show="device.is_active" type="button" @click="deactivateDevice(device)" - :disabled="deactivatingDevice === device.id" + :disabled="actingDevice === device.id" class="flex-shrink-0 inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors" style="min-height:36px; min-width:44px;" :aria-label="'{{ _('devices.deactivate_device') }} ' + (device.device_name || 'device')" > - + {{ _("devices.deactivate_device") }} + + @@ -242,8 +287,8 @@ function devicesPage() { devices: [], loadingTokens: true, loadingDevices: true, - revokingToken: null, - deactivatingDevice: null, + actingToken: null, + actingDevice: null, tokenError: null, deviceError: null, banner: { visible: false, error: false, message: '' }, @@ -286,7 +331,7 @@ function devicesPage() { async revokeToken(token) { if (!confirm({{ _("devices.confirm_revoke_token") | tojson }})) return; - this.revokingToken = token.id; + this.actingToken = token.id; try { const res = await fetch(`/api/api-tokens/${token.id}`, { method: 'DELETE', @@ -301,19 +346,61 @@ function devicesPage() { } catch (e) { this._showBanner(e.message, true); } finally { - this.revokingToken = null; + this.actingToken = null; + } + }, + + async reactivateMobileToken(token) { + if (!confirm({{ _("devices.reactivate_token_confirm") | tojson }})) return; + this.actingToken = token.id; + try { + const res = await fetch(`/api/api-tokens/${token.id}/reactivate`, { + method: 'POST', + headers: { 'X-CSRF-Token': csrfToken }, + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || 'Failed to reactivate token'); + } + await this.loadMobileTokens(); + this._showBanner({{ _("devices.token_reactivated_success") | tojson }}, false); + } catch (e) { + this._showBanner(e.message, true); + } finally { + this.actingToken = null; + } + }, + + async deleteMobileToken(token) { + if (!confirm({{ _("devices.delete_token_confirm") | tojson }})) return; + this.actingToken = token.id; + try { + const res = await fetch(`/api/api-tokens/${token.id}`, { + method: 'DELETE', + headers: { 'X-CSRF-Token': csrfToken }, + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || 'Failed to delete token'); + } + await this.loadMobileTokens(); + this._showBanner({{ _("devices.token_deleted_success") | tojson }}, false); + } catch (e) { + this._showBanner(e.message, true); + } finally { + this.actingToken = null; } }, async deactivateDevice(device) { if (!confirm({{ _("devices.confirm_deactivate_device") | tojson }})) return; - this.deactivatingDevice = device.id; + this.actingDevice = device.id; try { const res = await fetch(`/api/mobile/devices/${device.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrfToken }, }); - if (!res.ok && res.status !== 204) { + if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.detail || 'Failed to remove device'); } @@ -322,7 +409,28 @@ function devicesPage() { } catch (e) { this._showBanner(e.message, true); } finally { - this.deactivatingDevice = null; + this.actingDevice = null; + } + }, + + async deleteDevice(device) { + if (!confirm({{ _("devices.delete_device_confirm") | tojson }})) return; + this.actingDevice = device.id; + try { + const res = await fetch(`/api/mobile/devices/${device.id}`, { + method: 'DELETE', + headers: { 'X-CSRF-Token': csrfToken }, + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || 'Failed to delete device'); + } + await this.loadDevices(); + this._showBanner({{ _("devices.device_deleted_success") | tojson }}, false); + } catch (e) { + this._showBanner(e.message, true); + } finally { + this.actingDevice = null; } }, diff --git a/frontend/templates/dropbox.html b/frontend/templates/dropbox.html index a94a5b96..6c41bd96 100644 --- a/frontend/templates/dropbox.html +++ b/frontend/templates/dropbox.html @@ -104,7 +104,7 @@

Step 3: Set OAuth 2 Redirect URI

  1. In your app's settings page, go to the "OAuth 2" section
  2. -
  3. Add a redirect URI: {{ request.url.scheme }}://{{ request.url.netloc }}/dropbox-callback
  4. +
  5. Add a redirect URI: {{ callback_url }}
  6. Click "Add" to save the redirect URI
@@ -116,6 +116,34 @@
+ {% if user_mode and global_creds_available %} + +
+
+ +
+

Using shared application credentials

+

Your administrator has enabled shared Dropbox app credentials. You can authorize your account without supplying your own App Key and Secret.

+
+
+
+ + {% if folder_path %} +
+

Target folder (from integration settings)

+

{{ folder_path }}

+
+ {% endif %} + +
+ +
+ {% else %}
@@ -146,6 +174,7 @@ Start Authentication Flow
+ {% endif %} {% if not user_mode %} @@ -268,6 +297,10 @@ DROPBOX_FOLDER={{ folder_path|default('/Documents/Uploads', true) }} document.addEventListener('DOMContentLoaded', function() { const userMode = {{ 'true' if user_mode else 'false' }}; + const globalCredsAvailable = {{ 'true' if global_creds_available else 'false' }}; + // Redirect URI for OAuth: prefer server-provided value (respects PUBLIC_BASE_URL), + // fall back to window.location.origin for resilience. + const dropboxCallbackUrl = {{ callback_url | tojson }} || (window.location.origin + "/dropbox-callback"); // Store integration_id if provided (for per-user OAuth flow) const integrationId = "{{ integration_id or '' }}"; @@ -277,6 +310,7 @@ document.addEventListener('DOMContentLoaded', function() { // Elements const startAuthFlowBtn = document.getElementById('start-auth-flow'); + const startAuthFlowGlobalBtn = document.getElementById('start-auth-flow-global'); const testTokenBtn = document.getElementById('test-token'); const refreshTokenBtn = document.getElementById('refresh-token-btn'); const tokenStatus = document.getElementById('token-status'); @@ -328,11 +362,38 @@ document.addEventListener('DOMContentLoaded', function() { } }); + // Global-credentials "Authorize with Dropbox" button (user mode, admin-provided creds) + if (startAuthFlowGlobalBtn) { + startAuthFlowGlobalBtn.addEventListener('click', async function() { + startAuthFlowGlobalBtn.disabled = true; + startAuthFlowGlobalBtn.innerHTML = ' Redirecting…'; + try { + const resp = await fetch('/api/dropbox/global-authorize-url'); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + showModal('error', 'Error', err.detail || 'Could not retrieve authorization URL.'); + startAuthFlowGlobalBtn.disabled = false; + startAuthFlowGlobalBtn.innerHTML = 'Authorize with Dropbox'; + return; + } + const data = await resp.json(); + // Signal to the callback that global credentials should be used for the exchange + sessionStorage.setItem('dropbox_use_global_creds', 'true'); + window.location.href = data.authorize_url; + } catch (err) { + showModal('error', 'Network Error', err.message || 'Unknown error'); + startAuthFlowGlobalBtn.disabled = false; + startAuthFlowGlobalBtn.innerHTML = 'Authorize with Dropbox'; + } + }); + } + // Start Authentication Flow button click - startAuthFlowBtn.addEventListener('click', function() { + if (startAuthFlowBtn) { + startAuthFlowBtn.addEventListener('click', function() { const appKey = document.getElementById('app-key').value.trim(); const appSecret = appSecretInput.value.trim(); - const redirectUri = window.location.origin + "/dropbox-callback"; + const redirectUri = dropboxCallbackUrl; if (!appKey) { showModal('error', 'Validation Error', 'Please enter your App Key'); @@ -362,6 +423,7 @@ document.addEventListener('DOMContentLoaded', function() { // Redirect the user to the Dropbox login page window.location.href = authUrl; }); + } // end if (startAuthFlowBtn) // Test Token button click (admin mode only) if (testTokenBtn) { diff --git a/frontend/templates/dropbox_callback.html b/frontend/templates/dropbox_callback.html index fd7b0f31..bedddc70 100644 --- a/frontend/templates/dropbox_callback.html +++ b/frontend/templates/dropbox_callback.html @@ -59,6 +59,40 @@
+ + +

Configuration for Worker Nodes

@@ -94,6 +128,7 @@ document.addEventListener('DOMContentLoaded', function() { const code = "{{ code }}"; // Get credentials from session storage (these take precedence over server-provided values) + const useGlobalCreds = sessionStorage.getItem('dropbox_use_global_creds') === 'true'; const appKey = sessionStorage.getItem('dropbox_app_key') || "{{ app_key_value }}"; const appSecret = sessionStorage.getItem('dropbox_app_secret') || "{{ app_secret_value }}"; const folderPath = sessionStorage.getItem('dropbox_folder_path') || "{{ folder_path }}" || '/Documents/Uploads'; @@ -107,20 +142,100 @@ document.addEventListener('DOMContentLoaded', function() { } } - const redirectUri = window.location.origin + "/dropbox-callback"; + // Use server-provided callback URL (respects PUBLIC_BASE_URL) with fallback to window.location.origin + const redirectUri = {{ callback_url | tojson }} || (window.location.origin + "/dropbox-callback"); // Automatically exchange the code for a refresh token if (code) { - if (!appKey || !appSecret) { - showError("Missing App Key or App Secret. Please go back to the setup page and try again."); - return; + if (useGlobalCreds) { + // Global credentials: the server handles the exchange using the admin app secret + exchangeCodeGlobal(code, redirectUri); + } else { + if (!appKey || !appSecret) { + showError("Missing App Key or App Secret. Please go back to the setup page and try again."); + return; + } + exchangeCode(code, appKey, appSecret, redirectUri); } - - exchangeCode(code, appKey, appSecret, redirectUri); } else { showError("No authorization code was found in the URL"); } + function exchangeCodeGlobal(code, redirectUri) { + const formData = new FormData(); + formData.append('code', code); + formData.append('redirect_uri', redirectUri); + + document.getElementById('processing-message').innerHTML = + '

Exchanging authorization code using shared credentials…

'; + + fetch('/api/dropbox/exchange-token-global', { + method: 'POST', + body: formData + }) + .then(response => { + if (!response.ok) { + return response.json().then(err => { + throw new Error(err.detail || 'Failed to exchange token'); + }); + } + return response.json(); + }) + .then(data => { + if (data.refresh_token) { + const resolvedAppKey = data.app_key || ''; + if (integrationId) { + const creds = { + refresh_token: data.refresh_token, + // Store public app_key with the integration (no secret stored browser-side) + app_key: resolvedAppKey, + // Flag so the backend knows to use global app_secret for future operations + use_global_app_secret: true, + }; + + const body = { credentials: creds }; + + document.getElementById('processing-message').innerHTML = + '

Saving credentials to your integration…

'; + + return fetch(`/api/integrations/${integrationId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).then(response => { + if (!response.ok) { + return response.json().then(err => { + throw new Error('Failed to save credentials: ' + (err.detail || 'Unknown error')); + }); + } + return response.json(); + }).then(() => { + // Clean up + sessionStorage.removeItem('dropbox_use_global_creds'); + sessionStorage.removeItem('oauth_integration_id'); + + document.getElementById('processing-message').innerHTML = + '

✓ Dropbox authorized successfully!

' + + '

Redirecting to Integrations…

'; + document.querySelector('.animate-spin').parentNode.classList.add('hidden'); + setTimeout(() => { window.location.href = '/integrations'; }, 2000); + }); + } + // Global admin flow — the exchange-token-global endpoint is intended for + // per-user integrations, so reaching here without an integrationId is unexpected. + console.warn('dropbox_callback: global creds flow reached without integration_id'); + sessionStorage.removeItem('dropbox_use_global_creds'); + showSuccess(data.refresh_token, resolvedAppKey, '', folderPath); + setTimeout(() => { window.location.href = '/status'; }, 10000); + } else { + throw new Error('No refresh token was received from the server'); + } + }) + .catch(error => { + showError(error.message); + }); + } + function exchangeCode(code, appKey, appSecret, redirectUri) { const formData = new FormData(); formData.append('client_id', appKey); @@ -173,18 +288,21 @@ document.addEventListener('DOMContentLoaded', function() { } return response.json(); }).then(() => { - // Clean up + // Clean up session storage sessionStorage.removeItem('dropbox_app_key'); sessionStorage.removeItem('dropbox_app_secret'); sessionStorage.removeItem('dropbox_folder_path'); sessionStorage.removeItem('oauth_integration_id'); + sessionStorage.removeItem('dropbox_use_system_creds'); - // Show brief success then redirect to integrations - document.getElementById('processing-message').innerHTML = - '

✓ Dropbox authorized successfully!

' + - '

Redirecting to Integrations...

'; + // Hide processing spinner, show success document.querySelector('.animate-spin').parentNode.classList.add('hidden'); - setTimeout(() => { window.location.href = '/integrations'; }, 2000); + document.getElementById('processing-message').innerHTML = + '

✓ Dropbox authorized successfully!

'; + document.getElementById('success-container').classList.remove('hidden'); + + // Show folder browser with the access token + initFolderBrowser(data.access_token, integrationId); }); } @@ -279,6 +397,128 @@ DROPBOX_FOLDER=${folderPath || '/Documents/Uploads'}`; }); } } + + // ── Folder browser ──────────────────────────────────────────────── + function escapeHtml(str) { + const div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; + } + + function initFolderBrowser(accessToken, integrationId) { + const folderSelector = document.getElementById('folder-selector'); + if (!folderSelector || !integrationId) return; + + folderSelector.classList.remove('hidden'); + let currentPath = ''; + + const folderList = document.getElementById('folder-list'); + const breadcrumb = document.getElementById('folder-breadcrumb'); + const selectedInput = document.getElementById('selected-folder-path'); + const saveBtn = document.getElementById('save-folder-btn'); + const saveStatus = document.getElementById('folder-save-status'); + + function loadFolders(path) { + currentPath = path; + folderList.innerHTML = '

Loading folders…

'; + + const formData = new FormData(); + formData.append('access_token', accessToken); + formData.append('path', path); + + fetch('/api/dropbox/list-folders', { method: 'POST', body: formData }) + .then(r => r.json()) + .then(data => { + if (data.folders && data.folders.length > 0) { + folderList.innerHTML = data.folders.map(f => + `` + ).join(''); + + folderList.querySelectorAll('.folder-item').forEach(btn => { + btn.addEventListener('click', () => { + const p = btn.getAttribute('data-path'); + selectedInput.value = p; + loadFolders(p); + }); + }); + } else { + folderList.innerHTML = '
No subfolders found
'; + } + updateBreadcrumb(path); + }) + .catch(err => { + folderList.innerHTML = `
Failed to load folders: ${escapeHtml(err.message)}
`; + }); + } + + function updateBreadcrumb(path) { + const parts = path.split('/').filter(Boolean); + let html = ''; + let accumulated = ''; + for (const part of parts) { + accumulated += '/' + part; + html += `/`; + html += ``; + } + breadcrumb.innerHTML = html; + breadcrumb.querySelectorAll('.folder-nav-btn').forEach(btn => { + btn.addEventListener('click', () => { + const p = btn.getAttribute('data-path'); + selectedInput.value = p || '/'; + loadFolders(p); + }); + }); + } + + // Save selected folder to integration config + saveBtn.addEventListener('click', () => { + const folderPath = selectedInput.value.trim() || '/'; + saveBtn.disabled = true; + saveBtn.textContent = 'Saving…'; + + // Get the current integration config, update folder_path, then PUT back + fetch(`/api/integrations/${integrationId}`) + .then(r => r.json()) + .then(intg => { + const cfg = intg.config || {}; + // Update the correct folder key based on integration type + if (cfg.source_type) { + cfg.folder_path = folderPath; + } else { + cfg.folder = folderPath; + } + return fetch(`/api/integrations/${integrationId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config: cfg }), + }); + }) + .then(r => { + if (!r.ok) throw new Error('Failed to save folder'); + return r.json(); + }) + .then(() => { + saveStatus.textContent = '✓ Folder saved! Redirecting…'; + saveStatus.className = 'mt-2 text-sm text-green-600'; + saveStatus.classList.remove('hidden'); + saveBtn.textContent = 'Saved ✓'; + setTimeout(() => { window.location.href = '/integrations'; }, 1500); + }) + .catch(err => { + saveStatus.textContent = 'Error: ' + err.message; + saveStatus.className = 'mt-2 text-sm text-red-600'; + saveStatus.classList.remove('hidden'); + saveBtn.disabled = false; + saveBtn.textContent = 'Save Folder'; + }); + }); + + // Load root folders initially + loadFolders(''); + } }); {% endblock %} diff --git a/frontend/templates/file_annotations.html b/frontend/templates/file_annotations.html new file mode 100644 index 00000000..8c4ec528 --- /dev/null +++ b/frontend/templates/file_annotations.html @@ -0,0 +1,945 @@ +{% extends "base.html" %} +{% block title %}Comments & Annotations - {{ file.original_filename or 'Document' }} - DocuElevate{% endblock %} + +{% block head_extra %} + + +{% endblock %} + +{% block content %} +
+ + {% if error %} +
Error: {{ error }}
+ {% elif file %} + + + + +
+
+
+ + Comments & Annotations +
+
{{ file.original_filename }}
+ {% if multi_user_enabled %} +
+ + {{ _("file.owner_label") }}: {{ owner_display or _("file.owner_unowned") }} + {% if file.owner_id is none %} + — + + + {% endif %} +
+ {% endif %} +
+
+ + + {% if is_pdf and (processed_file_exists or original_file_exists) %} +
+
+ + Document Viewer +
+
+
+ {% endif %} + + +
+
+ +
+
+

{{ _("comments.heading") }}

+
+
+ + +
+
+
+ + +
+ + +
+
+ + +
+
+

{{ _("annotations.heading") }}

+
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + + {% if current_user_role == 'owner' %} +
+
+

+ + {{ _("sharing.heading") }} +

+
+
+ +
+

{{ _("sharing.loading") }}

+
+ + +
+
+ + +
+
+ + +
+ + + +
+
+ {% endif %} + + {% else %} + +
Document not found.
+ {% endif %} + +
+ + + + + + +{% if current_user_role == 'owner' %} + +{% endif %} + +{% if file and is_pdf and (processed_file_exists or original_file_exists) %} + +{% endif %} +{% if multi_user_enabled and file and file.owner_id is none %} + + +{% endif %} +{% endblock %} + diff --git a/frontend/templates/file_summary.html b/frontend/templates/file_summary.html new file mode 100644 index 00000000..ef91e2ae --- /dev/null +++ b/frontend/templates/file_summary.html @@ -0,0 +1,226 @@ +{% extends "base.html" %} +{% block title %}{{ file.document_title or file.original_filename or 'Document' }} - DocuElevate{% endblock %} + +{% block head_extra %} + + +{% endblock %} + +{% block content %} +
+ + {% if error %} +
Error: {{ error }}
+ {% elif file %} + + + + Back to Files + + +
+
+
+ {% if gpt_metadata and gpt_metadata.filename %} + {{ gpt_metadata.filename | replace('.pdf','') | replace('_',' ') }} + {% elif file.document_title %} + {{ file.document_title }} + {% else %} + {{ file.original_filename or '(untitled)' }} + {% endif %} +
+
{{ file.original_filename }}
+
+ +
+ + {% if file.is_duplicate %} + Duplicate + {% elif step_summary %} + {% set main_completed = step_summary.main.success + step_summary.main.skipped %} + {% if step_summary.main.failure > 0 or step_summary.uploads.failure > 0 %} + Failed + {% elif step_summary.main["in_progress"] > 0 or step_summary.uploads["in_progress"] > 0 %} + Processing + {% elif step_summary.total_main_steps > 0 and main_completed == step_summary.total_main_steps and step_summary.main.failure == 0 %} + Completed + {% else %} + Pending + {% endif %} + {% endif %} +
+
+ + + + + +
+
File Information
+
File ID{{ file.id }}
+
Filename{{ file.original_filename }}
+
Size{{ (file.file_size / 1024) | round(1) }} KB
+
MIME Type{{ file.mime_type or 'unknown' }}
+
Created{{ file.created_at.strftime('%Y-%m-%d %H:%M') if file.created_at else 'N/A' }}
+ {% if file.detected_language %} +
Language{{ file.detected_language }}
+ {% endif %} + {% if pipeline_info %} +
Pipeline{{ pipeline_info.name }}
+ {% endif %} + {% if file.document_title %} +
Document Title{{ file.document_title }}
+ {% endif %} + {% if multi_user_enabled %} +
+ {{ _("file.owner_label") }} + {{ owner_display or _("file.owner_unowned") }} +
+ {% endif %} +
+ + +
+
Quick Actions
+
+ {% if processed_file_exists %} + + Download Processed + + {% endif %} + {% if original_file_exists %} + + Download Original + + {% endif %} + + View Detail + + {% if multi_user_enabled and file.owner_id is none %} + + {% endif %} +
+ +
+ + {% else %} + +
Document not found.
+ {% endif %} + +
+ +{% if multi_user_enabled and file and file.owner_id is none %} + + +{% endif %} +{% endblock %} diff --git a/frontend/templates/file_view.html b/frontend/templates/file_view.html index 01083b78..2fad87e6 100644 --- a/frontend/templates/file_view.html +++ b/frontend/templates/file_view.html @@ -159,8 +159,8 @@ {% elif file %} - - Back to Files + + Back to File Summary
@@ -195,8 +195,8 @@ {% endif %} - - Processing Details + + Processing
@@ -316,6 +316,12 @@ {% endif %} + {% if multi_user_enabled %} +
+ {{ _("file.owner_label") }} + {{ owner_display or _("file.owner_unowned") }} +
+ {% endif %} @@ -344,7 +350,18 @@ Share + {% if multi_user_enabled and file.owner_id is none %} + + {% endif %} + @@ -937,6 +954,16 @@ pdfInit('/api/files/{{ file.id }}/preview?version={{ pv }}'); {% endif %} {% endif %} + {% if multi_user_enabled and file and file.owner_id is none %} + initClaimOwnership({{ file.id | tojson }}, { + confirm: {{ _("file.claim_ownership_confirm") | tojson }}, + success: {{ _("file.claim_ownership_success") | tojson }}, + failed: {{ _("file.claim_ownership_failed") | tojson }} + }); + {% endif %} }); +{% if multi_user_enabled and file and file.owner_id is none %} + +{% endif %} {% endblock %} diff --git a/frontend/templates/files.html b/frontend/templates/files.html index 1d142a03..10892093 100644 --- a/frontend/templates/files.html +++ b/frontend/templates/files.html @@ -904,7 +904,7 @@ function viewFileDetail(fileId, event) { if (event) event.stopPropagation(); - window.location.href = `/files/${fileId}/detail`; + window.location.href = `/files/${fileId}`; } // ── Preview modal ── diff --git a/frontend/templates/google_drive.html b/frontend/templates/google_drive.html index 10a9aca9..43b46d73 100644 --- a/frontend/templates/google_drive.html +++ b/frontend/templates/google_drive.html @@ -167,14 +167,31 @@

Now enter your Client ID and Client Secret below, and we'll help you complete the OAuth flow. You can set the folder ID after authentication is complete.

-
- - + {% if user_mode and has_system_credentials %} + +
+
+ {% endif %} -
- - +
+
+
+ + +
+ +
+ + +
+
@@ -430,6 +447,9 @@ GOOGLE_DRIVE_FOLDER_ID={{ folder_id|default('YOUR_FOLDER_ID', true) }} {% endblock %} diff --git a/frontend/templates/signup.html b/frontend/templates/signup.html index 1cb3a043..48246248 100644 --- a/frontend/templates/signup.html +++ b/frontend/templates/signup.html @@ -4,7 +4,7 @@ DocuElevate - Create Account - + @@ -32,6 +32,14 @@ error: '', async submit() { this.error = ''; + if (this.username.length < 3 || this.username.length > 64) { + this.error = 'Username must be between 3 and 64 characters.'; + return; + } + if (!/^[a-zA-Z0-9_-]+$/.test(this.username)) { + this.error = 'Username may only contain letters, numbers, hyphens, and underscores. Dots and other special characters are not allowed.'; + return; + } if (this.password !== this.password_confirm) { this.error = 'Passwords do not match.'; return; @@ -58,7 +66,12 @@ } } else { const data = await resp.json(); - this.error = data.detail || 'Registration failed. Please try again.'; + const detail = data.detail; + if (Array.isArray(detail)) { + this.error = detail.map(e => e.msg || String(e)).join(' ') || 'Registration failed. Please try again.'; + } else { + this.error = detail || 'Registration failed. Please try again.'; + } } } catch(e) { this.error = 'Network error. Please try again.'; diff --git a/frontend/translations/en.json b/frontend/translations/en.json index ebb67b2e..ca1f3125 100644 --- a/frontend/translations/en.json +++ b/frontend/translations/en.json @@ -315,7 +315,23 @@ "admin_users.total_count_users": "{count} users", "admin_users.total_no_users": "No users", "admin_users.total_one_user": "1 user", + "annotations.add": "Add annotation", + "annotations.color": "Color", + "annotations.content_placeholder": "Write an annotation...", + "annotations.delete_confirm": "Are you sure you want to delete this annotation?", + "annotations.deleted": "Annotation deleted", + "annotations.empty": "No annotations yet", + "annotations.go_to_page": "Go to page", + "annotations.heading": "Annotations", + "annotations.page": "Page", + "annotations.save": "Save", + "annotations.type_highlight": "Highlight", + "annotations.type_note": "Note", + "annotations.type_strikethrough": "Strikethrough", + "annotations.type_underline": "Underline", + "annotations.updated": "Annotation updated", "api_tokens.col_created": "Created", + "api_tokens.col_expires": "Expires", "api_tokens.col_last_ip": "Last IP", "api_tokens.col_last_used": "Last Used", "api_tokens.col_name": "Name", @@ -327,6 +343,14 @@ "api_tokens.create_heading": "Create New Token", "api_tokens.create_token": "Create Token", "api_tokens.creating": "Creating…", + "api_tokens.delete": "Delete", + "api_tokens.delete_confirm": "Permanently delete this revoked token? This cannot be undone.", + "api_tokens.delete_prefix": "Permanently delete token", + "api_tokens.expires_at_label": "Expires (optional)", + "api_tokens.expires_at_placeholder": "e.g. 30, 90, 365 days", + "api_tokens.expires_in_days_label": "Token lifetime (days)", + "api_tokens.expires_never": "Never", + "api_tokens.expires_on": "Expires", "api_tokens.heading": "API Tokens", "api_tokens.intro": "Create personal API tokens to interact with the DocuElevate API programmatically. Use tokens for webhook uploads, CI/CD pipelines, or any script that needs to upload or retrieve documents.", "api_tokens.loading_tokens": "Loading tokens…", @@ -334,9 +358,13 @@ "api_tokens.no_tokens_heading": "No API tokens yet", "api_tokens.no_tokens_help": "Create your first token above to get started.", "api_tokens.page_title": "API Tokens – DocuElevate", + "api_tokens.reactivate": "Reactivate", + "api_tokens.reactivate_confirm": "Reactivate this token? It will be usable again immediately.", + "api_tokens.reactivate_prefix": "Reactivate token", "api_tokens.revoke": "Revoke", "api_tokens.revoke_prefix": "Revoke token", "api_tokens.status_active": "Active", + "api_tokens.status_expired": "Expired", "api_tokens.status_revoked": "Revoked", "api_tokens.table_aria": "API Tokens", "api_tokens.token_created": "Token created successfully!", @@ -471,6 +499,21 @@ "billing.success_heading": "You're all set!", "billing.success_message": "Your subscription has been activated. Thank you for choosing DocuElevate!", "billing.success_page_title": "DocuElevate - Subscription Activated", + "comments.add_comment": "Add comment", + "comments.add_reply": "Reply", + "comments.body_placeholder": "Write a comment... Use @username to mention someone", + "comments.delete_confirm": "Are you sure you want to delete this comment?", + "comments.deleted": "Comment deleted", + "comments.edit": "Edit", + "comments.empty": "No comments yet", + "comments.heading": "Comments", + "comments.mention_users": "Mention users", + "comments.reply_placeholder": "Write a reply...", + "comments.resolve": "Resolve", + "comments.resolved": "Resolved", + "comments.save": "Save", + "comments.unresolve": "Reopen", + "comments.updated": "Comment updated", "common.actions": "Actions", "common.active": "Active", "common.all": "All", @@ -575,6 +618,22 @@ "cookie_policy.s5_p3_pre": "This Cookie Policy is part of and incorporated into our", "cookie_policy.s5_privacy_link": "Privacy Notice", "cookie_policy.s5_terms_link": "Terms of Service", + "connections.configure": "Configure", + "connections.configured": "Connected", + "connections.description": "Configure external authentication providers like OAuth2 and SAML.", + "connections.frontend_url_note": "Note: Requires Frontend URL to be configured. Configure in System Settings.", + "connections.linked": "Linked", + "connections.mobile_upload_description": "Allow users to upload files from mobile devices by scanning a QR code.", + "connections.mobile_upload_title": "Mobile Phone Upload", + "connections.qr_code_enabled": "Enable QR Code Upload", + "connections.save_note": "Changes to authentication providers require a restart to take effect.", + "connections.sso_auto_login": "Enable SSO Auto Login", + "connections.sso_auto_login_description": "Automatically redirect to SSO login when authentication is required.", + "connections.sso_auto_login_title": "SSO Auto Login", + "connections.title": "Connections", + "connections.unconfigure": "Disconnect", + "connections.unlinked": "Unlinked", + "connections.unlinked_services": "Services", "credentials.col_action": "Action", "credentials.col_credential": "Credential", "credentials.col_source": "Source", @@ -620,6 +679,11 @@ "devices.confirm_deactivate_device": "Remove this device? It will stop receiving push notifications.", "devices.confirm_revoke_token": "Revoke access for this device? It will need to log in again.", "devices.deactivate_device": "Remove", + "devices.delete_device": "Delete", + "devices.delete_device_confirm": "Permanently delete this inactive device? This cannot be undone.", + "devices.delete_token": "Delete", + "devices.delete_token_confirm": "Permanently delete this revoked token? This cannot be undone.", + "devices.device_deleted_success": "Device permanently deleted.", "devices.device_removed_success": "Device removed successfully.", "devices.heading": "Mobile Devices", "devices.intro": "Manage your mobile app connections and registered devices. You can revoke access for individual devices here.", @@ -632,12 +696,16 @@ "devices.no_mobile_tokens_help": "Log in via the mobile app or scan a QR code to create a mobile token.", "devices.page_title": "Devices – DocuElevate", "devices.qr_login_cta": "Connect a new device via QR code", + "devices.reactivate_token": "Reactivate", + "devices.reactivate_token_confirm": "Reactivate this token? The device will be able to use it again immediately.", "devices.registered_devices_description": "Devices registered for push notifications from the DocuElevate mobile app.", "devices.registered_devices_heading": "Registered Devices", "devices.revoke_token": "Revoke", "devices.status_active": "Active", "devices.status_inactive": "Inactive", "devices.status_revoked": "Revoked", + "devices.token_deleted_success": "Token permanently deleted.", + "devices.token_reactivated_success": "Token reactivated successfully.", "devices.token_revoked_success": "Device token revoked successfully.", "duplicates.file_id_label": "File ID", "duplicates.file_id_placeholder": "e.g. 42", @@ -1179,6 +1247,7 @@ "nav.api_docs": "API Docs", "nav.api_tokens": "API Tokens", "nav.backup_restore": "Backup & Restore", + "nav.connections": "Connections", "nav.credentials": "Credentials", "nav.dark_mode": "Dark Mode", "nav.dashboard": "Dashboard", @@ -1675,6 +1744,25 @@ "shared.table_aria": "Shared links", "shared.unlimited_placeholder": "Unlimited", "shared.your_links": "Your Shared Links", + "sharing.add_share": "Share", + "sharing.change_role": "Change role", + "sharing.error_empty_user": "Please enter a user ID to share with.", + "sharing.heading": "Share with Users", + "sharing.loading": "Loading shares…", + "sharing.no_shares": "Not shared with anyone yet.", + "sharing.revoke": "Revoke access", + "sharing.revoke_confirm": "Remove this user's access to the file?", + "sharing.role_editor": "Editor", + "sharing.role_label": "Role", + "sharing.role_viewer": "Viewer", + "sharing.user_id_label": "User ID or email", + "sharing.user_id_placeholder": "e.g. alice@example.com", + "file.owner_label": "Owner", + "file.owner_unowned": "Unowned", + "file.claim_ownership": "Claim Ownership", + "file.claim_ownership_confirm": "Claim this document as yours? You will become the owner and can manage sharing.", + "file.claim_ownership_success": "You are now the owner of this document.", + "file.claim_ownership_failed": "Could not claim ownership. The document may already have an owner.", "similarity.backfill_auto": "The background task will compute them automatically every 5 minutes, or you can", "similarity.files_missing_text": "file(s) have OCR text but no embedding yet.", "similarity.find_pairs_btn": "Find Pairs", @@ -1891,5 +1979,6 @@ "upload.uploading": "Uploading...", "upload.url_description": "Enter a direct link to a file (PDF, Office documents, or images)", "upload.url_label": "File URL", - "upload.url_placeholder": "https://example.com/document.pdf" + "upload.url_placeholder": "https://example.com/document.pdf", + "annotations.type": "Type" } diff --git a/migrations/env.py b/migrations/env.py index a0bc2f99..79d91540 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -24,7 +24,10 @@ from app.models import ( # noqa: F401 ApplicationSettings, AuditLog, BackupRecord, + ClassificationRuleModel, ComplianceTemplate, + DocumentAnnotation, + DocumentComment, DocumentMetadata, FileProcessingStep, FileRecord, diff --git a/tests/conftest.py b/tests/conftest.py index fef85bd9..b1958806 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -62,9 +62,14 @@ from app.main import app as fastapi_app # noqa: E402 from app.models import ( # noqa: F401, E402 ApiToken, AuditLog, + AutomationHook, + ClassificationRuleModel, ComplianceTemplate, + DocumentAnnotation, + DocumentComment, DocumentMetadata, FileRecord, + FileShare, Pipeline, PipelineRoutingRule, PipelineStep, @@ -115,6 +120,7 @@ def client(db_session) -> TestClient: # Import the canonical get_db function from app.database import get_db + from app.middleware.upload_rate_limit import require_upload_rate_limit # Override the get_db dependency to use our test database def override_get_db(): @@ -126,6 +132,14 @@ def client(db_session) -> TestClient: # Override the single canonical get_db dependency fastapi_app.dependency_overrides[get_db] = override_get_db + # Disable per-user upload rate limiting in tests so that upload-heavy + # test suites are not rejected with 429 Too Many Requests. + async def _no_rate_limit() -> None: + """No-op override: skip upload rate limiting during tests.""" + return None + + fastapi_app.dependency_overrides[require_upload_rate_limit] = _no_rate_limit + # Use base_url to satisfy TrustedHostMiddleware with TestClient(fastapi_app, base_url="http://localhost") as test_client: yield test_client diff --git a/tests/test_api_advanced_filters.py b/tests/test_api_advanced_filters.py index 922aee6e..f5301f87 100644 --- a/tests/test_api_advanced_filters.py +++ b/tests/test_api_advanced_filters.py @@ -181,3 +181,343 @@ class TestFilesAdvancedFiltering: assert response.status_code == 200 data = response.json() assert len(data["files"]) == 1 + + +# --------------------------------------------------------------------------- +# Saved searches CRUD tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestSavedSearchesCRUD: + """Tests for saved searches CRUD API endpoints.""" + + def test_list_saved_searches_empty(self, client: TestClient): + """GET /api/saved-searches returns empty list when no searches exist.""" + response = client.get("/api/saved-searches") + assert response.status_code == 200 + assert response.json() == [] + + def test_create_saved_search(self, client: TestClient): + """POST /api/saved-searches creates a new saved search.""" + payload = { + "name": "My Invoices", + "filters": {"tags": "invoice", "status": "completed"}, + } + response = client.post("/api/saved-searches", json=payload) + assert response.status_code == 201 + data = response.json() + assert data["name"] == "My Invoices" + assert data["filters"]["tags"] == "invoice" + assert data["filters"]["status"] == "completed" + assert "id" in data + + def test_create_and_list_saved_search(self, client: TestClient): + """Creating a saved search makes it appear in the list.""" + payload = { + "name": "PDF Files", + "filters": {"mime_type": "application/pdf"}, + } + client.post("/api/saved-searches", json=payload) + + response = client.get("/api/saved-searches") + assert response.status_code == 200 + searches = response.json() + assert len(searches) == 1 + assert searches[0]["name"] == "PDF Files" + + def test_create_saved_search_missing_name(self, client: TestClient): + """POST /api/saved-searches without name returns 422.""" + payload = {"filters": {"status": "completed"}} + response = client.post("/api/saved-searches", json=payload) + assert response.status_code == 422 + + def test_create_saved_search_empty_filters(self, client: TestClient): + """POST /api/saved-searches with empty filters returns 422.""" + payload = {"name": "Empty", "filters": {}} + response = client.post("/api/saved-searches", json=payload) + assert response.status_code == 422 + + def test_create_saved_search_invalid_filter_keys(self, client: TestClient): + """POST /api/saved-searches ignores unknown filter keys.""" + payload = { + "name": "With unknown keys", + "filters": {"invalid_key": "value", "status": "completed"}, + } + response = client.post("/api/saved-searches", json=payload) + assert response.status_code == 201 + data = response.json() + # Only valid filter key should remain + assert "invalid_key" not in data["filters"] + assert data["filters"]["status"] == "completed" + + def test_create_saved_search_only_invalid_keys(self, client: TestClient): + """POST with only invalid filter keys returns 422.""" + payload = { + "name": "All invalid", + "filters": {"bad_key": "value"}, + } + response = client.post("/api/saved-searches", json=payload) + assert response.status_code == 422 + + def test_create_duplicate_name(self, client: TestClient): + """POST /api/saved-searches with duplicate name returns 409.""" + payload = {"name": "My Search", "filters": {"status": "completed"}} + response1 = client.post("/api/saved-searches", json=payload) + assert response1.status_code == 201 + + response2 = client.post("/api/saved-searches", json=payload) + assert response2.status_code == 409 + + def test_create_saved_search_db_error(self, client: TestClient, monkeypatch): + """POST /api/saved-searches returns 500 on DB exception.""" + # Mock db.add or db.commit to raise an exception + # We can monkeypatch the route's dependency or the models + # It's easier to mock the SavedSearch model's __init__ or db's add + # Since we use db: DbSession, it's an instance of sqlalchemy.orm.Session + from sqlalchemy.orm import Session + + original_commit = Session.commit + + def mock_commit(*args, **kwargs): + raise Exception("Simulated DB error") + + monkeypatch.setattr(Session, "commit", mock_commit) + + payload = { + "name": "DB Error Search", + "filters": {"status": "completed"}, + } + response = client.post("/api/saved-searches", json=payload) + assert response.status_code == 500 + assert "Failed to save search" in response.json()["detail"] + + def test_create_saved_search_limit_reached(self, client: TestClient, monkeypatch): + """POST /api/saved-searches returns 409 if max limit is reached.""" + monkeypatch.setattr("app.api.saved_searches.MAX_SAVED_SEARCHES_PER_USER", 1) + + # Create first one + payload1 = {"name": "Search 1", "filters": {"status": "completed"}} + response1 = client.post("/api/saved-searches", json=payload1) + assert response1.status_code == 201 + + # Creating second one should fail due to limit + payload2 = {"name": "Search 2", "filters": {"status": "pending"}} + response2 = client.post("/api/saved-searches", json=payload2) + assert response2.status_code == 409 + assert "Maximum of 1 saved searches reached" in response2.json()["detail"] + + def test_create_saved_search_invalid_name_type(self, client: TestClient): + """POST /api/saved-searches with non-string name returns 422.""" + payload = { + "name": 12345, + "filters": {"status": "completed"}, + } + response = client.post("/api/saved-searches", json=payload) + assert response.status_code == 422 + + def test_update_saved_search(self, client: TestClient): + """PUT /api/saved-searches/{id} updates the saved search.""" + # Create + create_resp = client.post( + "/api/saved-searches", + json={"name": "Original", "filters": {"status": "pending"}}, + ) + search_id = create_resp.json()["id"] + + # Update + update_resp = client.put( + f"/api/saved-searches/{search_id}", + json={"name": "Updated", "filters": {"status": "completed"}}, + ) + assert update_resp.status_code == 200 + data = update_resp.json() + assert data["name"] == "Updated" + assert data["filters"]["status"] == "completed" + + def test_update_saved_search_not_found(self, client: TestClient): + """PUT /api/saved-searches/999 returns 404.""" + response = client.put( + "/api/saved-searches/999", + json={"name": "Nope", "filters": {"status": "completed"}}, + ) + assert response.status_code == 404 + + def test_update_saved_search_duplicate_name(self, client: TestClient): + """PUT /api/saved-searches/{id} with duplicate name returns 409.""" + # Create first search + client.post( + "/api/saved-searches", + json={"name": "First Search", "filters": {"status": "pending"}}, + ) + # Create second search + create_resp2 = client.post( + "/api/saved-searches", + json={"name": "Second Search", "filters": {"status": "completed"}}, + ) + search_id2 = create_resp2.json()["id"] + + # Try to rename second search to "First Search" + update_resp = client.put( + f"/api/saved-searches/{search_id2}", + json={"name": "First Search", "filters": {"status": "completed"}}, + ) + assert update_resp.status_code == 409 + + def test_update_saved_search_name_too_long(self, client: TestClient): + """PUT /api/saved-searches/{id} with name > 100 chars returns 422.""" + create_resp = client.post( + "/api/saved-searches", + json={"name": "Valid Name", "filters": {"status": "pending"}}, + ) + search_id = create_resp.json()["id"] + + update_resp = client.put( + f"/api/saved-searches/{search_id}", + json={"name": "x" * 101, "filters": {"status": "completed"}}, + ) + assert update_resp.status_code == 422 + + def test_update_saved_search_empty_name(self, client: TestClient): + """PUT /api/saved-searches/{id} with empty name returns 422.""" + create_resp = client.post( + "/api/saved-searches", + json={"name": "Valid Name", "filters": {"status": "pending"}}, + ) + search_id = create_resp.json()["id"] + + update_resp = client.put( + f"/api/saved-searches/{search_id}", + json={"name": "", "filters": {"status": "completed"}}, + ) + assert update_resp.status_code == 422 + + def test_update_saved_search_empty_filters(self, client: TestClient): + """PUT /api/saved-searches/{id} with empty filters returns 422.""" + create_resp = client.post( + "/api/saved-searches", + json={"name": "Valid Name", "filters": {"status": "pending"}}, + ) + search_id = create_resp.json()["id"] + + update_resp = client.put( + f"/api/saved-searches/{search_id}", + json={"name": "Valid Name", "filters": {}}, + ) + assert update_resp.status_code == 422 + + def test_update_saved_search_invalid_filters(self, client: TestClient): + """PUT /api/saved-searches/{id} with only invalid filters returns 422.""" + create_resp = client.post( + "/api/saved-searches", + json={"name": "Valid Name", "filters": {"status": "pending"}}, + ) + search_id = create_resp.json()["id"] + + update_resp = client.put( + f"/api/saved-searches/{search_id}", + json={"name": "Valid Name", "filters": {"invalid_key": "value"}}, + ) + assert update_resp.status_code == 422 + + def test_delete_saved_search(self, client: TestClient): + """DELETE /api/saved-searches/{id} removes the saved search.""" + # Create + create_resp = client.post( + "/api/saved-searches", + json={"name": "To Delete", "filters": {"status": "failed"}}, + ) + search_id = create_resp.json()["id"] + + # Delete + del_resp = client.delete(f"/api/saved-searches/{search_id}") + assert del_resp.status_code == 204 + + # Verify it's gone + list_resp = client.get("/api/saved-searches") + assert len(list_resp.json()) == 0 + + def test_delete_saved_search_not_found(self, client: TestClient): + """DELETE /api/saved-searches/999 returns 404.""" + response = client.delete("/api/saved-searches/999") + assert response.status_code == 404 + + def test_delete_saved_search_db_error(self, client: TestClient): + """DELETE /api/saved-searches/{id} handles database errors (500).""" + from unittest.mock import patch + + # Create + create_resp = client.post( + "/api/saved-searches", + json={"name": "To Delete DB Error", "filters": {"status": "failed"}}, + ) + search_id = create_resp.json()["id"] + + with patch("sqlalchemy.orm.Session.delete", side_effect=Exception("DB Delete Error")): + response = client.delete(f"/api/saved-searches/{search_id}") + assert response.status_code == 500 + assert response.json()["detail"] == "Failed to delete saved search" + + def test_create_name_too_long(self, client: TestClient): + """POST /api/saved-searches with name > 100 chars returns 422.""" + payload = { + "name": "x" * 101, + "filters": {"status": "completed"}, + } + response = client.post("/api/saved-searches", json=payload) + assert response.status_code == 422 + + def test_saved_search_filters_sanitized(self, client: TestClient): + """Saved search filters are sanitized to allowed keys only.""" + payload = { + "name": "Sanitized", + "filters": { + "search": "invoice", + "mime_type": "application/pdf", + "date_from": "2026-01-01", + "date_to": "2026-12-31", + "storage_provider": "dropbox", + "tags": "invoice,amazon", + "sort_by": "created_at", + "sort_order": "desc", + }, + } + response = client.post("/api/saved-searches", json=payload) + assert response.status_code == 201 + data = response.json() + assert len(data["filters"]) == 8 + assert data["filters"]["search"] == "invoice" + assert data["filters"]["tags"] == "invoice,amazon" + + def test_saved_search_with_fulltext_query(self, client: TestClient): + """Saved search can include full-text query (q) for the search view.""" + payload = { + "name": "Invoice Search", + "filters": {"q": "invoice total amount", "document_type": "Invoice"}, + } + response = client.post("/api/saved-searches", json=payload) + assert response.status_code == 201 + data = response.json() + assert data["filters"]["q"] == "invoice total amount" + assert data["filters"]["document_type"] == "Invoice" + + def test_saved_search_content_finding_filters(self, client: TestClient): + """Saved search accepts content-finding filter keys (language, sender, text_quality).""" + payload = { + "name": "German Invoices", + "filters": { + "q": "rechnung", + "language": "de", + "sender": "ACME GmbH", + "text_quality": "high", + "tags": "invoice", + }, + } + response = client.post("/api/saved-searches", json=payload) + assert response.status_code == 201 + data = response.json() + assert data["filters"]["q"] == "rechnung" + assert data["filters"]["language"] == "de" + assert data["filters"]["sender"] == "ACME GmbH" + assert data["filters"]["text_quality"] == "high" + assert data["filters"]["tags"] == "invoice" diff --git a/tests/test_api_classification_rules.py b/tests/test_api_classification_rules.py new file mode 100644 index 00000000..a519c6a4 --- /dev/null +++ b/tests/test_api_classification_rules.py @@ -0,0 +1,290 @@ +"""Tests for the classification rules API endpoints. + +Covers CRUD operations, validation, and access control for +``/api/classification-rules``. +""" + +import pytest + +from app.models import ClassificationRuleModel + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_rule(db_session, owner_id="anonymous", **overrides): + """Insert a ClassificationRuleModel and return it.""" + defaults = { + "owner_id": owner_id, + "name": "test_rule", + "category": "invoice", + "rule_type": "filename_pattern", + "pattern": r"(?i)invoice", + "priority": 0, + "case_sensitive": False, + "enabled": True, + } + defaults.update(overrides) + rule = ClassificationRuleModel(**defaults) + db_session.add(rule) + db_session.commit() + db_session.refresh(rule) + return rule + + +# --------------------------------------------------------------------------- +# Categories & Rule Types endpoints +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestCategoriesEndpoint: + """Tests for GET /api/classification-rules/categories.""" + + def test_list_categories(self, client): + """Should return a dict of built-in categories.""" + r = client.get("/api/classification-rules/categories") + assert r.status_code == 200 + data = r.json() + assert isinstance(data, dict) + assert "invoice" in data + assert "contract" in data + assert "receipt" in data + assert "unknown" in data + + +@pytest.mark.unit +class TestRuleTypesEndpoint: + """Tests for GET /api/classification-rules/rule-types.""" + + def test_list_rule_types(self, client): + """Should return a list of valid rule types.""" + r = client.get("/api/classification-rules/rule-types") + assert r.status_code == 200 + data = r.json() + assert isinstance(data, list) + assert len(data) == 3 + type_values = {item["type"] for item in data} + assert "filename_pattern" in type_values + assert "content_keyword" in type_values + assert "metadata_match" in type_values + + +# --------------------------------------------------------------------------- +# CRUD Operations +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestClassificationRuleCRUD: + """Full CRUD test-suite for classification rules.""" + + def test_list_rules_empty(self, client): + """List returns an empty array when no rules exist.""" + r = client.get("/api/classification-rules/") + assert r.status_code == 200 + assert r.json() == [] + + def test_create_rule(self, client): + """POST should create a new classification rule.""" + r = client.post( + "/api/classification-rules/", + json={ + "name": "My Invoice Rule", + "category": "invoice", + "rule_type": "filename_pattern", + "pattern": r"(?i)rechnung", + "priority": 10, + }, + ) + assert r.status_code == 201 + data = r.json() + assert data["name"] == "My Invoice Rule" + assert data["category"] == "invoice" + assert data["rule_type"] == "filename_pattern" + assert data["priority"] == 10 + assert data["enabled"] is True + assert data["id"] is not None + + def test_create_rule_invalid_type_rejected(self, client): + """Creating a rule with an invalid rule_type should be rejected.""" + r = client.post( + "/api/classification-rules/", + json={ + "name": "Bad Rule", + "category": "test", + "rule_type": "invalid_type", + "pattern": "test", + }, + ) + assert r.status_code == 400 + + def test_create_duplicate_name_rejected(self, client): + """Creating two rules with the same name should be rejected.""" + payload = { + "name": "Dupe Rule", + "category": "invoice", + "rule_type": "filename_pattern", + "pattern": "test", + } + r1 = client.post("/api/classification-rules/", json=payload) + assert r1.status_code == 201 + r2 = client.post("/api/classification-rules/", json=payload) + assert r2.status_code == 409 + + def test_get_rule(self, client): + """GET should return a specific rule by ID.""" + create_resp = client.post( + "/api/classification-rules/", + json={ + "name": "Get Test Rule", + "category": "contract", + "rule_type": "content_keyword", + "pattern": "agreement|terms", + }, + ) + rule_id = create_resp.json()["id"] + + r = client.get(f"/api/classification-rules/{rule_id}") + assert r.status_code == 200 + assert r.json()["name"] == "Get Test Rule" + assert r.json()["category"] == "contract" + + def test_get_nonexistent_rule(self, client): + """GET for a nonexistent rule should return 404.""" + r = client.get("/api/classification-rules/99999") + assert r.status_code == 404 + + def test_update_rule(self, client): + """PUT should update an existing rule.""" + create_resp = client.post( + "/api/classification-rules/", + json={ + "name": "Update Test", + "category": "receipt", + "rule_type": "filename_pattern", + "pattern": "receipt", + }, + ) + rule_id = create_resp.json()["id"] + + r = client.put( + f"/api/classification-rules/{rule_id}", + json={"category": "invoice", "priority": 50}, + ) + assert r.status_code == 200 + assert r.json()["category"] == "invoice" + assert r.json()["priority"] == 50 + # Name should be unchanged + assert r.json()["name"] == "Update Test" + + def test_update_nonexistent_rule(self, client): + """PUT for a nonexistent rule should return 404.""" + r = client.put("/api/classification-rules/99999", json={"category": "test"}) + assert r.status_code == 404 + + def test_update_invalid_rule_type_rejected(self, client): + """PUT with an invalid rule_type should be rejected.""" + create_resp = client.post( + "/api/classification-rules/", + json={ + "name": "Invalid Update", + "category": "test", + "rule_type": "filename_pattern", + "pattern": "test", + }, + ) + rule_id = create_resp.json()["id"] + + r = client.put( + f"/api/classification-rules/{rule_id}", + json={"rule_type": "bad_type"}, + ) + assert r.status_code == 400 + + def test_delete_rule(self, client): + """DELETE should remove the rule.""" + create_resp = client.post( + "/api/classification-rules/", + json={ + "name": "Delete Test", + "category": "test", + "rule_type": "content_keyword", + "pattern": "test", + }, + ) + rule_id = create_resp.json()["id"] + + r = client.delete(f"/api/classification-rules/{rule_id}") + assert r.status_code == 204 + + # Verify it's gone + r2 = client.get(f"/api/classification-rules/{rule_id}") + assert r2.status_code == 404 + + def test_delete_nonexistent_rule(self, client): + """DELETE for a nonexistent rule should return 404.""" + r = client.delete("/api/classification-rules/99999") + assert r.status_code == 404 + + def test_list_rules_after_create(self, client): + """List should return created rules.""" + client.post( + "/api/classification-rules/", + json={ + "name": "List Rule 1", + "category": "invoice", + "rule_type": "filename_pattern", + "pattern": "test1", + }, + ) + client.post( + "/api/classification-rules/", + json={ + "name": "List Rule 2", + "category": "contract", + "rule_type": "content_keyword", + "pattern": "test2", + }, + ) + r = client.get("/api/classification-rules/") + assert r.status_code == 200 + assert len(r.json()) == 2 + + def test_create_rule_with_all_fields(self, client): + """Create a rule providing all optional fields.""" + r = client.post( + "/api/classification-rules/", + json={ + "name": "Full Rule", + "category": "tax_document", + "rule_type": "metadata_match", + "pattern": "department=finance", + "priority": 100, + "case_sensitive": True, + "enabled": False, + }, + ) + assert r.status_code == 201 + data = r.json() + assert data["case_sensitive"] is True + assert data["enabled"] is False + assert data["priority"] == 100 + + def test_create_rule_defaults(self, client): + """Create a rule with minimal fields to test defaults.""" + r = client.post( + "/api/classification-rules/", + json={ + "name": "Minimal Rule", + "category": "invoice", + "rule_type": "filename_pattern", + "pattern": "test", + }, + ) + assert r.status_code == 201 + data = r.json() + assert data["priority"] == 0 + assert data["case_sensitive"] is False + assert data["enabled"] is True diff --git a/tests/test_api_dropbox.py b/tests/test_api_dropbox.py index aacc57bb..71139d7b 100644 --- a/tests/test_api_dropbox.py +++ b/tests/test_api_dropbox.py @@ -416,3 +416,253 @@ class TestSaveDropboxSettings: # .env write is best-effort; endpoint should still succeed via DB write assert response.status_code == 200 assert response.json()["status"] == "success" + + +@pytest.mark.unit +class TestListDropboxFolders: + """Tests for list_dropbox_folders endpoint.""" + + @patch("app.api.dropbox.requests.post") + def test_list_folders_success(self, mock_post, client): + """Test successful folder listing at root.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "entries": [ + {".tag": "folder", "name": "Documents", "path_display": "/Documents", "id": "id:1"}, + {".tag": "folder", "name": "Photos", "path_display": "/Photos", "id": "id:2"}, + {".tag": "file", "name": "readme.txt", "path_display": "/readme.txt", "id": "id:3"}, + ], + "has_more": False, + } + mock_post.return_value = mock_response + + response = client.post( + "/api/dropbox/list-folders", + data={"access_token": "test-token", "path": ""}, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["folders"]) == 2 + assert data["folders"][0]["name"] == "Documents" + assert data["folders"][1]["name"] == "Photos" + assert data["path"] == "/" + assert data["has_more"] is False + + @patch("app.api.dropbox.requests.post") + def test_list_folders_subfolder(self, mock_post, client): + """Test listing folders in a subfolder.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "entries": [ + {".tag": "folder", "name": "Invoices", "path_display": "/Documents/Invoices", "id": "id:4"}, + ], + "has_more": False, + } + mock_post.return_value = mock_response + + response = client.post( + "/api/dropbox/list-folders", + data={"access_token": "test-token", "path": "/Documents"}, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["folders"]) == 1 + assert data["folders"][0]["path"] == "/Documents/Invoices" + + @patch("app.api.dropbox.requests.post") + def test_list_folders_empty(self, mock_post, client): + """Test listing folders in an empty directory.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"entries": [], "has_more": False} + mock_post.return_value = mock_response + + response = client.post( + "/api/dropbox/list-folders", + data={"access_token": "test-token", "path": "/EmptyFolder"}, + ) + + assert response.status_code == 200 + assert len(response.json()["folders"]) == 0 + + @patch("app.api.dropbox.requests.post") + def test_list_folders_unauthorized(self, mock_post, client): + """Test listing folders with invalid token returns 401.""" + mock_response = Mock() + mock_response.status_code = 401 + mock_response.text = "Invalid access token" + mock_post.return_value = mock_response + + response = client.post( + "/api/dropbox/list-folders", + data={"access_token": "bad-token", "path": ""}, + ) + + assert response.status_code == 401 + + @patch("app.api.dropbox.requests.post") + def test_list_folders_api_error(self, mock_post, client): + """Test listing folders when Dropbox API returns an error.""" + mock_response = Mock() + mock_response.status_code = 500 + mock_response.text = "Internal server error" + mock_post.return_value = mock_response + + response = client.post( + "/api/dropbox/list-folders", + data={"access_token": "test-token", "path": ""}, + ) + + assert response.status_code == 502 + + @patch("app.api.dropbox.requests.post") + def test_list_folders_root_path_normalization(self, mock_post, client): + """Test that '/' is normalized to empty string for Dropbox API.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"entries": [], "has_more": False} + mock_post.return_value = mock_response + + response = client.post( + "/api/dropbox/list-folders", + data={"access_token": "test-token", "path": "/"}, + ) + + assert response.status_code == 200 + # Check the actual API call used empty string for root + call_args = mock_post.call_args + assert call_args[1]["json"]["path"] == "" + + @patch("app.api.dropbox.requests.post") + def test_list_folders_sorted_alphabetically(self, mock_post, client): + """Test that folders are returned in alphabetical order.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "entries": [ + {".tag": "folder", "name": "Zebra", "path_display": "/Zebra", "id": "id:1"}, + {".tag": "folder", "name": "Alpha", "path_display": "/Alpha", "id": "id:2"}, + {".tag": "folder", "name": "middle", "path_display": "/middle", "id": "id:3"}, + ], + "has_more": False, + } + mock_post.return_value = mock_response + + response = client.post( + "/api/dropbox/list-folders", + data={"access_token": "test-token", "path": ""}, + ) + + assert response.status_code == 200 + names = [f["name"] for f in response.json()["folders"]] + assert names == ["Alpha", "middle", "Zebra"] + + +class TestBuildDropboxRedirectUri: + """Tests for the _build_dropbox_redirect_uri helper.""" + + def test_uses_public_base_url_when_set(self): + """When PUBLIC_BASE_URL is configured, redirect URI should use it.""" + from unittest.mock import MagicMock + + with patch("app.api.dropbox.settings") as mock_settings: + mock_settings.public_base_url = "https://myapp.example.com" + from app.api.dropbox import _build_dropbox_redirect_uri + + mock_request = MagicMock() + result = _build_dropbox_redirect_uri(mock_request) + + assert result == "https://myapp.example.com/dropbox-callback" + + def test_uses_public_base_url_strips_trailing_slash(self): + """PUBLIC_BASE_URL with trailing slash should be handled correctly.""" + from unittest.mock import MagicMock + + with patch("app.api.dropbox.settings") as mock_settings: + mock_settings.public_base_url = "https://myapp.example.com/" + from app.api.dropbox import _build_dropbox_redirect_uri + + mock_request = MagicMock() + result = _build_dropbox_redirect_uri(mock_request) + + assert result == "https://myapp.example.com/dropbox-callback" + + def test_falls_back_to_request_when_public_base_url_not_set(self): + """When PUBLIC_BASE_URL is not set, use request scheme and netloc.""" + from unittest.mock import MagicMock + + with patch("app.api.dropbox.settings") as mock_settings: + mock_settings.public_base_url = None + from app.api.dropbox import _build_dropbox_redirect_uri + + mock_request = MagicMock() + mock_request.url.scheme = "https" + mock_request.url.netloc = "other.example.com" + result = _build_dropbox_redirect_uri(mock_request) + + assert result == "https://other.example.com/dropbox-callback" + + +@pytest.mark.unit +class TestGlobalAuthorizeUrl: + """Tests for GET /api/dropbox/global-authorize-url endpoint.""" + + @patch("app.api.dropbox.settings") + def test_returns_authorize_url(self, mock_settings, client): + """Test that a valid authorize URL is returned when global creds are configured.""" + mock_settings.dropbox_allow_global_credentials_for_integrations = True + mock_settings.dropbox_app_key = "test-app-key" + mock_settings.dropbox_app_secret = "test-app-secret" + mock_settings.public_base_url = "https://example.com" + + response = client.get("/api/dropbox/global-authorize-url") + + assert response.status_code == 200 + data = response.json() + assert "authorize_url" in data + assert "https://www.dropbox.com/oauth2/authorize" in data["authorize_url"] + assert "client_id=test-app-key" in data["authorize_url"] + # redirect_uri should be URL-encoded + assert "redirect_uri=" in data["authorize_url"] + assert "https%3A%2F%2Fexample.com%2Fdropbox-callback" in data["authorize_url"] + + @patch("app.api.dropbox.settings") + def test_returns_403_when_global_creds_disabled(self, mock_settings, client): + """Test 403 when global credentials for integrations are disabled.""" + mock_settings.dropbox_allow_global_credentials_for_integrations = False + mock_settings.dropbox_app_key = "test-app-key" + mock_settings.dropbox_app_secret = "test-app-secret" + + response = client.get("/api/dropbox/global-authorize-url") + + assert response.status_code == 403 + + @patch("app.api.dropbox.settings") + def test_returns_503_when_creds_not_configured(self, mock_settings, client): + """Test 503 when global Dropbox credentials are not configured.""" + mock_settings.dropbox_allow_global_credentials_for_integrations = True + mock_settings.dropbox_app_key = None + mock_settings.dropbox_app_secret = None + + response = client.get("/api/dropbox/global-authorize-url") + + assert response.status_code == 503 + + @patch("app.api.dropbox.settings") + def test_redirect_uri_uses_public_base_url(self, mock_settings, client): + """Redirect URI in authorize URL must use PUBLIC_BASE_URL when configured.""" + mock_settings.dropbox_allow_global_credentials_for_integrations = True + mock_settings.dropbox_app_key = "my-key" + mock_settings.dropbox_app_secret = "my-secret" + mock_settings.public_base_url = "https://prod.example.com" + + response = client.get("/api/dropbox/global-authorize-url") + + assert response.status_code == 200 + authorize_url = response.json()["authorize_url"] + # The redirect_uri must be URL-encoded and contain the public base URL + assert "https%3A%2F%2Fprod.example.com%2Fdropbox-callback" in authorize_url diff --git a/tests/test_api_files_comprehensive.py b/tests/test_api_files_comprehensive.py index 3f9200e5..3e1b59ae 100644 --- a/tests/test_api_files_comprehensive.py +++ b/tests/test_api_files_comprehensive.py @@ -5,6 +5,7 @@ Tests all API endpoints with success and error cases, proper mocking, and edge c Target: Bring coverage from 11.75% to 70%+ """ +import os from io import BytesIO from unittest.mock import Mock, patch @@ -833,6 +834,54 @@ class TestExtractTextFromPDF: # pypdf might fail on minimal PDF, that's ok for this test pass + def test_extract_text_from_minimal_pdf(self, tmp_path): + """Test text extraction from a minimal real PDF covers lines 754-756.""" + from app.api.files import _extract_text_from_pdf + + # Minimal valid PDF from conftest + pdf_content = b"""%PDF-1.4 +1 0 obj +<< +/Type /Catalog +/Pages 2 0 R +>> +endobj +2 0 obj +<< +/Type /Pages +/Kids [3 0 R] +/Count 1 +>> +endobj +3 0 obj +<< +/Type /Page +/Parent 2 0 R +/MediaBox [0 0 612 792] +>> +endobj +xref +0 4 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +trailer +<< +/Size 4 +/Root 1 0 R +>> +startxref +197 +%%EOF +""" + pdf_path = tmp_path / "test.pdf" + pdf_path.write_bytes(pdf_content) + + text = _extract_text_from_pdf(str(pdf_path)) + assert isinstance(text, str) + # May be empty string for this minimal PDF, but should not raise + @pytest.mark.unit class TestRetryPipelineStep: @@ -1212,3 +1261,2092 @@ class TestAdditionalFileOperations: response = client.get(f"/api/files/{file.id}/download?version=original") assert response.status_code == 200 # Should default to application/pdf + + +@pytest.mark.unit +class TestGetLimiter: + """Tests for the get_limiter helper.""" + + def test_get_limiter_returns_limiter(self): + """Test that get_limiter returns the app-level rate limiter.""" + from app.api.files import get_limiter + from app.main import app + + result = get_limiter() + assert result is app.state.limiter + + +@pytest.mark.unit +class TestListFilesAPIDateRangeAndFilters: + """Tests for date range and advanced filter parameters of GET /api/files.""" + + def test_list_files_invalid_date_from_returns_422(self, client: TestClient, db_session): + """Test that an invalid date_from value returns 422.""" + response = client.get("/api/files?date_from=not-a-date") + assert response.status_code == 422 + assert "date_from" in response.json()["detail"].lower() + + def test_list_files_invalid_date_to_returns_422(self, client: TestClient, db_session): + """Test that an invalid date_to value returns 422.""" + response = client.get("/api/files?date_to=not-a-date") + assert response.status_code == 422 + assert "date_to" in response.json()["detail"].lower() + + def test_list_files_valid_date_range(self, client: TestClient, db_session): + """Test that valid ISO 8601 date range filters work.""" + response = client.get("/api/files?date_from=2024-01-01&date_to=2024-12-31") + assert response.status_code == 200 + data = response.json() + assert "files" in data + + def test_list_files_with_storage_provider_filter(self, client: TestClient, db_session): + """Test filtering by storage provider.""" + from app.models import FileProcessingStep + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/tmp/test.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + # Add a successful upload step + step = FileProcessingStep( + file_id=file.id, + step_name="upload_to_dropbox", + status="success", + ) + db_session.add(step) + db_session.commit() + + response = client.get("/api/files?storage_provider=dropbox") + assert response.status_code == 200 + data = response.json() + # File with successful dropbox upload step should appear + assert data["pagination"]["total"] >= 1 + + def test_list_files_with_tags_filter(self, client: TestClient, db_session): + """Test filtering by tags (AND logic via ai_metadata).""" + file1 = FileRecord( + filehash="hash1", + original_filename="invoice.pdf", + local_filename="/tmp/invoice.pdf", + file_size=1024, + mime_type="application/pdf", + ai_metadata='{"tags": ["invoice", "finance"]}', + ) + file2 = FileRecord( + filehash="hash2", + original_filename="receipt.pdf", + local_filename="/tmp/receipt.pdf", + file_size=2048, + mime_type="application/pdf", + ai_metadata='{"tags": ["receipt"]}', + ) + db_session.add(file1) + db_session.add(file2) + db_session.commit() + + response = client.get("/api/files?tags=invoice") + assert response.status_code == 200 + data = response.json() + assert data["pagination"]["total"] == 1 + assert data["files"][0]["original_filename"] == "invoice.pdf" + + def test_list_files_with_multiple_tags_and_logic(self, client: TestClient, db_session): + """Test that multiple tags use AND logic.""" + file1 = FileRecord( + filehash="hash1", + original_filename="both.pdf", + local_filename="/tmp/both.pdf", + file_size=1024, + mime_type="application/pdf", + ai_metadata='{"tags": ["invoice", "finance"]}', + ) + file2 = FileRecord( + filehash="hash2", + original_filename="one.pdf", + local_filename="/tmp/one.pdf", + file_size=2048, + mime_type="application/pdf", + ai_metadata='{"tags": ["invoice"]}', + ) + db_session.add(file1) + db_session.add(file2) + db_session.commit() + + response = client.get("/api/files?tags=invoice,finance") + assert response.status_code == 200 + data = response.json() + # Only file1 has both tags + assert data["pagination"]["total"] == 1 + assert data["files"][0]["original_filename"] == "both.pdf" + + def test_list_files_pagination_next_previous_urls(self, client: TestClient, db_session): + """Test that next/previous URLs are present in pagination when applicable.""" + for i in range(5): + db_session.add( + FileRecord( + filehash=f"hash{i}", + original_filename=f"file{i}.pdf", + local_filename=f"/tmp/file{i}.pdf", + file_size=100, + mime_type="application/pdf", + ) + ) + db_session.commit() + + response = client.get("/api/files?page=1&per_page=2") + assert response.status_code == 200 + data = response.json() + assert data["pagination"]["next"] is not None + assert data["pagination"]["previous"] is None + + response2 = client.get("/api/files?page=2&per_page=2") + assert response2.status_code == 200 + data2 = response2.json() + assert data2["pagination"]["previous"] is not None + + +@pytest.mark.unit +class TestBulkReprocessCloudOCR: + """Tests for POST /api/files/bulk-reprocess-cloud-ocr endpoint.""" + + @patch("app.tasks.process_document.process_document.delay") + def test_bulk_reprocess_cloud_ocr_success(self, mock_delay, client: TestClient, db_session, tmp_path): + """Test bulk reprocessing with cloud OCR.""" + file_path = tmp_path / "test.pdf" + file_path.write_bytes(b"%PDF-1.4") + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename=str(file_path), + original_file_path=str(file_path), + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + mock_task = Mock() + mock_task.id = "task-ocr" + mock_delay.return_value = mock_task + + response = client.post("/api/files/bulk-reprocess-cloud-ocr", json=[file.id]) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert len(data["processed_files"]) == 1 + assert mock_delay.called + # Verify force_cloud_ocr=True was passed + _, kwargs = mock_delay.call_args + assert kwargs.get("force_cloud_ocr") is True + + @patch("app.tasks.process_document.process_document.delay") + def test_bulk_reprocess_cloud_ocr_uses_local_filename_fallback( + self, mock_delay, client: TestClient, db_session, tmp_path + ): + """Test cloud OCR bulk reprocess falls back to local_filename when original_file_path missing.""" + file_path = tmp_path / "test.pdf" + file_path.write_bytes(b"%PDF-1.4") + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename=str(file_path), + original_file_path=None, + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + mock_task = Mock() + mock_task.id = "task-ocr" + mock_delay.return_value = mock_task + + response = client.post("/api/files/bulk-reprocess-cloud-ocr", json=[file.id]) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + + def test_bulk_reprocess_cloud_ocr_no_file_on_disk(self, client: TestClient, db_session): + """Test bulk cloud OCR when no file exists on disk.""" + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/nonexistent/test.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + response = client.post("/api/files/bulk-reprocess-cloud-ocr", json=[file.id]) + assert response.status_code == 200 + data = response.json() + assert len(data["errors"]) == 1 + assert "not found" in data["errors"][0]["error"].lower() + + def test_bulk_reprocess_cloud_ocr_no_files_found(self, client: TestClient, db_session): + """Test bulk cloud OCR with non-existent IDs.""" + response = client.post("/api/files/bulk-reprocess-cloud-ocr", json=[99999]) + assert response.status_code == 404 + + @patch("app.tasks.process_document.process_document.delay") + def test_bulk_reprocess_cloud_ocr_task_error_collected(self, mock_delay, client: TestClient, db_session, tmp_path): + """Test that task errors are collected instead of raising.""" + file_path = tmp_path / "test.pdf" + file_path.write_bytes(b"%PDF-1.4") + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename=str(file_path), + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + mock_delay.side_effect = Exception("Task broker error") + + response = client.post("/api/files/bulk-reprocess-cloud-ocr", json=[file.id]) + assert response.status_code == 200 + data = response.json() + assert len(data["errors"]) == 1 + + +@pytest.mark.unit +class TestBulkDownloadFiles: + """Tests for POST /api/files/bulk-download endpoint.""" + + def test_bulk_download_success(self, client: TestClient, db_session, tmp_path): + """Test bulk download creates a ZIP archive.""" + file_path = tmp_path / "test.pdf" + file_path.write_bytes(b"%PDF-1.4 test content") + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename=str(file_path), + processed_file_path=str(file_path), + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + response = client.post("/api/files/bulk-download", json=[file.id]) + assert response.status_code == 200 + assert response.headers["content-type"] == "application/zip" + assert "attachment" in response.headers["content-disposition"] + assert ".zip" in response.headers["content-disposition"] + + def test_bulk_download_prefers_processed_file(self, client: TestClient, db_session, tmp_path): + """Test that bulk download prefers processed file over original.""" + original_path = tmp_path / "original.pdf" + original_path.write_bytes(b"%PDF-1.4 original") + processed_dir = tmp_path / "processed" + processed_dir.mkdir() + processed_path = processed_dir / "processed.pdf" + processed_path.write_bytes(b"%PDF-1.4 processed") + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename=str(original_path), + processed_file_path=str(processed_path), + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + response = client.post("/api/files/bulk-download", json=[file.id]) + assert response.status_code == 200 + + def test_bulk_download_no_files_found(self, client: TestClient, db_session): + """Test bulk download with non-existent file IDs.""" + response = client.post("/api/files/bulk-download", json=[99999]) + assert response.status_code == 404 + + def test_bulk_download_all_files_missing_on_disk(self, client: TestClient, db_session): + """Test bulk download when none of the files are on disk.""" + file = FileRecord( + filehash="hash1", + original_filename="missing.pdf", + local_filename="/nonexistent/missing.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + response = client.post("/api/files/bulk-download", json=[file.id]) + assert response.status_code == 404 + assert "none of the selected files could be found on disk" in response.json()["detail"].lower() + + def test_bulk_download_deduplicates_filenames(self, client: TestClient, db_session, tmp_path): + """Test that duplicate filenames are made unique in the ZIP.""" + file_path1 = tmp_path / "dup1.pdf" + file_path1.write_bytes(b"%PDF-1.4 first") + file_path2 = tmp_path / "dup2.pdf" + file_path2.write_bytes(b"%PDF-1.4 second") + + file1 = FileRecord( + filehash="hash1", + original_filename="duplicate.pdf", + local_filename=str(file_path1), + file_size=1024, + mime_type="application/pdf", + ) + file2 = FileRecord( + filehash="hash2", + original_filename="duplicate.pdf", + local_filename=str(file_path2), + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file1) + db_session.add(file2) + db_session.commit() + + response = client.post("/api/files/bulk-download", json=[file1.id, file2.id]) + assert response.status_code == 200 + + def test_bulk_download_falls_back_to_local_filename(self, client: TestClient, db_session, tmp_path): + """Test that bulk download falls back to local_filename when processed_file_path is missing.""" + file_path = tmp_path / "local.pdf" + file_path.write_bytes(b"%PDF-1.4 local") + + file = FileRecord( + filehash="hash1", + original_filename="local.pdf", + local_filename=str(file_path), + processed_file_path=None, + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + response = client.post("/api/files/bulk-download", json=[file.id]) + assert response.status_code == 200 + + def test_bulk_download_uses_hash_based_path(self, client: TestClient, db_session, tmp_path): + """Test bulk download finds file via hash-based path in processed dir.""" + processed_dir = tmp_path / "processed" + processed_dir.mkdir() + hash_file = processed_dir / "abc123.pdf" + hash_file.write_bytes(b"%PDF-1.4 hash-based") + + file = FileRecord( + filehash="abc123", + original_filename="test.pdf", + local_filename="/nonexistent/test.pdf", + processed_file_path=None, + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + with patch("app.api.files.settings") as mock_settings: + mock_settings.workdir = str(tmp_path) + response = client.post("/api/files/bulk-download", json=[file.id]) + assert response.status_code == 200 + + +@pytest.mark.unit +class TestReprocessSingleFileExceptions: + """Test exception handling for reprocess single file endpoint.""" + + @patch("app.tasks.process_document.process_document.delay") + def test_reprocess_single_file_exception_handling(self, mock_delay, client: TestClient, db_session, tmp_path): + """Test that unexpected exceptions are handled and return 500.""" + file_path = tmp_path / "test.pdf" + file_path.write_bytes(b"%PDF-1.4") + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename=str(file_path), + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + mock_delay.side_effect = Exception("Broker unavailable") + + response = client.post(f"/api/files/{file.id}/reprocess") + assert response.status_code == 500 + assert "Error reprocessing file" in response.json()["detail"] + + +@pytest.mark.unit +class TestReprocessWithCloudOCRLocalFileFallback: + """Test cloud OCR reprocess uses local_filename when original_file_path missing.""" + + @patch("app.tasks.process_document.process_document.delay") + def test_cloud_ocr_uses_local_filename_when_original_missing( + self, mock_delay, client: TestClient, db_session, tmp_path + ): + """Test cloud OCR falls back to local_filename when original_file_path doesn't exist.""" + file_path = tmp_path / "local.pdf" + file_path.write_bytes(b"%PDF-1.4") + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename=str(file_path), + original_file_path="/nonexistent/original.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + mock_task = Mock() + mock_task.id = "task-ocr" + mock_delay.return_value = mock_task + + response = client.post(f"/api/files/{file.id}/reprocess-with-cloud-ocr") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["force_cloud_ocr"] is True + + @patch("app.tasks.process_document.process_document.delay") + def test_cloud_ocr_reprocess_exception_handling(self, mock_delay, client: TestClient, db_session, tmp_path): + """Test exception handling in cloud OCR reprocess.""" + file_path = tmp_path / "test.pdf" + file_path.write_bytes(b"%PDF-1.4") + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename=str(file_path), + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + mock_delay.side_effect = Exception("Broker error") + + response = client.post(f"/api/files/{file.id}/reprocess-with-cloud-ocr") + assert response.status_code == 500 + assert "Error reprocessing file" in response.json()["detail"] + + +@pytest.mark.unit +class TestRetryPipelineStepEdgeCases: + """Tests for edge cases in _retry_pipeline_step.""" + + def test_retry_process_document_with_none_local_filename(self, db_session): + """Test that process_document retry raises when local_filename is empty/nonexistent.""" + from app.api.files import _retry_pipeline_step + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/nonexistent/test.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + with pytest.raises(HTTPException) as exc_info: + _retry_pipeline_step(file, "process_document", db_session) + assert exc_info.value.status_code == 400 + assert "not found on disk" in exc_info.value.detail.lower() + + def test_retry_process_with_ocr_with_none_local_filename(self, db_session): + """Test that process_with_ocr retry raises when local_filename doesn't exist.""" + from app.api.files import _retry_pipeline_step + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/nonexistent/test.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + with pytest.raises(HTTPException) as exc_info: + _retry_pipeline_step(file, "process_with_ocr", db_session) + assert exc_info.value.status_code == 400 + + def test_retry_extract_metadata_with_none_local_filename(self, db_session): + """Test that extract_metadata retry raises when local_filename doesn't exist.""" + from app.api.files import _retry_pipeline_step + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/nonexistent/test.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + with pytest.raises(HTTPException) as exc_info: + _retry_pipeline_step(file, "extract_metadata_with_gpt", db_session) + assert exc_info.value.status_code == 400 + + def test_retry_embed_metadata_uses_processed_file_path(self, db_session, tmp_path): + """Test embed_metadata retry uses processed_file_path when local_filename missing.""" + from app.api.files import _retry_pipeline_step + + processed_path = tmp_path / "processed.pdf" + processed_path.write_bytes(b"%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\nstartxref\n0\n%%EOF") + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/nonexistent/test.pdf", + processed_file_path=str(processed_path), + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + with ( + patch("app.tasks.extract_metadata_with_gpt.extract_metadata_with_gpt") as mock_task, + patch("app.api.files._extract_text_from_pdf", return_value="Sample text"), + ): + mock_task.delay.return_value = Mock(id="task-embed") + result = _retry_pipeline_step(file, "embed_metadata_into_pdf", db_session) + assert result["status"] == "success" + + def test_retry_embed_metadata_uses_original_file_path(self, db_session, tmp_path): + """Test embed_metadata retry uses original_file_path when other paths missing.""" + from app.api.files import _retry_pipeline_step + + original_path = tmp_path / "original.pdf" + original_path.write_bytes(b"%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\nstartxref\n0\n%%EOF") + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/nonexistent/local.pdf", + processed_file_path="/nonexistent/processed.pdf", + original_file_path=str(original_path), + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + with ( + patch("app.tasks.extract_metadata_with_gpt.extract_metadata_with_gpt") as mock_task, + patch("app.api.files._extract_text_from_pdf", return_value="Sample text"), + ): + mock_task.delay.return_value = Mock(id="task-embed") + result = _retry_pipeline_step(file, "embed_metadata_into_pdf", db_session) + assert result["status"] == "success" + + def test_retry_embed_metadata_uses_workdir_fallback(self, db_session, tmp_path): + """Test embed_metadata retry uses workdir/tmp fallback path.""" + from app.api.files import _retry_pipeline_step + + workdir_tmp = tmp_path / "tmp" + workdir_tmp.mkdir() + fallback_file = workdir_tmp / "test.pdf" + fallback_file.write_bytes(b"%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\nstartxref\n0\n%%EOF") + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename=str(tmp_path / "tmp" / "test.pdf"), + processed_file_path="/nonexistent/processed.pdf", + original_file_path="/nonexistent/original.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + with ( + patch("app.tasks.extract_metadata_with_gpt.extract_metadata_with_gpt") as mock_task, + patch("app.api.files._extract_text_from_pdf", return_value="Sample text"), + patch("app.api.files.settings") as mock_settings, + ): + mock_settings.workdir = str(tmp_path) + mock_task.delay.return_value = Mock(id="task-embed") + result = _retry_pipeline_step(file, "embed_metadata_into_pdf", db_session) + assert result["status"] == "success" + + def test_retry_embed_metadata_no_file_found(self, db_session): + """Test embed_metadata retry raises when no file path is found.""" + from app.api.files import _retry_pipeline_step + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/nonexistent/local.pdf", + processed_file_path="/nonexistent/processed.pdf", + original_file_path="/nonexistent/original.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + with patch("app.api.files.settings") as mock_settings: + mock_settings.workdir = "/nonexistent_workdir" + with pytest.raises(HTTPException) as exc_info: + _retry_pipeline_step(file, "embed_metadata_into_pdf", db_session) + assert exc_info.value.status_code == 400 + assert "not found" in exc_info.value.detail.lower() + + def test_retry_embed_metadata_none_local_filename(self, db_session): + """Test embed_metadata retry with None local_filename skips local path check.""" + from app.api.files import _retry_pipeline_step + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/nonexistent/local.pdf", + processed_file_path="/nonexistent/processed.pdf", + original_file_path="/nonexistent/original.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + with patch("app.api.files.settings") as mock_settings: + mock_settings.workdir = "/nonexistent_workdir" + with pytest.raises(HTTPException) as exc_info: + _retry_pipeline_step(file, "embed_metadata_into_pdf", db_session) + assert exc_info.value.status_code == 400 + + +@pytest.mark.unit +class TestRetrySubtaskEdgeCases: + """Tests for edge cases in retry-subtask endpoint.""" + + def test_retry_subtask_exception_handling(self, client: TestClient, db_session, tmp_path): + """Test that unexpected exceptions return 500.""" + processed_dir = tmp_path / "processed" + processed_dir.mkdir() + processed_file = processed_dir / "hash1.pdf" + processed_file.write_bytes(b"%PDF-1.4") + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/tmp/test.pdf", + processed_file_path=str(processed_file), + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + with patch("app.tasks.upload_to_dropbox.upload_to_dropbox") as mock_task: + mock_task.delay.side_effect = Exception("Broker down") + response = client.post(f"/api/files/{file.id}/retry-subtask?subtask_name=upload_to_dropbox") + assert response.status_code == 500 + assert "Error retrying subtask" in response.json()["detail"] + + def test_retry_subtask_finds_file_by_legacy_filename_pattern(self, client: TestClient, db_session, tmp_path): + """Test retry-subtask finds processed file by legacy _processed suffix.""" + processed_dir = tmp_path / "processed" + processed_dir.mkdir() + processed_file = processed_dir / "test_processed.pdf" + processed_file.write_bytes(b"%PDF-1.4") + + file = FileRecord( + filehash="notfound", + original_filename="test.pdf", + local_filename="/tmp/test.pdf", + processed_file_path=None, + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + with ( + patch("app.api.files.settings") as mock_settings, + patch("app.tasks.upload_to_nextcloud.upload_to_nextcloud") as mock_task, + ): + mock_settings.workdir = str(tmp_path) + mock_task.delay.return_value = Mock(id="task-nc") + response = client.post(f"/api/files/{file.id}/retry-subtask?subtask_name=upload_to_nextcloud") + assert response.status_code == 200 + + def test_retry_subtask_finds_file_by_original_filename_in_processed_dir( + self, client: TestClient, db_session, tmp_path + ): + """Test retry-subtask finds processed file by original filename in processed dir.""" + processed_dir = tmp_path / "processed" + processed_dir.mkdir() + processed_file = processed_dir / "test.pdf" + processed_file.write_bytes(b"%PDF-1.4") + + file = FileRecord( + filehash="notfound", + original_filename="test.pdf", + local_filename="/tmp/test.pdf", + processed_file_path=None, + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + with ( + patch("app.api.files.settings") as mock_settings, + patch("app.tasks.upload_to_s3.upload_to_s3") as mock_task, + ): + mock_settings.workdir = str(tmp_path) + mock_task.delay.return_value = Mock(id="task-s3") + response = client.post(f"/api/files/{file.id}/retry-subtask?subtask_name=upload_to_s3") + assert response.status_code == 200 + + +@pytest.mark.unit +class TestFilePreviewEdgeCases: + """Tests for additional edge cases in file preview endpoint.""" + + def test_preview_processed_file_missing_returns_404(self, client: TestClient, db_session, tmp_path): + """Test preview of processed file returns 404 when file not on disk.""" + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/nonexistent/test.pdf", + processed_file_path="/nonexistent/processed.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + with patch("app.api.files.settings") as mock_settings: + mock_settings.workdir = "/nonexistent_workdir" + response = client.get(f"/api/files/{file.id}/preview?version=processed") + assert response.status_code == 404 + assert "processed file not found" in response.json()["detail"].lower() + + def test_preview_uses_original_file_path_when_available(self, client: TestClient, db_session, tmp_path): + """Test preview prefers original_file_path over local_filename.""" + original_path = tmp_path / "original.pdf" + original_path.write_bytes(b"%PDF-1.4 original") + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/nonexistent/local.pdf", + original_file_path=str(original_path), + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + response = client.get(f"/api/files/{file.id}/preview?version=original") + assert response.status_code == 200 + + +@pytest.mark.unit +class TestDownloadFileEdgeCases: + """Tests for additional edge cases in download endpoint.""" + + def test_download_processed_file_not_found_returns_404(self, client: TestClient, db_session, tmp_path): + """Test download of processed file returns 404 when not found.""" + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/nonexistent/test.pdf", + processed_file_path="/nonexistent/processed.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + with patch("app.api.files.settings") as mock_settings: + mock_settings.workdir = "/nonexistent_workdir" + response = client.get(f"/api/files/{file.id}/download?version=processed") + assert response.status_code == 404 + assert "processed file not found" in response.json()["detail"].lower() + + def test_download_original_uses_original_file_path(self, client: TestClient, db_session, tmp_path): + """Test download original prefers original_file_path over local_filename.""" + original_path = tmp_path / "original.pdf" + original_path.write_bytes(b"%PDF-1.4 original") + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/nonexistent/local.pdf", + original_file_path=str(original_path), + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + response = client.get(f"/api/files/{file.id}/download?version=original") + assert response.status_code == 200 + assert "attachment" in response.headers["content-disposition"] + + def test_download_original_not_found_returns_404(self, client: TestClient, db_session): + """Test download original returns 404 when file not on disk.""" + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/nonexistent/test.pdf", + original_file_path="/nonexistent/original.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + response = client.get(f"/api/files/{file.id}/download?version=original") + assert response.status_code == 404 + + +@pytest.mark.unit +class TestSaveUploadFileChunks: + """Tests for _save_upload_file_chunks helper.""" + + @pytest.mark.asyncio + async def test_save_upload_file_chunks_success(self, tmp_path): + """Test successful file save.""" + from unittest.mock import AsyncMock + + from app.api.files import _save_upload_file_chunks + + target_path = str(tmp_path / "uploaded.bin") + mock_file = AsyncMock() + mock_file.read = AsyncMock(side_effect=[b"chunk1", b"chunk2", b""]) + + size = await _save_upload_file_chunks(mock_file, target_path, max_size=1024) + assert size == 12 + with open(target_path, "rb") as f: + content = f.read() + assert content == b"chunk1chunk2" + + @pytest.mark.asyncio + async def test_save_upload_file_chunks_exceeds_max_size(self, tmp_path): + """Test that exceeding max size raises 413.""" + from unittest.mock import AsyncMock + + from app.api.files import _save_upload_file_chunks + + target_path = str(tmp_path / "uploaded.bin") + mock_file = AsyncMock() + # First chunk is 10 bytes, max is 5 + mock_file.read = AsyncMock(side_effect=[b"0123456789", b""]) + + with pytest.raises(HTTPException) as exc_info: + await _save_upload_file_chunks(mock_file, target_path, max_size=5) + assert exc_info.value.status_code == 413 + # File should be cleaned up + assert not os.path.exists(target_path) + + @pytest.mark.asyncio + async def test_save_upload_file_chunks_io_error(self, tmp_path): + """Test that IO errors raise 500.""" + from unittest.mock import AsyncMock + + from app.api.files import _save_upload_file_chunks + + target_path = "/nonexistent_dir/uploaded.bin" + mock_file = AsyncMock() + mock_file.read = AsyncMock(return_value=b"data") + + with pytest.raises(HTTPException) as exc_info: + await _save_upload_file_chunks(mock_file, target_path, max_size=1024) + assert exc_info.value.status_code == 500 + + +@pytest.mark.unit +class TestCheckForExactDuplicate: + """Tests for _check_for_exact_duplicate helper.""" + + def test_check_duplicate_deduplication_disabled(self, db_session, tmp_path): + """Test that duplicate check returns None when deduplication disabled.""" + from app.api.files import _check_for_exact_duplicate + + test_file = tmp_path / "test.pdf" + test_file.write_bytes(b"%PDF-1.4") + + with patch("app.api.files.settings") as mock_settings: + mock_settings.enable_deduplication = False + result = _check_for_exact_duplicate(db_session, str(test_file), "test.pdf") + assert result is None + + def test_check_duplicate_no_match(self, db_session, tmp_path): + """Test that duplicate check returns None when no matching hash.""" + from app.api.files import _check_for_exact_duplicate + + test_file = tmp_path / "test.pdf" + test_file.write_bytes(b"%PDF-1.4 unique content") + + with patch("app.api.files.settings") as mock_settings: + mock_settings.enable_deduplication = True + result = _check_for_exact_duplicate(db_session, str(test_file), "test.pdf") + assert result is None + + def test_check_duplicate_finds_exact_match(self, db_session, tmp_path): + """Test that duplicate check returns info when exact match found.""" + from app.api.files import _check_for_exact_duplicate + from app.utils.file_operations import hash_file + + test_file = tmp_path / "test.pdf" + test_file.write_bytes(b"%PDF-1.4 exact content") + filehash = hash_file(str(test_file)) + + existing = FileRecord( + filehash=filehash, + original_filename="existing.pdf", + local_filename=str(test_file), + file_size=1024, + mime_type="application/pdf", + is_duplicate=False, + ) + db_session.add(existing) + db_session.commit() + + with patch("app.api.files.settings") as mock_settings: + mock_settings.enable_deduplication = True + result = _check_for_exact_duplicate(db_session, str(test_file), "test.pdf") + assert result is not None + assert result["duplicate_type"] == "exact" + assert result["original_file_id"] == existing.id + + def test_check_duplicate_hash_error_returns_none(self, db_session, tmp_path): + """Test that hash errors return None (graceful fallback).""" + from app.api.files import _check_for_exact_duplicate + + with ( + patch("app.api.files.settings") as mock_settings, + patch("app.api.files.hash_file", side_effect=Exception("Hash error")), + ): + mock_settings.enable_deduplication = True + result = _check_for_exact_duplicate(db_session, "/nonexistent.pdf", "test.pdf") + assert result is None + + +@pytest.mark.unit +class TestUIUploadEdgeCases: + """Tests for additional UI upload edge cases.""" + + @patch("app.config.settings.max_upload_size", 1024) + def test_ui_upload_content_length_too_large(self, client: TestClient, tmp_path): + """Test upload rejected early when Content-Length exceeds max.""" + with patch("app.config.settings.workdir", str(tmp_path)): + response = client.post( + "/api/ui-upload", + files={"file": ("test.pdf", b"%PDF-1.4", "application/pdf")}, + headers={"Content-Length": "99999"}, + ) + assert response.status_code == 413 + assert "too large" in response.json()["detail"].lower() + + @patch("app.config.settings.max_upload_size", 10485760) + def test_ui_upload_malformed_content_length_proceeds(self, client: TestClient, tmp_path): + """Test upload proceeds normally with malformed Content-Length header.""" + with ( + patch("app.config.settings.workdir", str(tmp_path)), + patch("app.tasks.process_document.process_document.delay") as mock_delay, + ): + mock_task = Mock() + mock_task.id = "task123" + mock_delay.return_value = mock_task + + response = client.post( + "/api/ui-upload", + files={"file": ("test.pdf", b"%PDF-1.4 content", "application/pdf")}, + headers={"Content-Length": "not-a-number"}, + ) + assert response.status_code == 200 + + @patch("app.config.settings.max_upload_size", 10485760) + def test_ui_upload_file_without_extension(self, client: TestClient, tmp_path): + """Test upload of file without an extension.""" + with ( + patch("app.config.settings.workdir", str(tmp_path)), + patch("app.api.files.convert_to_pdf") as mock_convert, + ): + mock_task = Mock() + mock_task.id = "task123" + mock_convert.delay = Mock(return_value=mock_task) + + response = client.post( + "/api/ui-upload", + files={"file": ("nodotfile", b"raw content", "application/octet-stream")}, + ) + assert response.status_code == 200 + data = response.json() + assert "task_id" in data + + @patch("app.config.settings.max_upload_size", 10485760) + @patch("app.config.settings.enable_deduplication", True) + def test_ui_upload_exact_duplicate_returns_duplicate_status(self, client: TestClient, db_session, tmp_path): + """Test that an exact duplicate upload returns duplicate status.""" + from app.utils.file_operations import hash_file + + # Create a file and compute its hash + file_content = b"%PDF-1.4 duplicate content here 12345678901234567890" + # First write to compute hash + tmp_file = tmp_path / "original.pdf" + tmp_file.write_bytes(file_content) + filehash = hash_file(str(tmp_file)) + + existing = FileRecord( + filehash=filehash, + original_filename="original.pdf", + local_filename=str(tmp_file), + file_size=len(file_content), + mime_type="application/pdf", + is_duplicate=False, + ) + db_session.add(existing) + db_session.commit() + + with patch("app.config.settings.workdir", str(tmp_path)): + response = client.post( + "/api/ui-upload", + files={"file": ("duplicate.pdf", file_content, "application/pdf")}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "duplicate" + assert "duplicate_of" in data + + @patch("app.config.settings.max_upload_size", 10485760) + def test_ui_upload_unsupported_mime_type_still_queues(self, client: TestClient, tmp_path): + """Test that unsupported MIME types are still queued via convert_to_pdf.""" + with ( + patch("app.config.settings.workdir", str(tmp_path)), + patch("app.api.files.convert_to_pdf") as mock_convert, + ): + mock_task = Mock() + mock_task.id = "task-unknown" + mock_convert.delay = Mock(return_value=mock_task) + + response = client.post( + "/api/ui-upload", + files={"file": ("data.xyz", b"raw data", "application/x-unknown-type")}, + ) + assert response.status_code == 200 + data = response.json() + assert "task_id" in data + + +@pytest.mark.unit +class TestClaimFile: + """Tests for POST /api/files/{file_id}/claim endpoint.""" + + def test_claim_file_multi_user_disabled(self, client: TestClient, db_session): + """Test claim returns 400 when multi-user mode is disabled.""" + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/tmp/test.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + with patch("app.api.files.settings") as mock_settings: + mock_settings.multi_user_enabled = False + response = client.post(f"/api/files/{file.id}/claim") + assert response.status_code == 400 + assert "multi-user mode" in response.json()["detail"].lower() + + def test_claim_file_no_owner_id(self, client: TestClient, db_session): + """Test claim returns 401 when no owner_id is available.""" + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/tmp/test.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + with ( + patch("app.api.files.settings") as mock_settings, + patch("app.api.files.get_current_owner_id", return_value=None), + ): + mock_settings.multi_user_enabled = True + response = client.post(f"/api/files/{file.id}/claim") + assert response.status_code == 401 + + def test_claim_file_not_found(self, client: TestClient, db_session): + """Test claim returns 404 for non-existent file.""" + with ( + patch("app.api.files.settings") as mock_settings, + patch("app.api.files.get_current_owner_id", return_value="user123"), + ): + mock_settings.multi_user_enabled = True + response = client.post("/api/files/99999/claim") + assert response.status_code == 404 + + def test_claim_file_already_owned_by_same_user(self, client: TestClient, db_session): + """Test claim returns already_owned when user already owns the file.""" + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/tmp/test.pdf", + file_size=1024, + mime_type="application/pdf", + owner_id="user123", + ) + db_session.add(file) + db_session.commit() + + with ( + patch("app.api.files.settings") as mock_settings, + patch("app.api.files.get_current_owner_id", return_value="user123"), + ): + mock_settings.multi_user_enabled = True + response = client.post(f"/api/files/{file.id}/claim") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "already_owned" + + def test_claim_file_owned_by_another_user(self, client: TestClient, db_session): + """Test claim returns 403 when file is owned by another user.""" + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/tmp/test.pdf", + file_size=1024, + mime_type="application/pdf", + owner_id="other_user", + ) + db_session.add(file) + db_session.commit() + + with ( + patch("app.api.files.settings") as mock_settings, + patch("app.api.files.get_current_owner_id", return_value="user123"), + ): + mock_settings.multi_user_enabled = True + response = client.post(f"/api/files/{file.id}/claim") + assert response.status_code == 403 + + def test_claim_file_success(self, client: TestClient, db_session): + """Test successful file claim by a user.""" + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/tmp/test.pdf", + file_size=1024, + mime_type="application/pdf", + owner_id=None, + ) + db_session.add(file) + db_session.commit() + + with ( + patch("app.api.files.settings") as mock_settings, + patch("app.api.files.get_current_owner_id", return_value="user123"), + ): + mock_settings.multi_user_enabled = True + response = client.post(f"/api/files/{file.id}/claim") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["owner_id"] == "user123" + + def test_claim_file_db_error_returns_500(self, client: TestClient, db_session): + """Test claim returns 500 on database commit error.""" + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/tmp/test.pdf", + file_size=1024, + mime_type="application/pdf", + owner_id=None, + ) + db_session.add(file) + db_session.commit() + + with ( + patch("app.api.files.settings") as mock_settings, + patch("app.api.files.get_current_owner_id", return_value="user123"), + patch.object(db_session, "commit", side_effect=Exception("DB error")), + ): + mock_settings.multi_user_enabled = True + response = client.post(f"/api/files/{file.id}/claim") + assert response.status_code == 500 + + +@pytest.mark.unit +class TestBulkClaimFiles: + """Tests for POST /api/files/bulk-claim endpoint.""" + + def test_bulk_claim_multi_user_disabled(self, client: TestClient, db_session): + """Test bulk claim returns 400 when multi-user mode is disabled.""" + with patch("app.api.files.settings") as mock_settings: + mock_settings.multi_user_enabled = False + response = client.post("/api/files/bulk-claim", json=[1, 2]) + assert response.status_code == 400 + + def test_bulk_claim_no_owner_id(self, client: TestClient, db_session): + """Test bulk claim returns 401 when no owner_id.""" + with ( + patch("app.api.files.settings") as mock_settings, + patch("app.api.files.get_current_owner_id", return_value=None), + ): + mock_settings.multi_user_enabled = True + response = client.post("/api/files/bulk-claim", json=[1, 2]) + assert response.status_code == 401 + + def test_bulk_claim_no_files_found(self, client: TestClient, db_session): + """Test bulk claim returns 404 when no files found.""" + with ( + patch("app.api.files.settings") as mock_settings, + patch("app.api.files.get_current_owner_id", return_value="user123"), + ): + mock_settings.multi_user_enabled = True + response = client.post("/api/files/bulk-claim", json=[99999]) + assert response.status_code == 404 + + def test_bulk_claim_success_unowned_files(self, client: TestClient, db_session): + """Test successful bulk claim of unowned files.""" + file1 = FileRecord( + filehash="hash1", + original_filename="test1.pdf", + local_filename="/tmp/test1.pdf", + file_size=1024, + mime_type="application/pdf", + owner_id=None, + ) + file2 = FileRecord( + filehash="hash2", + original_filename="test2.pdf", + local_filename="/tmp/test2.pdf", + file_size=1024, + mime_type="application/pdf", + owner_id=None, + ) + db_session.add(file1) + db_session.add(file2) + db_session.commit() + + with ( + patch("app.api.files.settings") as mock_settings, + patch("app.api.files.get_current_owner_id", return_value="user123"), + ): + mock_settings.multi_user_enabled = True + response = client.post("/api/files/bulk-claim", json=[file1.id, file2.id]) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["claimed_count"] == 2 + assert data["owner_id"] == "user123" + + def test_bulk_claim_skips_already_owned_files(self, client: TestClient, db_session): + """Test bulk claim skips files already owned.""" + file1 = FileRecord( + filehash="hash1", + original_filename="test1.pdf", + local_filename="/tmp/test1.pdf", + file_size=1024, + mime_type="application/pdf", + owner_id="other_user", + ) + file2 = FileRecord( + filehash="hash2", + original_filename="test2.pdf", + local_filename="/tmp/test2.pdf", + file_size=1024, + mime_type="application/pdf", + owner_id=None, + ) + db_session.add(file1) + db_session.add(file2) + db_session.commit() + + with ( + patch("app.api.files.settings") as mock_settings, + patch("app.api.files.get_current_owner_id", return_value="user123"), + ): + mock_settings.multi_user_enabled = True + response = client.post("/api/files/bulk-claim", json=[file1.id, file2.id]) + assert response.status_code == 200 + data = response.json() + assert data["claimed_count"] == 1 + assert len(data["skipped"]) == 1 + + def test_bulk_claim_db_error_returns_500(self, client: TestClient, db_session): + """Test bulk claim returns 500 on database error.""" + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/tmp/test.pdf", + file_size=1024, + mime_type="application/pdf", + owner_id=None, + ) + db_session.add(file) + db_session.commit() + + with ( + patch("app.api.files.settings") as mock_settings, + patch("app.api.files.get_current_owner_id", return_value="user123"), + patch.object(db_session, "commit", side_effect=Exception("DB error")), + ): + mock_settings.multi_user_enabled = True + response = client.post("/api/files/bulk-claim", json=[file.id]) + assert response.status_code == 500 + + +@pytest.mark.unit +class TestAssignOwner: + """Tests for POST /api/files/assign-owner endpoint.""" + + def _make_admin_client(self, client: TestClient): + """Set up admin session for client requests.""" + client.cookies.clear() + return client + + def test_assign_owner_multi_user_disabled(self, client: TestClient, db_session): + """Test assign-owner returns 400 when multi-user mode is disabled.""" + with patch("app.api.files.settings") as mock_settings: + mock_settings.multi_user_enabled = False + response = client.post("/api/files/assign-owner?owner_id=user123") + assert response.status_code == 400 + + def test_assign_owner_not_admin(self, client: TestClient, db_session): + """Test assign-owner returns 403 when user is not admin.""" + with patch("app.api.files.settings") as mock_settings: + mock_settings.multi_user_enabled = True + response = client.post("/api/files/assign-owner?owner_id=user123") + assert response.status_code == 403 + assert "admin" in response.json()["detail"].lower() + + def test_assign_owner_empty_owner_id(self, client: TestClient, db_session): + """Test assign-owner returns 422 for empty owner_id.""" + with ( + patch("app.api.files.settings") as mock_settings, + patch( + "app.api.files.Request.session", + new_callable=lambda: property(lambda self: {"user": {"is_admin": True}}), + ), + ): + mock_settings.multi_user_enabled = True + response = client.post("/api/files/assign-owner?owner_id=") + # Either 422 or 403 depending on auth check order + assert response.status_code in [422, 403] + + def test_assign_owner_success_all_unowned(self, client: TestClient, db_session): + """Test assign-owner assigns all unowned files.""" + file1 = FileRecord( + filehash="hash1", + original_filename="test1.pdf", + local_filename="/tmp/test1.pdf", + file_size=1024, + mime_type="application/pdf", + owner_id=None, + ) + file2 = FileRecord( + filehash="hash2", + original_filename="test2.pdf", + local_filename="/tmp/test2.pdf", + file_size=1024, + mime_type="application/pdf", + owner_id=None, + ) + db_session.add(file1) + db_session.add(file2) + db_session.commit() + + with ( + patch("app.api.files.settings") as mock_settings, + patch( + "app.api.files.Request.session", + new_callable=lambda: property(lambda self: {"user": {"is_admin": True}}), + ), + ): + mock_settings.multi_user_enabled = True + response = client.post("/api/files/assign-owner?owner_id=newowner") + assert response.status_code in [200, 403] + + def test_assign_owner_success_specific_file_ids(self, client: TestClient, db_session): + """Test assign-owner assigns specific file IDs.""" + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/tmp/test.pdf", + file_size=1024, + mime_type="application/pdf", + owner_id=None, + ) + db_session.add(file) + db_session.commit() + + # Use session override approach + with patch("app.api.files.settings") as mock_settings: + mock_settings.multi_user_enabled = True + # Test that non-admin gets 403 + response = client.post(f"/api/files/assign-owner?owner_id=newowner&file_ids={file.id}") + assert response.status_code in [200, 403] + + +@pytest.mark.unit +class TestAssignOwnerAdminFull: + """Full tests for assign-owner with admin session via session fixture.""" + + def test_assign_owner_all_unowned_with_admin_session(self, client: TestClient, db_session): + """Test full assign-owner flow with admin session.""" + + file1 = FileRecord( + filehash="hash1", + original_filename="test1.pdf", + local_filename="/tmp/test1.pdf", + file_size=1024, + mime_type="application/pdf", + owner_id=None, + ) + file2 = FileRecord( + filehash="hash2", + original_filename="test2.pdf", + local_filename="/tmp/test2.pdf", + file_size=1024, + mime_type="application/pdf", + owner_id=None, + ) + db_session.add(file1) + db_session.add(file2) + db_session.commit() + + # Patch request.session at the ASGI level by using middleware patch + with ( + patch("app.api.files.settings") as mock_settings, + patch( + "starlette.requests.Request.session", + new_callable=lambda: property(lambda self: {"user": {"is_admin": True}}), + ), + ): + mock_settings.multi_user_enabled = True + response = client.post("/api/files/assign-owner?owner_id=newowner") + assert response.status_code == 200 + data = response.json() + assert data["updated_count"] == 2 + + def test_assign_owner_specific_file_ids_with_admin_session(self, client: TestClient, db_session): + """Test assign-owner with specific file_ids and admin session.""" + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/tmp/test.pdf", + file_size=1024, + mime_type="application/pdf", + owner_id=None, + ) + db_session.add(file) + db_session.commit() + + with ( + patch("app.api.files.settings") as mock_settings, + patch( + "starlette.requests.Request.session", + new_callable=lambda: property(lambda self: {"user": {"is_admin": True}}), + ), + ): + mock_settings.multi_user_enabled = True + response = client.post(f"/api/files/assign-owner?owner_id=newowner&file_ids={file.id}") + assert response.status_code == 200 + data = response.json() + assert data["updated_count"] == 1 + + def test_assign_owner_empty_owner_id_returns_422_with_admin(self, client: TestClient, db_session): + """Test assign-owner returns 422 for empty owner_id even with admin session.""" + with ( + patch("app.api.files.settings") as mock_settings, + patch( + "starlette.requests.Request.session", + new_callable=lambda: property(lambda self: {"user": {"is_admin": True}}), + ), + ): + mock_settings.multi_user_enabled = True + response = client.post("/api/files/assign-owner?owner_id= ") + assert response.status_code == 422 + + def test_assign_owner_db_error_returns_500_with_admin(self, client: TestClient, db_session): + """Test assign-owner returns 500 on DB error with admin session.""" + with ( + patch("app.api.files.settings") as mock_settings, + patch( + "starlette.requests.Request.session", + new_callable=lambda: property(lambda self: {"user": {"is_admin": True}}), + ), + patch.object(db_session, "commit", side_effect=Exception("DB error")), + ): + mock_settings.multi_user_enabled = True + response = client.post("/api/files/assign-owner?owner_id=newowner") + assert response.status_code == 500 + + +@pytest.mark.unit +class TestAssignPipelineToFile: + """Tests for POST /api/files/{file_id}/assign-pipeline endpoint.""" + + def test_assign_pipeline_file_not_found(self, client: TestClient, db_session): + """Test assign-pipeline returns 404 for non-existent file.""" + response = client.post("/api/files/99999/assign-pipeline") + assert response.status_code == 404 + + def test_assign_pipeline_clear_pipeline(self, client: TestClient, db_session): + """Test assign-pipeline clears pipeline when pipeline_id is None.""" + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/tmp/test.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + response = client.post(f"/api/files/{file.id}/assign-pipeline") + assert response.status_code == 200 + data = response.json() + assert data["file_id"] == file.id + assert data["pipeline_id"] is None + + def test_assign_pipeline_with_valid_pipeline(self, client: TestClient, db_session): + """Test assign-pipeline successfully assigns a pipeline.""" + from app.models import Pipeline + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/tmp/test.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + + pipeline = Pipeline( + name="Test Pipeline", + owner_id=None, + is_default=False, + ) + db_session.add(pipeline) + db_session.commit() + + response = client.post(f"/api/files/{file.id}/assign-pipeline?pipeline_id={pipeline.id}") + assert response.status_code == 200 + data = response.json() + assert data["pipeline_id"] == pipeline.id + + def test_assign_pipeline_with_nonexistent_pipeline(self, client: TestClient, db_session): + """Test assign-pipeline returns 404 for non-existent pipeline.""" + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/tmp/test.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + response = client.post(f"/api/files/{file.id}/assign-pipeline?pipeline_id=99999") + assert response.status_code == 404 + assert "pipeline not found" in response.json()["detail"].lower() + + def test_assign_pipeline_non_admin_cannot_assign_others_pipeline(self, client: TestClient, db_session): + """Test non-admin cannot assign another user's pipeline.""" + from app.models import Pipeline + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/tmp/test.pdf", + file_size=1024, + mime_type="application/pdf", + ) + pipeline = Pipeline( + name="Other Pipeline", + owner_id="other_user", + is_default=False, + ) + db_session.add(file) + db_session.add(pipeline) + db_session.commit() + + with ( + patch("app.auth.get_current_user", return_value={"is_admin": False}), + patch("app.auth.get_current_user_id", return_value="user123"), + patch("app.api.files.get_current_owner_id", return_value="user123"), + ): + response = client.post(f"/api/files/{file.id}/assign-pipeline?pipeline_id={pipeline.id}") + assert response.status_code == 404 + + def test_assign_pipeline_db_error_returns_500(self, client: TestClient, db_session): + """Test assign-pipeline returns 500 on database error.""" + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/tmp/test.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + with patch.object(db_session, "commit", side_effect=Exception("DB error")): + response = client.post(f"/api/files/{file.id}/assign-pipeline") + assert response.status_code == 500 + + +@pytest.mark.unit +class TestRetryPipelineStepEmptyLocalFilename: + """Tests for _retry_pipeline_step with empty-string local_filename (falsy but not null).""" + + def test_retry_process_document_empty_local_filename(self, db_session): + """Test process_document retry with empty-string local_filename raises 400.""" + from unittest.mock import MagicMock + + from app.api.files import _retry_pipeline_step + + # Create a mock FileRecord to avoid DB NOT NULL constraint while testing empty string + mock_file = MagicMock(spec=FileRecord) + mock_file.id = 1 + mock_file.local_filename = "" + mock_file.original_filename = "test.pdf" + + with pytest.raises(HTTPException) as exc_info: + _retry_pipeline_step(mock_file, "process_document", db_session) + assert exc_info.value.status_code == 400 + assert "Local file path is None. Cannot retry." in exc_info.value.detail + + def test_retry_process_with_ocr_empty_local_filename(self, db_session): + """Test process_with_ocr retry with empty-string local_filename raises 400.""" + from unittest.mock import MagicMock + + from app.api.files import _retry_pipeline_step + + mock_file = MagicMock(spec=FileRecord) + mock_file.id = 1 + mock_file.local_filename = "" + mock_file.original_filename = "test.pdf" + + with pytest.raises(HTTPException) as exc_info: + _retry_pipeline_step(mock_file, "process_with_ocr", db_session) + assert exc_info.value.status_code == 400 + + def test_retry_extract_metadata_empty_local_filename(self, db_session): + """Test extract_metadata retry with empty-string local_filename raises 400.""" + from unittest.mock import MagicMock + + from app.api.files import _retry_pipeline_step + + mock_file = MagicMock(spec=FileRecord) + mock_file.id = 1 + mock_file.local_filename = "" + mock_file.original_filename = "test.pdf" + + with pytest.raises(HTTPException) as exc_info: + _retry_pipeline_step(mock_file, "extract_metadata_with_gpt", db_session) + assert exc_info.value.status_code == 400 + + def test_retry_embed_metadata_empty_local_filename_skips_local_check(self, db_session): + """Test embed_metadata with empty local_filename skips local file check and uses other paths.""" + from unittest.mock import MagicMock + + from app.api.files import _retry_pipeline_step + + # Use empty string for local_filename (falsy, triggers else branch at line 869) + mock_file = MagicMock(spec=FileRecord) + mock_file.id = 1 + mock_file.local_filename = "" + mock_file.processed_file_path = "/nonexistent/processed.pdf" + mock_file.original_file_path = "/nonexistent/original.pdf" + mock_file.original_filename = "test.pdf" + + with patch("app.api.files.settings") as mock_settings: + mock_settings.workdir = "/nonexistent_workdir" + with pytest.raises(HTTPException) as exc_info: + _retry_pipeline_step(mock_file, "embed_metadata_into_pdf", db_session) + assert exc_info.value.status_code == 400 + + def test_retry_embed_metadata_workdir_fallback_succeeds(self, db_session, tmp_path): + """Test embed_metadata workdir fallback path is found and used (line 901).""" + from unittest.mock import MagicMock + + from app.api.files import _retry_pipeline_step + + # Set up the workdir fallback path structure + workdir_tmp = tmp_path / "tmp" + workdir_tmp.mkdir() + fallback_file = workdir_tmp / "fallback.pdf" + fallback_file.write_bytes(b"%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\nstartxref\n0\n%%EOF") + + mock_file = MagicMock(spec=FileRecord) + mock_file.id = 1 + # local_filename is non-empty but DOES NOT EXIST (so fallback will be checked) + mock_file.local_filename = "/nonexistent/fallback.pdf" + mock_file.processed_file_path = "/nonexistent/processed.pdf" + mock_file.original_file_path = "/nonexistent/original.pdf" + mock_file.original_filename = "test.pdf" + + with ( + patch("app.tasks.extract_metadata_with_gpt.extract_metadata_with_gpt") as mock_task, + patch("app.api.files._extract_text_from_pdf", return_value="Sample text"), + patch("app.api.files.settings") as mock_settings, + ): + mock_settings.workdir = str(tmp_path) + mock_task.delay.return_value = Mock(id="task-fallback") + # basename("/nonexistent/fallback.pdf") = "fallback.pdf" + # fallback path = tmp_path/tmp/fallback.pdf which exists + result = _retry_pipeline_step(mock_file, "embed_metadata_into_pdf", db_session) + assert result["status"] == "success" + + +@pytest.mark.unit +class TestBulkReprocessOuterException: + """Test outer exception handling in bulk reprocess endpoints.""" + + def test_bulk_reprocess_outer_exception(self, client: TestClient, db_session): + """Test outer exception handling in bulk_reprocess_files (lines 451-453).""" + with patch("app.api.files.apply_owner_filter", side_effect=Exception("Unexpected error")): + response = client.post("/api/files/bulk-reprocess", json=[1, 2]) + assert response.status_code == 500 + assert "Error bulk reprocessing files" in response.json()["detail"] + + def test_bulk_reprocess_cloud_ocr_outer_exception(self, client: TestClient, db_session): + """Test outer exception handling in bulk_reprocess_files_cloud_ocr (lines 532-534).""" + with patch("app.api.files.apply_owner_filter", side_effect=Exception("Unexpected error")): + response = client.post("/api/files/bulk-reprocess-cloud-ocr", json=[1, 2]) + assert response.status_code == 500 + assert "Error bulk reprocessing files with Cloud OCR" in response.json()["detail"] + + def test_bulk_download_outer_exception(self, client: TestClient, db_session): + """Test outer exception handling in bulk_download_files (lines 612-614).""" + with patch("app.api.files.apply_owner_filter", side_effect=Exception("Unexpected error")): + response = client.post("/api/files/bulk-download", json=[1, 2]) + assert response.status_code == 500 + assert "Error creating bulk download ZIP" in response.json()["detail"] + + +@pytest.mark.unit +class TestRetrySubtaskViaPipelineStep: + """Test retry-subtask endpoint routing to _retry_pipeline_step (line 971).""" + + @patch("app.tasks.process_document.process_document.delay") + def test_retry_subtask_routes_to_pipeline_step(self, mock_delay, client: TestClient, db_session, tmp_path): + """Test that retry-subtask routes pipeline step names to _retry_pipeline_step.""" + file_path = tmp_path / "test.pdf" + file_path.write_bytes(b"%PDF-1.4 content") + + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename=str(file_path), + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + mock_task = Mock() + mock_task.id = "task-pipeline" + mock_delay.return_value = mock_task + + # Call with a pipeline step name - should route to _retry_pipeline_step (line 971) + response = client.post(f"/api/files/{file.id}/retry-subtask?subtask_name=process_document") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["subtask_name"] == "process_document" + + +@pytest.mark.unit +class TestUIUploadFileSplitting: + """Test file splitting logic in ui-upload (lines 1431-1464).""" + + @patch("app.tasks.process_document.process_document.delay") + def test_ui_upload_with_file_splitting(self, mock_delay, client: TestClient, tmp_path): + """Test upload that triggers file splitting into parts.""" + mock_task = Mock() + mock_task.id = "task-split" + mock_delay.return_value = mock_task + + split_file_1 = str(tmp_path / "part1.pdf") + split_file_2 = str(tmp_path / "part2.pdf") + # Create the split files on disk + with open(split_file_1, "wb") as f: + f.write(b"%PDF-1.4 part1") + with open(split_file_2, "wb") as f: + f.write(b"%PDF-1.4 part2") + + pdf_content = b"%PDF-1.4 content" + + with ( + patch("app.config.settings.workdir", str(tmp_path)), + patch("app.utils.file_splitting.should_split_file", return_value=True), + patch("app.utils.file_splitting.split_pdf_by_size", return_value=[split_file_1, split_file_2]), + ): + response = client.post( + "/api/ui-upload", + files={"file": ("large.pdf", pdf_content, "application/pdf")}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "queued" + assert data["split_into_parts"] == 2 + assert mock_delay.call_count >= 1 + + @patch("app.tasks.process_document.process_document.delay") + def test_ui_upload_file_splitting_exception_falls_back(self, mock_delay, client: TestClient, tmp_path): + """Test that splitting exception falls back to processing whole file.""" + mock_task = Mock() + mock_task.id = "task-fallback" + mock_delay.return_value = mock_task + + pdf_content = b"%PDF-1.4 content" + + with ( + patch("app.config.settings.workdir", str(tmp_path)), + patch("app.utils.file_splitting.should_split_file", return_value=True), + patch("app.utils.file_splitting.split_pdf_by_size", side_effect=Exception("Split failed")), + ): + response = client.post( + "/api/ui-upload", + files={"file": ("test.pdf", pdf_content, "application/pdf")}, + ) + assert response.status_code == 200 + data = response.json() + # Should fall back to processing whole file + assert data["status"] == "queued" + assert mock_delay.called + + +@pytest.mark.unit +class TestUIUploadAllowedMimeTypes: + """Test upload for allowed MIME types that go through convert_to_pdf.""" + + def test_ui_upload_office_document_triggers_conversion(self, client: TestClient, tmp_path): + """Test office document upload triggers PDF conversion via allowed MIME types.""" + with ( + patch("app.config.settings.workdir", str(tmp_path)), + patch("app.api.files.convert_to_pdf") as mock_convert, + ): + mock_task = Mock() + mock_task.id = "task-office" + mock_convert.delay = Mock(return_value=mock_task) + + # Use a MIME type in ALLOWED_MIME_TYPES + response = client.post( + "/api/ui-upload", + files={ + "file": ( + "document.docx", + b"PK fake docx content", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + }, + ) + assert response.status_code == 200 + data = response.json() + assert "task_id" in data + + +@pytest.mark.unit +class TestAssignPipelineNonAdminOwnership: + """Test assign_pipeline ownership check for non-admin users (line 1688).""" + + def test_assign_pipeline_non_admin_file_owned_by_others_returns_404(self, client: TestClient, db_session): + """Test non-admin cannot see or modify files owned by other users (line 1688).""" + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/tmp/test.pdf", + file_size=1024, + mime_type="application/pdf", + owner_id="other_user_id", + ) + db_session.add(file) + db_session.commit() + + # In single-user/no-auth mode, get_current_user returns None, get_current_user_id returns "anonymous" + # get_current_owner_id also returns None + # So: is_admin_user=False, file.owner_id="other_user_id", owner_id=None + # Condition: not False AND "other_user_id" is not None AND "other_user_id" != None -> True + response = client.post(f"/api/files/{file.id}/assign-pipeline") + # The non-admin check should return 404 since owner_id != file.owner_id + assert response.status_code == 404 + + +@pytest.mark.unit +class TestDuplicateFileOSError: + """Test OSError handling when removing duplicate file in ui-upload (lines 1408-1409).""" + + @patch("app.config.settings.max_upload_size", 10485760) + @patch("app.config.settings.enable_deduplication", True) + def test_ui_upload_duplicate_os_remove_error_handled(self, client: TestClient, db_session, tmp_path): + """Test that OSError when removing duplicate file is handled gracefully.""" + from app.utils.file_operations import hash_file + + file_content = b"%PDF-1.4 duplicate content for oserror test 1234567890ABCDEF" + tmp_file = tmp_path / "original_oserror.pdf" + tmp_file.write_bytes(file_content) + filehash = hash_file(str(tmp_file)) + + existing = FileRecord( + filehash=filehash, + original_filename="original.pdf", + local_filename=str(tmp_file), + file_size=len(file_content), + mime_type="application/pdf", + is_duplicate=False, + ) + db_session.add(existing) + db_session.commit() + + with ( + patch("app.config.settings.workdir", str(tmp_path)), + patch("os.remove", side_effect=OSError("Permission denied")), + ): + response = client.post( + "/api/ui-upload", + files={"file": ("duplicate.pdf", file_content, "application/pdf")}, + ) + # Should still return duplicate status even if os.remove fails + assert response.status_code == 200 + data = response.json() + assert data["status"] == "duplicate" + + +@pytest.mark.unit +class TestRetrySubtaskProcessedFilePresentButMissing: + """Test retry-subtask when processed_file_path is set but doesn't exist (line 1026->1030).""" + + def test_retry_subtask_processed_path_set_but_missing_uses_legacy(self, client: TestClient, db_session, tmp_path): + """Test that retry-subtask falls through to legacy paths when processed_file_path doesn't exist.""" + # Create a file in legacy path location + processed_dir = tmp_path / "processed" + processed_dir.mkdir() + # Use hash-based path for legacy lookup + legacy_file = processed_dir / "hashlegacy.pdf" + legacy_file.write_bytes(b"%PDF-1.4 legacy") + + file = FileRecord( + filehash="hashlegacy", + original_filename="test.pdf", + local_filename="/tmp/test.pdf", + # processed_file_path is set but points to a non-existent file + processed_file_path="/nonexistent/processed.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + with ( + patch("app.api.files.settings") as mock_settings, + patch("app.tasks.upload_to_email.upload_to_email") as mock_task, + ): + mock_settings.workdir = str(tmp_path) + mock_task.delay.return_value = Mock(id="task-email") + response = client.post(f"/api/files/{file.id}/retry-subtask?subtask_name=upload_to_email") + # Should find the file via legacy hash-based path + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + + +@pytest.mark.unit +class TestPreviewDownloadExceptions: + """Test exception handling in preview and download endpoints (lines 1153-1155, 1235-1237).""" + + def test_preview_unexpected_exception_returns_500(self, client: TestClient, db_session): + """Test that unexpected exception in preview endpoint returns 500.""" + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/tmp/test.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + # Patch apply_owner_filter to raise an unexpected exception (not HTTPException) + with patch("app.api.files.apply_owner_filter", side_effect=RuntimeError("Unexpected DB error")): + response = client.get(f"/api/files/{file.id}/preview?version=original") + assert response.status_code == 500 + assert "Error retrieving file preview" in response.json()["detail"] + + def test_download_unexpected_exception_returns_500(self, client: TestClient, db_session): + """Test that unexpected exception in download endpoint returns 500.""" + file = FileRecord( + filehash="hash1", + original_filename="test.pdf", + local_filename="/tmp/test.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(file) + db_session.commit() + + with patch("app.api.files.apply_owner_filter", side_effect=RuntimeError("Unexpected DB error")): + response = client.get(f"/api/files/{file.id}/download?version=original") + assert response.status_code == 500 + assert "Error downloading file" in response.json()["detail"] + + +@pytest.mark.unit +class TestAssignOwnerWithFileIds: + """Test assign-owner endpoint with specific file_ids (line 1615).""" + + def test_assign_owner_with_file_ids_using_admin_session(self, client: TestClient, db_session): + """Test assign-owner with specific file_ids in request body (covers line 1615).""" + file1 = FileRecord( + filehash="hash1", + original_filename="test1.pdf", + local_filename="/tmp/test1.pdf", + file_size=1024, + mime_type="application/pdf", + owner_id=None, + ) + db_session.add(file1) + db_session.commit() + + with ( + patch("app.api.files.settings") as mock_settings, + patch( + "starlette.requests.Request.session", + new_callable=lambda: property(lambda self: {"user": {"is_admin": True}}), + ), + ): + mock_settings.multi_user_enabled = True + # Send file_ids as JSON body (the parameter is body-typed, not query-typed) + response = client.post( + "/api/files/assign-owner?owner_id=newowner", + json=[file1.id], # file_ids as JSON body + ) + assert response.status_code == 200 + data = response.json() + # file1 should be assigned (file_ids branch, line 1615) + assert data["updated_count"] == 1 + assert data["owner_id"] == "newowner" diff --git a/tests/test_api_integrations.py b/tests/test_api_integrations.py index 90d1c1f3..958a1a86 100644 --- a/tests/test_api_integrations.py +++ b/tests/test_api_integrations.py @@ -1,5 +1,7 @@ """Tests for the per-user integrations API (app/api/integrations.py).""" +import unittest.mock + import pytest from fastapi.testclient import TestClient from sqlalchemy import create_engine @@ -887,9 +889,9 @@ class TestConnectionTestEndpoint: def test_test_unsupported_type(self, int_client): """Unsupported integration types return a helpful non-error message.""" payload = { - "integration_type": "DROPBOX", + "integration_type": "FTP", "config": {}, - "credentials": {"token": "abc"}, + "credentials": {"username": "user", "password": "pass"}, } resp = int_client.post("/api/integrations/test", json=payload) assert resp.status_code == 200 @@ -897,6 +899,83 @@ class TestConnectionTestEndpoint: assert data["success"] is False assert "not yet supported" in data["message"] + def test_test_dropbox_missing_refresh_token(self, int_client): + """Dropbox test with missing refresh_token returns failure.""" + payload = { + "integration_type": "DROPBOX", + "config": {}, + "credentials": {"app_key": "key", "app_secret": "secret"}, + } + resp = int_client.post("/api/integrations/test", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is False + assert "refresh_token" in data["message"].lower() + + def test_test_dropbox_missing_app_key(self, int_client): + """Dropbox test with missing app_key/app_secret returns failure.""" + payload = { + "integration_type": "DROPBOX", + "config": {}, + "credentials": {"refresh_token": "rtoken"}, + } + resp = int_client.post("/api/integrations/test", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is False + assert "app_key" in data["message"].lower() + + def test_test_dropbox_invalid_credentials(self, int_client): + """Dropbox test with bad credentials returns an auth failure.""" + from unittest.mock import MagicMock, patch + + import dropbox.exceptions as dbx_exc + + with patch("app.api.integrations.dbx_lib") as mock_dbx: + mock_instance = MagicMock() + mock_dbx.Dropbox.return_value = mock_instance + mock_instance.users_get_current_account.side_effect = dbx_exc.AuthError("req_id", MagicMock()) + payload = { + "integration_type": "DROPBOX", + "config": {}, + "credentials": { + "app_key": "bad_key", + "app_secret": "bad_secret", + "refresh_token": "bad_token", + }, + } + resp = int_client.post("/api/integrations/test", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is False + assert "authentication failed" in data["message"].lower() + + def test_test_dropbox_success(self, int_client): + """Dropbox test with valid (mocked) credentials returns success.""" + from unittest.mock import MagicMock, patch + + with patch("app.api.integrations.dbx_lib") as mock_dbx: + mock_instance = MagicMock() + mock_dbx.Dropbox.return_value = mock_instance + mock_account = MagicMock() + mock_account.name.display_name = "Test User" + mock_instance.users_get_current_account.return_value = mock_account + + payload = { + "integration_type": "DROPBOX", + "config": {}, + "credentials": { + "app_key": "valid_key", + "app_secret": "valid_secret", + "refresh_token": "valid_token", + }, + } + resp = int_client.post("/api/integrations/test", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert "dropbox connection successful" in data["message"].lower() + def test_test_invalid_type_returns_400(self, int_client): """Invalid integration_type returns 400.""" payload = { @@ -984,6 +1063,69 @@ class TestConnectionTestEndpoint: assert data["success"] is False assert "scheme" in data["message"].lower() + @unittest.mock.patch("httpx.request") + def test_test_webdav_success(self, mock_request, int_client): + """WebDAV test succeeds with valid credentials and a valid status code.""" + mock_response = unittest.mock.MagicMock() + mock_response.status_code = 207 # Typical WebDAV success for PROPFIND + mock_request.return_value = mock_response + + payload = { + "integration_type": "WEBDAV", + "config": {"url": "https://example.com/webdav"}, + "credentials": {"username": "user1", "password": "password123"}, + } + resp = int_client.post("/api/integrations/test", json=payload) + + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + + mock_request.assert_called_once_with( + "PROPFIND", + "https://example.com/webdav", + auth=("user1", "password123"), + headers={"Depth": "0"}, + timeout=10.0, + follow_redirects=False, + ) + + @unittest.mock.patch("httpx.request") + def test_test_webdav_failure_status(self, mock_request, int_client): + """WebDAV test fails if the server returns a 4xx or 5xx status code.""" + mock_response = unittest.mock.MagicMock() + mock_response.status_code = 401 + mock_request.return_value = mock_response + + payload = { + "integration_type": "WEBDAV", + "config": {"url": "https://example.com/webdav"}, + "credentials": {"username": "user1", "password": "wrong"}, + } + resp = int_client.post("/api/integrations/test", json=payload) + + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is False + assert "401" in data["message"] + + @unittest.mock.patch("httpx.request") + def test_test_webdav_exception(self, mock_request, int_client): + """WebDAV test fails gracefully if an exception occurs during the request.""" + mock_request.side_effect = Exception("Connection error") + + payload = { + "integration_type": "WEBDAV", + "config": {"url": "https://example.com/webdav"}, + "credentials": {}, + } + resp = int_client.post("/api/integrations/test", json=payload) + + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is False + assert "failed" in data["message"].lower() + # --------------------------------------------------------------------------- # Quota endpoint tests diff --git a/tests/test_api_mobile.py b/tests/test_api_mobile.py index 54b08967..5fcd7229 100644 --- a/tests/test_api_mobile.py +++ b/tests/test_api_mobile.py @@ -329,7 +329,7 @@ class TestDeactivateDevice: """Tests for DELETE /api/mobile/devices/{device_id}.""" def test_deactivate_own_device(self, mob_engine, mob_session): - """Deactivating a device sets is_active to False.""" + """Deactivating an active device sets is_active to False (soft-delete, returns 200).""" from app.main import app device = MobileDevice( @@ -346,7 +346,8 @@ class TestDeactivateDevice: client = _make_client(mob_engine) try: resp = client.delete(f"/api/mobile/devices/{device_id}") - assert resp.status_code == 204 + assert resp.status_code == 200 + assert resp.json()["detail"] == "Device deactivated" mob_session.expire_all() updated = mob_session.get(MobileDevice, device_id) @@ -355,6 +356,33 @@ class TestDeactivateDevice: finally: _cleanup(app) + def test_delete_inactive_device(self, mob_engine, mob_session): + """Deleting an already-inactive device permanently removes it (hard-delete, returns 200).""" + from app.main import app + + device = MobileDevice( + owner_id=_OWNER, + push_token=_EXPO_TOKEN, + platform="ios", + is_active=False, + ) + mob_session.add(device) + mob_session.commit() + mob_session.refresh(device) + device_id = device.id + + client = _make_client(mob_engine) + try: + resp = client.delete(f"/api/mobile/devices/{device_id}") + assert resp.status_code == 200 + assert resp.json()["detail"] == "Device deleted" + + mob_session.expire_all() + deleted = mob_session.get(MobileDevice, device_id) + assert deleted is None + finally: + _cleanup(app) + def test_deactivate_other_users_device_returns_404(self, mob_engine, mob_session): """Attempting to deactivate another user's device returns 404.""" from app.main import app @@ -439,6 +467,42 @@ class TestWhoAmI: assert data["email"] == _OWNER assert data["avatar_url"] is not None # Gravatar URL assert data["is_admin"] is False + assert data["preferred_language"] is None # not set yet + finally: + _cleanup(app) + + def test_whoami_returns_preferred_language(self, mob_engine, mob_session): + """preferred_language from UserProfile is included in the whoami response.""" + from app.main import app + from app.models import UserProfile + + profile = UserProfile( + user_id=_OWNER, + display_name="Bob Test", + preferred_language="de", + ) + mob_session.add(profile) + mob_session.commit() + + client = _make_client(mob_engine) + try: + resp = client.get("/api/mobile/whoami") + assert resp.status_code == 200 + data = resp.json() + assert data["preferred_language"] == "de" + finally: + _cleanup(app) + + def test_whoami_no_profile_preferred_language_is_null(self, mob_engine): + """preferred_language is null when no UserProfile exists.""" + from app.main import app + + client = _make_client(mob_engine) + try: + resp = client.get("/api/mobile/whoami") + assert resp.status_code == 200 + data = resp.json() + assert data["preferred_language"] is None finally: _cleanup(app) diff --git a/tests/test_api_onedrive_comprehensive.py b/tests/test_api_onedrive_comprehensive.py index 1019031b..f46fb397 100644 --- a/tests/test_api_onedrive_comprehensive.py +++ b/tests/test_api_onedrive_comprehensive.py @@ -695,3 +695,182 @@ class TestOneDriveIntegration: # Verify env format is present (exact values may vary) assert "env_format" in config_data + + +@pytest.mark.unit +class TestListOneDriveFolders: + """Tests for list_onedrive_folders endpoint.""" + + @patch("app.api.onedrive.requests.get") + def test_list_folders_success(self, mock_get, client): + """Test successful folder listing at root.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "value": [ + { + "name": "Documents", + "id": "id:1", + "folder": {"childCount": 3}, + "parentReference": {"path": "/drive/root:"}, + }, + { + "name": "Pictures", + "id": "id:2", + "folder": {"childCount": 10}, + "parentReference": {"path": "/drive/root:"}, + }, + ], + } + mock_get.return_value = mock_response + + response = client.post( + "/api/onedrive/list-folders", + data={"access_token": "test-token", "path": ""}, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["folders"]) == 2 + assert data["folders"][0]["name"] == "Documents" + assert data["folders"][0]["path"] == "/Documents" + assert data["folders"][1]["name"] == "Pictures" + assert data["path"] == "/" + + @patch("app.api.onedrive.requests.get") + def test_list_folders_subfolder(self, mock_get, client): + """Test listing folders in a subfolder.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "value": [ + { + "name": "Invoices", + "id": "id:3", + "folder": {"childCount": 0}, + "parentReference": {"path": "/drive/root:/Documents"}, + }, + ], + } + mock_get.return_value = mock_response + + response = client.post( + "/api/onedrive/list-folders", + data={"access_token": "test-token", "path": "Documents"}, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["folders"]) == 1 + assert data["folders"][0]["path"] == "/Documents/Invoices" + assert data["path"] == "/Documents" + + @patch("app.api.onedrive.requests.get") + def test_list_folders_empty(self, mock_get, client): + """Test listing folders in an empty directory.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"value": []} + mock_get.return_value = mock_response + + response = client.post( + "/api/onedrive/list-folders", + data={"access_token": "test-token", "path": "EmptyFolder"}, + ) + + assert response.status_code == 200 + assert len(response.json()["folders"]) == 0 + + @patch("app.api.onedrive.requests.get") + def test_list_folders_unauthorized(self, mock_get, client): + """Test listing folders with invalid token returns 401.""" + mock_response = Mock() + mock_response.status_code = 401 + mock_response.text = "Invalid access token" + mock_get.return_value = mock_response + + response = client.post( + "/api/onedrive/list-folders", + data={"access_token": "bad-token", "path": ""}, + ) + + assert response.status_code == 401 + + @patch("app.api.onedrive.requests.get") + def test_list_folders_api_error(self, mock_get, client): + """Test listing folders when Graph API returns an error.""" + mock_response = Mock() + mock_response.status_code = 500 + mock_response.text = "Internal server error" + mock_get.return_value = mock_response + + response = client.post( + "/api/onedrive/list-folders", + data={"access_token": "test-token", "path": ""}, + ) + + assert response.status_code == 502 + + @patch("app.api.onedrive.requests.get") + def test_list_folders_sorted_alphabetically(self, mock_get, client): + """Test that folders are returned in alphabetical order.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "value": [ + { + "name": "Zebra", + "id": "id:1", + "folder": {"childCount": 0}, + "parentReference": {"path": "/drive/root:"}, + }, + { + "name": "Alpha", + "id": "id:2", + "folder": {"childCount": 0}, + "parentReference": {"path": "/drive/root:"}, + }, + { + "name": "middle", + "id": "id:3", + "folder": {"childCount": 0}, + "parentReference": {"path": "/drive/root:"}, + }, + ], + } + mock_get.return_value = mock_response + + response = client.post( + "/api/onedrive/list-folders", + data={"access_token": "test-token", "path": ""}, + ) + + assert response.status_code == 200 + names = [f["name"] for f in response.json()["folders"]] + assert names == ["Alpha", "middle", "Zebra"] + + @patch("app.api.onedrive.requests.get") + def test_list_folders_root_drive_parent(self, mock_get, client): + """Test folder path construction when parentReference.path is /drive/root.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "value": [ + { + "name": "TopLevel", + "id": "id:1", + "folder": {"childCount": 0}, + "parentReference": {"path": "/drive/root"}, + }, + ], + } + mock_get.return_value = mock_response + + response = client.post( + "/api/onedrive/list-folders", + data={"access_token": "test-token", "path": ""}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["folders"][0]["path"] == "/TopLevel" diff --git a/tests/test_api_sessions.py b/tests/test_api_sessions.py new file mode 100644 index 00000000..3e334c5e --- /dev/null +++ b/tests/test_api_sessions.py @@ -0,0 +1,439 @@ +"""Tests for the session management API endpoints (app/api/sessions.py). + +Covers: +* _get_owner_id dependency helper (authenticated and unauthenticated paths) +* GET /api/sessions/ – list sessions +* DELETE /api/sessions/{id} – revoke a single session +* POST /api/sessions/revoke-all – log off everywhere +""" + +from __future__ import annotations + +import base64 +import json +import secrets +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +import itsdangerous +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.database import Base, get_db +from app.models import UserSession + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_OWNER = "sessionuser@example.com" +_OTHER_OWNER = "other@example.com" +_SESSION_SECRET = "test_secret_key_for_testing_must_be_at_least_32_characters_long" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_session_cookie(session_data: dict) -> str: + """Encode *session_data* as a signed Starlette session cookie value.""" + signer = itsdangerous.TimestampSigner(_SESSION_SECRET) + data = base64.b64encode(json.dumps(session_data).encode("utf-8")) + return signer.sign(data).decode("utf-8") + + +def _make_user_session( + db, + user_id: str = _OWNER, + session_token: str | None = None, + expires_delta: timedelta = timedelta(days=30), +) -> UserSession: + """Create and persist a UserSession in *db*.""" + now = datetime.now(timezone.utc) + token = session_token or secrets.token_urlsafe(32) + session = UserSession( + session_token=token, + user_id=user_id, + ip_address="127.0.0.1", + user_agent="TestBrowser/1.0", + device_info="TestBrowser on Linux", + created_at=now, + last_active_at=now, + expires_at=now + expires_delta, + ) + db.add(session) + db.commit() + db.refresh(session) + return session + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def sess_engine(): + """In-memory SQLite engine scoped to one test.""" + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + yield engine + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture() +def sess_db(sess_engine): + """Database session scoped to one test.""" + Session = sessionmaker(bind=sess_engine) + session = Session() + yield session + session.close() + + +def _make_client(sess_engine, owner_id: str = _OWNER) -> TestClient: + """Return a TestClient with *owner_id* injected as the authenticated user.""" + from app.api.sessions import _get_owner_id + from app.main import app + + Session = sessionmaker(bind=sess_engine) + + def _override_get_db(): + session = Session() + try: + yield session + finally: + session.close() + + def _override_owner(): + return owner_id + + app.dependency_overrides[get_db] = _override_get_db + app.dependency_overrides[_get_owner_id] = _override_owner + + return TestClient(app, base_url="http://localhost", raise_server_exceptions=False) + + +def _make_unauthenticated_client(sess_engine) -> TestClient: + """Return a TestClient with only the DB overridden (no auth injection).""" + from app.main import app + + Session = sessionmaker(bind=sess_engine) + + def _override_get_db(): + session = Session() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db] = _override_get_db + + return TestClient(app, base_url="http://localhost", raise_server_exceptions=False) + + +def _cleanup(): + """Remove all dependency overrides from the app.""" + from app.main import app + + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# Tests – _get_owner_id helper +# --------------------------------------------------------------------------- + + +class TestGetOwnerId: + """Tests for the _get_owner_id dependency helper in app/api/sessions.py.""" + + @pytest.mark.unit + def test_unauthenticated_raises_401(self): + """_get_owner_id should raise HTTP 401 when the user is not authenticated.""" + from unittest.mock import MagicMock + + from fastapi import HTTPException + + from app.api.sessions import _get_owner_id + + mock_request = MagicMock() + with patch("app.api.sessions.get_current_owner_id", return_value=None): + with pytest.raises(HTTPException) as exc_info: + _get_owner_id(mock_request) + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Not authenticated" + + @pytest.mark.unit + def test_authenticated_returns_owner_id(self): + """_get_owner_id should return the owner_id when the user is authenticated.""" + from unittest.mock import MagicMock + + from app.api.sessions import _get_owner_id + + mock_request = MagicMock() + with patch("app.api.sessions.get_current_owner_id", return_value=_OWNER): + result = _get_owner_id(mock_request) + assert result == _OWNER + + +# --------------------------------------------------------------------------- +# Tests – GET /api/sessions/ +# --------------------------------------------------------------------------- + + +class TestListSessions: + """Tests for GET /api/sessions/.""" + + @pytest.mark.unit + def test_list_sessions_empty(self, sess_engine): + """Returns an empty session list when no sessions exist.""" + client = _make_client(sess_engine) + try: + resp = client.get("/api/sessions/") + assert resp.status_code == 200 + data = resp.json() + assert data["sessions"] == [] + assert "session_lifetime_days" in data + finally: + _cleanup() + + @pytest.mark.unit + def test_list_sessions_returns_active_sessions(self, sess_engine, sess_db): + """Returns session details for all active sessions belonging to the user.""" + _make_user_session(sess_db, user_id=_OWNER) + _make_user_session(sess_db, user_id=_OWNER) + # A session owned by a different user must not appear. + _make_user_session(sess_db, user_id=_OTHER_OWNER) + + client = _make_client(sess_engine) + try: + resp = client.get("/api/sessions/") + assert resp.status_code == 200 + sessions = resp.json()["sessions"] + assert len(sessions) == 2 + for s in sessions: + assert "id" in s + assert "device_info" in s + assert "ip_address" in s + assert "created_at" in s + assert "last_active_at" in s + assert "expires_at" in s + assert "is_current" in s + finally: + _cleanup() + + @pytest.mark.unit + def test_list_sessions_marks_current_session(self, sess_engine, sess_db): + """The session whose token matches request.session['_session_token'] is + marked ``is_current=True``; all others are ``False``.""" + current_token = secrets.token_urlsafe(32) + current_session = _make_user_session(sess_db, user_id=_OWNER, session_token=current_token) + other_session = _make_user_session(sess_db, user_id=_OWNER) + + cookie = _make_session_cookie({"_session_token": current_token}) + + client = _make_client(sess_engine) + try: + resp = client.get("/api/sessions/", cookies={"session": cookie}) + assert resp.status_code == 200 + sessions = resp.json()["sessions"] + session_map = {s["id"]: s for s in sessions} + assert session_map[current_session.id]["is_current"] is True + assert session_map[other_session.id]["is_current"] is False + finally: + _cleanup() + + @pytest.mark.unit + def test_list_sessions_no_current_token(self, sess_engine, sess_db): + """When no _session_token is present, all sessions have is_current=False.""" + _make_user_session(sess_db, user_id=_OWNER) + + client = _make_client(sess_engine) + try: + resp = client.get("/api/sessions/") + assert resp.status_code == 200 + for s in resp.json()["sessions"]: + assert s["is_current"] is False + finally: + _cleanup() + + @pytest.mark.unit + def test_list_sessions_returns_lifetime_days(self, sess_engine): + """Response always includes session_lifetime_days.""" + client = _make_client(sess_engine) + try: + resp = client.get("/api/sessions/") + assert resp.status_code == 200 + assert isinstance(resp.json()["session_lifetime_days"], int) + assert resp.json()["session_lifetime_days"] >= 1 + finally: + _cleanup() + + +# --------------------------------------------------------------------------- +# Tests – DELETE /api/sessions/{session_id} +# --------------------------------------------------------------------------- + + +class TestRevokeSingleSession: + """Tests for DELETE /api/sessions/{session_id}.""" + + @pytest.mark.unit + def test_revoke_session_success(self, sess_engine, sess_db): + """Revoking an owned session returns 204 No Content.""" + session = _make_user_session(sess_db, user_id=_OWNER) + + client = _make_client(sess_engine) + try: + resp = client.delete(f"/api/sessions/{session.id}") + assert resp.status_code == 204 + finally: + _cleanup() + + @pytest.mark.unit + def test_revoke_session_not_found(self, sess_engine): + """Revoking a non-existent session returns 404.""" + client = _make_client(sess_engine) + try: + resp = client.delete("/api/sessions/999999") + assert resp.status_code == 404 + finally: + _cleanup() + + @pytest.mark.unit + def test_revoke_session_belonging_to_other_user_returns_404(self, sess_engine, sess_db): + """A user cannot revoke another user's session (returns 404).""" + other_session = _make_user_session(sess_db, user_id=_OTHER_OWNER) + + client = _make_client(sess_engine, owner_id=_OWNER) + try: + resp = client.delete(f"/api/sessions/{other_session.id}") + assert resp.status_code == 404 + finally: + _cleanup() + + @pytest.mark.unit + def test_revoke_session_audit_failure_does_not_break_response(self, sess_engine, sess_db): + """Even if the audit service raises an exception, the response is still 204.""" + session = _make_user_session(sess_db, user_id=_OWNER) + + client = _make_client(sess_engine) + try: + with patch("app.utils.audit_service.record_event", side_effect=Exception("audit down")): + resp = client.delete(f"/api/sessions/{session.id}") + assert resp.status_code == 204 + finally: + _cleanup() + + +# --------------------------------------------------------------------------- +# Tests – POST /api/sessions/revoke-all +# --------------------------------------------------------------------------- + + +class TestRevokeAllSessions: + """Tests for POST /api/sessions/revoke-all.""" + + @pytest.mark.unit + def test_revoke_all_no_sessions(self, sess_engine): + """Returns revoked_count=0 when there are no sessions to revoke.""" + client = _make_client(sess_engine) + try: + resp = client.post("/api/sessions/revoke-all") + assert resp.status_code == 200 + data = resp.json() + assert data["revoked_count"] == 0 + assert "message" in data + finally: + _cleanup() + + @pytest.mark.unit + def test_revoke_all_revokes_all_sessions(self, sess_engine, sess_db): + """All active sessions for the user are revoked.""" + _make_user_session(sess_db, user_id=_OWNER) + _make_user_session(sess_db, user_id=_OWNER) + + client = _make_client(sess_engine) + try: + resp = client.post("/api/sessions/revoke-all") + assert resp.status_code == 200 + data = resp.json() + assert data["revoked_count"] == 2 + assert "2" in data["message"] + finally: + _cleanup() + + @pytest.mark.unit + def test_revoke_all_preserves_current_session(self, sess_engine, sess_db): + """The session matching the current _session_token is NOT revoked.""" + current_token = secrets.token_urlsafe(32) + current_session = _make_user_session(sess_db, user_id=_OWNER, session_token=current_token) + _make_user_session(sess_db, user_id=_OWNER) + _make_user_session(sess_db, user_id=_OWNER) + + cookie = _make_session_cookie({"_session_token": current_token}) + + client = _make_client(sess_engine) + try: + resp = client.post("/api/sessions/revoke-all", cookies={"session": cookie}) + assert resp.status_code == 200 + # Only the two non-current sessions should be revoked. + assert resp.json()["revoked_count"] == 2 + + # The current session must still be active in the DB. + sess_db.refresh(current_session) + assert current_session.is_revoked is False + finally: + _cleanup() + + @pytest.mark.unit + def test_revoke_all_current_session_token_not_in_db(self, sess_engine, sess_db): + """When the _session_token in the cookie doesn't match any DB row, + all sessions are revoked (no session is preserved).""" + _make_user_session(sess_db, user_id=_OWNER) + + # Cookie references a token that does not exist in the DB. + cookie = _make_session_cookie({"_session_token": "ghost_token_xyz"}) + + client = _make_client(sess_engine) + try: + resp = client.post("/api/sessions/revoke-all", cookies={"session": cookie}) + assert resp.status_code == 200 + assert resp.json()["revoked_count"] == 1 + finally: + _cleanup() + + @pytest.mark.unit + def test_revoke_all_audit_failure_does_not_break_response(self, sess_engine, sess_db): + """Even if the audit service raises, revoke-all still returns 200.""" + _make_user_session(sess_db, user_id=_OWNER) + + client = _make_client(sess_engine) + try: + with patch("app.utils.audit_service.record_event", side_effect=Exception("audit down")): + resp = client.post("/api/sessions/revoke-all") + assert resp.status_code == 200 + assert resp.json()["revoked_count"] == 1 + finally: + _cleanup() + + @pytest.mark.unit + def test_revoke_all_message_format(self, sess_engine, sess_db): + """Response message includes the count and mentions API tokens.""" + _make_user_session(sess_db, user_id=_OWNER) + + client = _make_client(sess_engine) + try: + resp = client.post("/api/sessions/revoke-all") + assert resp.status_code == 200 + msg = resp.json()["message"] + assert "1" in msg + assert "API" in msg or "token" in msg.lower() + finally: + _cleanup() diff --git a/tests/test_api_settings.py b/tests/test_api_settings.py index 8512387d..154f9bf7 100644 --- a/tests/test_api_settings.py +++ b/tests/test_api_settings.py @@ -180,6 +180,27 @@ class TestSettingModels: assert update.key == "test_key" assert update.value is None + def test_setting_value_update_model(self): + """Test SettingValueUpdate model (PUT body — no key required).""" + from app.api.settings import SettingValueUpdate + + body = SettingValueUpdate(value="test_value") + assert body.value == "test_value" + + def test_setting_value_update_model_with_none_value(self): + """Test SettingValueUpdate model accepts None value.""" + from app.api.settings import SettingValueUpdate + + body = SettingValueUpdate(value=None) + assert body.value is None + + def test_setting_value_update_model_defaults_to_none(self): + """Test SettingValueUpdate model value defaults to None when omitted.""" + from app.api.settings import SettingValueUpdate + + body = SettingValueUpdate() + assert body.value is None + def test_setting_response_model(self): """Test SettingResponse model.""" from app.api.settings import SettingResponse @@ -205,8 +226,100 @@ class TestSettingModels: assert "test_key" in response.db_settings -@pytest.mark.unit -class TestListCredentials: +@pytest.mark.integration +class TestPutSettingEndpoint: + """Tests for PUT /api/settings/{key} endpoint.""" + + def test_put_setting_requires_admin(self, client): + """Test PUT /settings/{key} requires admin access.""" + response = client.put("/api/settings/social_auth_dropbox_enabled", json={"value": "true"}) + assert response.status_code in [302, 401, 403] + + @patch("app.api.settings.notify_settings_updated") + @patch("app.api.settings.get_setting_metadata") + @patch("app.api.settings.validate_setting_value") + @patch("app.api.settings.save_setting_to_db") + def test_put_setting_saves_value(self, mock_save, mock_validate, mock_metadata, mock_notify, client): + """Test PUT /settings/{key} saves the value when authenticated as admin.""" + from app.api.settings import require_admin + from app.main import app as fastapi_app + + mock_validate.return_value = (True, None) + mock_save.return_value = True + mock_metadata.return_value = {"restart_required": True} + + def override_require_admin(): + return {"id": "admin", "is_admin": True, "preferred_username": "admin"} + + fastapi_app.dependency_overrides[require_admin] = override_require_admin + try: + response = client.put( + "/api/settings/social_auth_dropbox_enabled", + json={"value": "true"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["key"] == "social_auth_dropbox_enabled" + assert data["value"] == "true" + assert data["restart_required"] is True + finally: + fastapi_app.dependency_overrides.pop(require_admin, None) + + @patch("app.api.settings.notify_settings_updated") + @patch("app.api.settings.get_setting_metadata") + @patch("app.api.settings.validate_setting_value") + @patch("app.api.settings.save_setting_to_db") + def test_put_setting_body_without_key_field_is_accepted( + self, mock_save, mock_validate, mock_metadata, mock_notify, client + ): + """Test PUT /settings/{key} body need not contain a key field.""" + from app.api.settings import require_admin + from app.main import app as fastapi_app + + mock_validate.return_value = (True, None) + mock_save.return_value = True + mock_metadata.return_value = {"restart_required": False} + + def override_require_admin(): + return {"id": "admin", "is_admin": True, "preferred_username": "admin"} + + fastapi_app.dependency_overrides[require_admin] = override_require_admin + try: + # Body only contains "value" — no "key" field (mirrors admin_connections.html behaviour) + response = client.put( + "/api/settings/social_auth_dropbox_use_global_credentials", + json={"value": "false"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + finally: + fastapi_app.dependency_overrides.pop(require_admin, None) + + @patch("app.api.settings.validate_setting_value") + @patch("app.api.settings.get_setting_metadata") + def test_put_setting_returns_400_on_invalid_value(self, mock_metadata, mock_validate, client): + """Test PUT /settings/{key} returns 400 for invalid values.""" + from app.api.settings import require_admin + from app.main import app as fastapi_app + + mock_validate.return_value = (False, "Invalid boolean value") + mock_metadata.return_value = {"restart_required": False} + + def override_require_admin(): + return {"id": "admin", "is_admin": True} + + fastapi_app.dependency_overrides[require_admin] = override_require_admin + try: + response = client.put( + "/api/settings/social_auth_dropbox_enabled", + json={"value": "not_a_bool"}, + ) + assert response.status_code == 400 + finally: + fastapi_app.dependency_overrides.pop(require_admin, None) + """Tests for the list_credentials function (GET /api/settings/credentials).""" @patch("app.api.settings.get_all_settings_from_db") diff --git a/tests/test_api_tokens.py b/tests/test_api_tokens.py index dabf2dd8..2a2b124a 100644 --- a/tests/test_api_tokens.py +++ b/tests/test_api_tokens.py @@ -314,8 +314,8 @@ class TestTokenRevoke: _cleanup(app) @pytest.mark.unit - def test_revoke_already_revoked_token(self, tok_engine): - """Revoking an already-revoked token should return 400.""" + def test_delete_already_revoked_token(self, tok_engine): + """Deleting an already-revoked token should permanently remove it (hard-delete, 200).""" from app.main import app client = _make_client(tok_engine) @@ -324,9 +324,15 @@ class TestTokenRevoke: token_id = create_resp.json()["id"] client.delete(f"/api/api-tokens/{token_id}") + # Second DELETE should hard-delete the revoked token. resp = client.delete(f"/api/api-tokens/{token_id}") - assert resp.status_code == 400 - assert resp.json()["detail"] == "Token is already revoked" + assert resp.status_code == 200 + assert resp.json()["detail"] == "Token deleted" + + # Token must no longer appear in the list. + list_resp = client.get("/api/api-tokens/") + ids = [t["id"] for t in list_resp.json()] + assert token_id not in ids finally: _cleanup(app) @@ -677,3 +683,216 @@ class TestTokenUtils: token = "de_test_token_value" expected_hash = "9b89d9adf2f390c75bf2fd0ff2bb5622ef5a9dce438354cce6e39f2f5401129e" assert hash_token(token) == expected_hash + + +# --------------------------------------------------------------------------- +# Tests – Token reactivation +# --------------------------------------------------------------------------- + + +class TestTokenReactivate: + """Tests for POST /api/api-tokens/{id}/reactivate.""" + + @pytest.mark.unit + def test_reactivate_revoked_token(self, tok_engine): + """Reactivating a revoked token should set is_active=True and clear revoked_at.""" + from app.main import app + + client = _make_client(tok_engine) + try: + create_resp = client.post("/api/api-tokens/", json={"name": "Reactivate Me"}) + token_id = create_resp.json()["id"] + client.delete(f"/api/api-tokens/{token_id}") + + resp = client.post(f"/api/api-tokens/{token_id}/reactivate") + assert resp.status_code == 200 + data = resp.json() + assert data["is_active"] is True + assert data["revoked_at"] is None + finally: + _cleanup(app) + + @pytest.mark.unit + def test_reactivate_active_token_returns_400(self, tok_engine): + """Reactivating an already-active token should return 400.""" + from app.main import app + + client = _make_client(tok_engine) + try: + create_resp = client.post("/api/api-tokens/", json={"name": "Already Active"}) + token_id = create_resp.json()["id"] + + resp = client.post(f"/api/api-tokens/{token_id}/reactivate") + assert resp.status_code == 400 + assert resp.json()["detail"] == "Token is already active" + finally: + _cleanup(app) + + @pytest.mark.unit + def test_reactivate_nonexistent_token(self, tok_engine): + """Reactivating a non-existent token should return 404.""" + from app.main import app + + client = _make_client(tok_engine) + try: + resp = client.post("/api/api-tokens/99999/reactivate") + assert resp.status_code == 404 + finally: + _cleanup(app) + + @pytest.mark.unit + def test_reactivate_other_users_token(self, tok_engine): + """A user cannot reactivate another user's token.""" + from app.main import app + + client_a = _make_client(tok_engine, _OWNER) + try: + create_resp = client_a.post("/api/api-tokens/", json={"name": "A Token"}) + token_id = create_resp.json()["id"] + client_a.delete(f"/api/api-tokens/{token_id}") + finally: + _cleanup(app) + + client_b = _make_client(tok_engine, _OTHER_OWNER) + try: + resp = client_b.post(f"/api/api-tokens/{token_id}/reactivate") + assert resp.status_code == 404 + finally: + _cleanup(app) + + +# --------------------------------------------------------------------------- +# Tests – Token lifetime (expires_at) +# --------------------------------------------------------------------------- + + +class TestTokenExpiry: + """Tests for token creation with optional lifetime and expiry enforcement.""" + + @pytest.mark.unit + def test_create_token_without_expiry(self, tok_engine): + """Creating a token without expires_in_days should leave expires_at as None.""" + from app.main import app + + client = _make_client(tok_engine) + try: + resp = client.post("/api/api-tokens/", json={"name": "No Expiry"}) + assert resp.status_code == 201 + data = resp.json() + assert data["expires_at"] is None + finally: + _cleanup(app) + + @pytest.mark.unit + def test_create_token_with_expiry(self, tok_engine, tok_session): + """Creating a token with expires_in_days should set expires_at in the future.""" + from datetime import datetime, timezone + + from app.main import app + + client = _make_client(tok_engine) + try: + resp = client.post("/api/api-tokens/", json={"name": "With Expiry", "expires_in_days": 30}) + assert resp.status_code == 201 + data = resp.json() + assert data["expires_at"] is not None + # Parse the returned datetime; handle both tz-aware and tz-naive serialisations + expires_str = data["expires_at"].replace("Z", "+00:00") + expires_at = datetime.fromisoformat(expires_str) + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + now = datetime.now(timezone.utc) + delta_days = (expires_at - now).days + assert 28 <= delta_days <= 30 + finally: + _cleanup(app) + + @pytest.mark.unit + def test_expired_token_not_resolved(self, tok_engine, tok_session): + """A token past its expires_at should not authenticate.""" + from datetime import datetime, timedelta, timezone + from unittest.mock import MagicMock + + from app.api.api_tokens import generate_api_token, hash_token + from app.auth import _resolve_bearer_user + + plaintext = generate_api_token() + token_hash = hash_token(plaintext) + + db_token = ApiToken( + owner_id=_OWNER, + name="Expired Token", + token_hash=token_hash, + token_prefix=plaintext[:12], + is_active=True, + expires_at=datetime.now(timezone.utc) - timedelta(days=1), # expired yesterday + ) + tok_session.add(db_token) + tok_session.commit() + + mock_request = MagicMock() + mock_request.headers = {"authorization": f"Bearer {plaintext}"} + mock_request.client.host = "127.0.0.1" + + user = _resolve_bearer_user(mock_request, tok_session) + assert user is None + + @pytest.mark.unit + def test_non_expired_token_resolves(self, tok_engine, tok_session): + """A token before its expires_at should authenticate normally.""" + from datetime import datetime, timedelta, timezone + from unittest.mock import MagicMock + + from app.api.api_tokens import generate_api_token, hash_token + from app.auth import _resolve_bearer_user + + plaintext = generate_api_token() + token_hash = hash_token(plaintext) + + db_token = ApiToken( + owner_id=_OWNER, + name="Valid Token", + token_hash=token_hash, + token_prefix=plaintext[:12], + is_active=True, + expires_at=datetime.now(timezone.utc) + timedelta(days=30), # expires in 30 days + ) + tok_session.add(db_token) + tok_session.commit() + + mock_request = MagicMock() + mock_request.headers = {"authorization": f"Bearer {plaintext}"} + mock_request.client.host = "127.0.0.1" + + user = _resolve_bearer_user(mock_request, tok_session) + assert user is not None + assert user["preferred_username"] == _OWNER + + @pytest.mark.unit + def test_create_token_expires_in_days_zero_rejected(self, tok_engine): + """expires_in_days=0 should be rejected with 422 (ge=1).""" + from app.main import app + + client = _make_client(tok_engine) + try: + resp = client.post("/api/api-tokens/", json={"name": "Bad Expiry", "expires_in_days": 0}) + assert resp.status_code == 422 + finally: + _cleanup(app) + + @pytest.mark.unit + def test_expires_at_included_in_list_response(self, tok_engine): + """List endpoint should include expires_at field.""" + from app.main import app + + client = _make_client(tok_engine) + try: + client.post("/api/api-tokens/", json={"name": "Listed", "expires_in_days": 7}) + resp = client.get("/api/api-tokens/") + assert resp.status_code == 200 + tokens = resp.json() + assert len(tokens) == 1 + assert "expires_at" in tokens[0] + assert tokens[0]["expires_at"] is not None + finally: + _cleanup(app) diff --git a/tests/test_audit_logs.py b/tests/test_audit_logs.py index bbfd7c65..fc077fd6 100644 --- a/tests/test_audit_logs.py +++ b/tests/test_audit_logs.py @@ -5,12 +5,15 @@ Covers the audit service (recording, querying, SIEM forwarding), the REST API endpoints, and the admin viewer page. """ +import base64 import json import socket from datetime import datetime, timezone -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, PropertyMock, patch import pytest +from fastapi import HTTPException +from itsdangerous import TimestampSigner from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool @@ -645,6 +648,16 @@ class TestAuditLogAPI: # View tests # --------------------------------------------------------------------------- +_TEST_SESSION_SECRET = "test_secret_key_for_testing_must_be_at_least_32_characters_long" + + +def _make_admin_session_cookie() -> str: + """Create a signed session cookie with admin user data for integration tests.""" + session_data = {"user": {"id": "admin", "is_admin": True}} + signer = TimestampSigner(_TEST_SESSION_SECRET) + data = base64.b64encode(json.dumps(session_data).encode()).decode("utf-8") + return signer.sign(data).decode("utf-8") + @pytest.mark.integration class TestAuditLogView: @@ -655,3 +668,85 @@ class TestAuditLogView: resp = client.get("/admin/audit-logs") assert resp.status_code == 200 assert "Audit Logs" in resp.text + + def test_audit_logs_page_accessible_with_admin_session(self, client): + """GET /admin/audit-logs with admin session cookie returns 200.""" + client.cookies.set("session", _make_admin_session_cookie()) + resp = client.get("/admin/audit-logs", follow_redirects=False) + assert resp.status_code == 200 + assert "Audit Logs" in resp.text + + def test_audit_logs_page_redirects_non_admin(self, client): + """GET /admin/audit-logs without admin session redirects to home.""" + resp = client.get("/admin/audit-logs", follow_redirects=False) + assert resp.status_code == 302 + + +@pytest.mark.unit +class TestAuditLogsPageUnit: + """Unit tests for the audit_logs_page view function (lines 29-43).""" + + @patch("app.views.audit_logs.templates") + @patch("app.views.audit_logs.settings") + @pytest.mark.asyncio + async def test_audit_logs_page_siem_disabled(self, mock_settings, mock_templates): + """Renders the template with siem_transport=None when SIEM is disabled.""" + from app.views.audit_logs import audit_logs_page + + mock_settings.audit_siem_enabled = False + mock_settings.version = "2.0.0" + + mock_request = Mock() + mock_request.session = {"user": {"id": "admin", "is_admin": True}} + mock_db = Mock() + + await audit_logs_page(mock_request, mock_db) + + mock_templates.TemplateResponse.assert_called_once() + call_args = mock_templates.TemplateResponse.call_args + assert call_args[0][0] == "audit_logs.html" + context = call_args[0][1] + assert context["siem_enabled"] is False + assert context["siem_transport"] is None + assert context["app_version"] == "2.0.0" + + @patch("app.views.audit_logs.templates") + @patch("app.views.audit_logs.settings") + @pytest.mark.asyncio + async def test_audit_logs_page_siem_enabled(self, mock_settings, mock_templates): + """Renders the template with siem_transport set when SIEM is enabled.""" + from app.views.audit_logs import audit_logs_page + + mock_settings.audit_siem_enabled = True + mock_settings.audit_siem_transport = "syslog" + mock_settings.version = "2.0.0" + + mock_request = Mock() + mock_request.session = {"user": {"id": "admin", "is_admin": True}} + mock_db = Mock() + + await audit_logs_page(mock_request, mock_db) + + mock_templates.TemplateResponse.assert_called_once() + call_args = mock_templates.TemplateResponse.call_args + context = call_args[0][1] + assert context["siem_enabled"] is True + assert context["siem_transport"] == "syslog" + + @patch("app.views.audit_logs.settings") + @pytest.mark.asyncio + async def test_audit_logs_page_raises_500_on_error(self, mock_settings): + """Raises HTTP 500 when an unexpected error occurs while loading the page.""" + from app.views.audit_logs import audit_logs_page + + # Make accessing audit_siem_enabled raise an exception to trigger the except branch + type(mock_settings).audit_siem_enabled = PropertyMock(side_effect=RuntimeError("settings unavailable")) + + mock_request = Mock() + mock_request.session = {"user": {"id": "admin", "is_admin": True}} + mock_db = Mock() + + with pytest.raises(HTTPException) as exc_info: + await audit_logs_page(mock_request, mock_db) + assert exc_info.value.status_code == 500 + assert "Failed to load audit logs page" in exc_info.value.detail diff --git a/tests/test_auth_extended.py b/tests/test_auth_extended.py new file mode 100644 index 00000000..765b6672 --- /dev/null +++ b/tests/test_auth_extended.py @@ -0,0 +1,1801 @@ +"""Extended tests for app/auth.py to improve coverage. + +Covers: +- get_current_user with server-side session validation +- _resolve_bearer_user function +- get_current_user_id +- require_login Bearer token paths +- login() mobile redirect handling +- social_login() function +- _normalize_social_userinfo() function +- social_callback() function +- _ensure_user_profile admin update logic +- oauth_callback session token + mobile/onboarding paths +- _record_login_event exception handling +- _create_mobile_redirect() function +- auth() local user paths and multi-user mode +- auth() admin mobile redirect +- logout() session token revocation +""" + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import Request +from starlette.responses import RedirectResponse + +# --------------------------------------------------------------------------- +# get_current_user — server-side session validation +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetCurrentUserSessionValidation: + """Tests for get_current_user with server-side session tokens.""" + + def test_valid_server_session_returns_user(self): + """Valid _session_token should keep the user in session and return them.""" + from app.auth import get_current_user + + mock_request = MagicMock(spec=Request) + user = {"id": "u1", "preferred_username": "alice"} + mock_request.session = {"user": user, "_session_token": "valid-token"} + + mock_session_obj = MagicMock() # truthy — session is valid + + with ( + patch("app.database.SessionLocal") as mock_session_local, + patch("app.utils.session_manager.validate_session", return_value=mock_session_obj), + ): + mock_db = MagicMock() + mock_session_local.return_value = mock_db + + result = get_current_user(mock_request) + + assert result == user + + def test_invalid_server_session_clears_user(self): + """When validate_session returns None the session is cleared and None is returned.""" + from app.auth import get_current_user + + mock_request = MagicMock(spec=Request) + user = {"id": "u1", "preferred_username": "alice"} + mock_request.session = {"user": user, "_session_token": "expired-token"} + + with ( + patch("app.database.SessionLocal") as mock_session_local, + patch("app.utils.session_manager.validate_session", return_value=None), + ): + mock_db = MagicMock() + mock_session_local.return_value = mock_db + + result = get_current_user(mock_request) + + assert result is None + assert "user" not in mock_request.session + assert "_session_token" not in mock_request.session + + def test_session_validation_exception_returns_user(self): + """If validate_session raises, the error is swallowed and the user is returned.""" + from app.auth import get_current_user + + mock_request = MagicMock(spec=Request) + user = {"id": "u1", "preferred_username": "alice"} + mock_request.session = {"user": user, "_session_token": "token"} + + with ( + patch("app.database.SessionLocal") as mock_session_local, + patch("app.utils.session_manager.validate_session", side_effect=RuntimeError("db down")), + ): + mock_db = MagicMock() + mock_session_local.return_value = mock_db + + result = get_current_user(mock_request) + + # Exception must be swallowed; the user is still returned + assert result == user + + +# --------------------------------------------------------------------------- +# _resolve_bearer_user +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestResolveBearerUser: + """Tests for _resolve_bearer_user().""" + + def _make_db(self, token_obj=None): + """Return a mock db whose query chain yields *token_obj*.""" + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = token_obj + return mock_db + + def _make_request(self, auth_header: str = "", client_ip: str = "1.2.3.4"): + """Return a mock request with the given Authorization header.""" + mock_request = MagicMock(spec=Request) + mock_request.headers = {"authorization": auth_header, "x-forwarded-for": client_ip} + mock_request.client = MagicMock() + mock_request.client.host = client_ip + return mock_request + + def test_no_bearer_header_returns_none(self): + """Missing Authorization header returns None.""" + from app.auth import _resolve_bearer_user + + result = _resolve_bearer_user(self._make_request(), self._make_db()) + assert result is None + + def test_wrong_scheme_returns_none(self): + """Authorization header not starting with 'Bearer ' returns None.""" + from app.auth import _resolve_bearer_user + + result = _resolve_bearer_user(self._make_request("Basic abc123"), self._make_db()) + assert result is None + + def test_empty_token_after_prefix_returns_none(self): + """'Bearer ' with empty token returns None.""" + from app.auth import _resolve_bearer_user + + result = _resolve_bearer_user(self._make_request("Bearer "), self._make_db()) + assert result is None + + def test_no_matching_token_returns_none(self): + """Token hash not found in DB returns None.""" + from app.auth import _resolve_bearer_user + + with patch("app.api.api_tokens.hash_token", return_value="deadbeef"): + result = _resolve_bearer_user(self._make_request("Bearer mytoken"), self._make_db(None)) + + assert result is None + + def test_valid_token_returns_user_dict(self): + """Valid active token returns synthetic user dict.""" + from app.auth import _resolve_bearer_user + + mock_token = MagicMock() + mock_token.id = 1 + mock_token.owner_id = "alice@example.com" + mock_token.expires_at = None + mock_token.is_active = True + + mock_db = self._make_db(mock_token) + + with patch("app.api.api_tokens.hash_token", return_value="abc123"): + result = _resolve_bearer_user(self._make_request("Bearer plaintext"), mock_db) + + assert result is not None + assert result["id"] == "alice@example.com" + assert result["email"] == "alice@example.com" + assert result["is_admin"] is False + assert result["_api_token_id"] == 1 + + def test_expired_token_returns_none(self): + """Token past its expiry datetime is rejected.""" + from app.auth import _resolve_bearer_user + + expired = datetime(2020, 1, 1, tzinfo=timezone.utc) + mock_token = MagicMock() + mock_token.id = 2 + mock_token.owner_id = "bob@example.com" + mock_token.expires_at = expired + mock_token.is_active = True + + mock_db = self._make_db(mock_token) + + with patch("app.api.api_tokens.hash_token", return_value="abc123"): + result = _resolve_bearer_user(self._make_request("Bearer plaintext"), mock_db) + + assert result is None + + def test_expired_token_naive_datetime_returns_none(self): + """Token with timezone-naive expires_at in the past is rejected.""" + from app.auth import _resolve_bearer_user + + # naive datetime far in the past + expired_naive = datetime(2020, 1, 1) # no tzinfo + mock_token = MagicMock() + mock_token.id = 3 + mock_token.owner_id = "carol@example.com" + mock_token.expires_at = expired_naive + mock_token.is_active = True + + mock_db = self._make_db(mock_token) + + with patch("app.api.api_tokens.hash_token", return_value="abc123"): + result = _resolve_bearer_user(self._make_request("Bearer plaintext"), mock_db) + + assert result is None + + def test_future_expiry_token_accepted(self): + """Token with future expires_at is accepted.""" + from app.auth import _resolve_bearer_user + + future = datetime(2099, 1, 1, tzinfo=timezone.utc) + mock_token = MagicMock() + mock_token.id = 4 + mock_token.owner_id = "dave@example.com" + mock_token.expires_at = future + mock_token.is_active = True + + mock_db = self._make_db(mock_token) + + with patch("app.api.api_tokens.hash_token", return_value="abc123"): + result = _resolve_bearer_user(self._make_request("Bearer plaintext"), mock_db) + + assert result is not None + assert result["id"] == "dave@example.com" + + def test_update_tracking_exception_still_returns_user(self): + """If db.commit() fails during usage tracking, the token user is still returned.""" + from app.auth import _resolve_bearer_user + + mock_token = MagicMock() + mock_token.id = 5 + mock_token.owner_id = "eve@example.com" + mock_token.expires_at = None + mock_token.is_active = True + + mock_db = self._make_db(mock_token) + mock_db.commit.side_effect = Exception("DB write error") + + with patch("app.api.api_tokens.hash_token", return_value="abc123"): + result = _resolve_bearer_user(self._make_request("Bearer plaintext"), mock_db) + + assert result is not None + assert result["id"] == "eve@example.com" + mock_db.rollback.assert_called_once() + + def test_x_forwarded_for_header_used_for_ip(self): + """IP is extracted from X-Forwarded-For when present.""" + from app.auth import _resolve_bearer_user + + mock_token = MagicMock() + mock_token.id = 6 + mock_token.owner_id = "frank@example.com" + mock_token.expires_at = None + mock_token.is_active = True + + mock_db = self._make_db(mock_token) + + mock_request = MagicMock(spec=Request) + mock_request.headers = { + "authorization": "Bearer plaintoken", + "x-forwarded-for": "10.0.0.1, 192.168.1.1", + } + mock_request.client = MagicMock() + mock_request.client.host = "127.0.0.1" + + with patch("app.api.api_tokens.hash_token", return_value="abc123"): + result = _resolve_bearer_user(mock_request, mock_db) + + assert result is not None + assert mock_token.last_used_ip == "10.0.0.1" + + def test_no_client_and_no_forwarded_for(self): + """Falls back to None IP when no forwarded-for and no client.""" + from app.auth import _resolve_bearer_user + + mock_token = MagicMock() + mock_token.id = 7 + mock_token.owner_id = "grace@example.com" + mock_token.expires_at = None + mock_token.is_active = True + + mock_db = self._make_db(mock_token) + + mock_request = MagicMock(spec=Request) + mock_request.headers = {"authorization": "Bearer plaintoken", "x-forwarded-for": ""} + mock_request.client = None # no client + + with patch("app.api.api_tokens.hash_token", return_value="abc123"): + result = _resolve_bearer_user(mock_request, mock_db) + + assert result is not None + assert mock_token.last_used_ip is None + + def test_no_forwarded_for_but_client_exists(self): + """Falls back to request.client.host when x-forwarded-for is absent/empty.""" + from app.auth import _resolve_bearer_user + + mock_token = MagicMock() + mock_token.id = 8 + mock_token.owner_id = "holly@example.com" + mock_token.expires_at = None + mock_token.is_active = True + + mock_db = self._make_db(mock_token) + + mock_request = MagicMock(spec=Request) + mock_request.headers = {"authorization": "Bearer plaintoken", "x-forwarded-for": ""} + mock_client = MagicMock() + mock_client.host = "10.10.10.1" + mock_request.client = mock_client # client exists + + with patch("app.api.api_tokens.hash_token", return_value="abc123"): + result = _resolve_bearer_user(mock_request, mock_db) + + assert result is not None + assert mock_token.last_used_ip == "10.10.10.1" + + +# --------------------------------------------------------------------------- +# get_current_user_id +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetCurrentUserId: + """Tests for get_current_user_id().""" + + def test_returns_anonymous_when_no_user(self): + """Returns 'anonymous' when there is no session user.""" + from app.auth import get_current_user_id + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.state = MagicMock(spec=[]) + + result = get_current_user_id(mock_request) + assert result == "anonymous" + + def test_returns_preferred_username(self): + """Returns preferred_username when available.""" + from app.auth import get_current_user_id + + mock_request = MagicMock(spec=Request) + mock_request.session = {"user": {"preferred_username": "alice", "email": "a@e.com", "id": "1"}} + + result = get_current_user_id(mock_request) + assert result == "alice" + + def test_falls_back_to_email(self): + """Falls back to email when preferred_username is absent.""" + from app.auth import get_current_user_id + + mock_request = MagicMock(spec=Request) + mock_request.session = {"user": {"email": "bob@example.com", "id": "2"}} + + result = get_current_user_id(mock_request) + assert result == "bob@example.com" + + def test_falls_back_to_id(self): + """Falls back to id when preferred_username and email are absent.""" + from app.auth import get_current_user_id + + mock_request = MagicMock(spec=Request) + mock_request.session = {"user": {"id": "user-123"}} + + result = get_current_user_id(mock_request) + assert result == "user-123" + + def test_returns_anonymous_when_all_fields_missing(self): + """Returns 'anonymous' when user dict has no identifier fields.""" + from app.auth import get_current_user_id + + mock_request = MagicMock(spec=Request) + mock_request.session = {"user": {"name": "No Fields"}} + + result = get_current_user_id(mock_request) + assert result == "anonymous" + + +# --------------------------------------------------------------------------- +# require_login — Bearer token paths +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestRequireLoginBearer: + """Tests for require_login Bearer token authentication on /api/ paths.""" + + @pytest.mark.asyncio + async def test_bearer_token_auth_allows_async_endpoint(self): + """Valid Bearer token authenticates an async /api/ endpoint.""" + with patch("app.auth.AUTH_ENABLED", True): + from app.auth import require_login + + @require_login + async def api_endpoint(request: Request): + return {"user": request.state.api_token_user} + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.url = MagicMock() + mock_request.url.__str__ = MagicMock(return_value="http://test.com/api/files") + + api_user = {"id": "tok_user", "email": "tok_user"} + + with ( + patch("app.database.SessionLocal") as mock_sl, + patch("app.auth._resolve_bearer_user", return_value=api_user), + ): + mock_db = MagicMock() + mock_sl.return_value = mock_db + + result = await api_endpoint(mock_request) + + assert result["user"] == api_user + assert mock_request.state.api_token_user == api_user + + @pytest.mark.asyncio + async def test_bearer_token_auth_allows_sync_endpoint(self): + """Valid Bearer token authenticates a sync /api/ endpoint.""" + with patch("app.auth.AUTH_ENABLED", True): + from app.auth import require_login + + @require_login + def api_endpoint(request: Request): + return {"user": request.state.api_token_user} + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.url = MagicMock() + mock_request.url.__str__ = MagicMock(return_value="http://test.com/api/upload") + + api_user = {"id": "tok_user_sync", "email": "tok_user_sync"} + + with ( + patch("app.database.SessionLocal") as mock_sl, + patch("app.auth._resolve_bearer_user", return_value=api_user), + ): + mock_db = MagicMock() + mock_sl.return_value = mock_db + + result = await api_endpoint(mock_request) + + assert result["user"] == api_user + + @pytest.mark.asyncio + async def test_bearer_db_exception_returns_401(self): + """Exception during db setup falls back to 401 for /api/ paths.""" + from fastapi.responses import JSONResponse + + with patch("app.auth.AUTH_ENABLED", True): + from app.auth import require_login + + @require_login + async def api_endpoint(request: Request): + return {"ok": True} + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.url = MagicMock() + mock_request.url.__str__ = MagicMock(return_value="http://test.com/api/files") + + with patch("app.database.SessionLocal", side_effect=Exception("db error")): + result = await api_endpoint(mock_request) + + assert isinstance(result, JSONResponse) + assert result.status_code == 401 + + +# --------------------------------------------------------------------------- +# login() — mobile redirect handling +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestLoginMobileRedirect: + """Tests for the mobile redirect handling inside login().""" + + @pytest.mark.asyncio + async def test_mobile_flag_with_docuelevate_scheme_stores_uri(self): + """mobile=1 with a docuelevate:// redirect_uri stores it in the session.""" + from app.auth import login + + mock_request = MagicMock(spec=Request) + mock_request.query_params = {"mobile": "1", "redirect_uri": "docuelevate://callback"} + mock_request.session = {} + + with patch("app.auth.templates") as mock_tpl: + mock_tpl.TemplateResponse.return_value = "page" + await login(mock_request) + + assert mock_request.session.get("mobile_redirect_uri") == "docuelevate://callback" + + @pytest.mark.asyncio + async def test_mobile_flag_with_exp_scheme_stores_uri(self): + """mobile=1 with an exp:// redirect_uri (Expo Go) stores it in the session.""" + from app.auth import login + + mock_request = MagicMock(spec=Request) + mock_request.query_params = {"mobile": "1", "redirect_uri": "exp://192.168.1.1:19000/--/auth"} + mock_request.session = {} + + with patch("app.auth.templates") as mock_tpl: + mock_tpl.TemplateResponse.return_value = "page" + await login(mock_request) + + assert mock_request.session.get("mobile_redirect_uri") == "exp://192.168.1.1:19000/--/auth" + + @pytest.mark.asyncio + async def test_mobile_flag_with_http_scheme_is_rejected(self): + """mobile=1 with an http:// redirect_uri (open-redirect risk) is not stored.""" + from app.auth import login + + mock_request = MagicMock(spec=Request) + mock_request.query_params = {"mobile": "1", "redirect_uri": "http://evil.example.com/phish"} + mock_request.session = {} + + with patch("app.auth.templates") as mock_tpl: + mock_tpl.TemplateResponse.return_value = "page" + await login(mock_request) + + assert "mobile_redirect_uri" not in mock_request.session + + @pytest.mark.asyncio + async def test_no_mobile_flag_does_not_set_uri(self): + """Standard browser login (no mobile=1) does not store a mobile redirect URI.""" + from app.auth import login + + mock_request = MagicMock(spec=Request) + mock_request.query_params = {} + mock_request.session = {} + + with patch("app.auth.templates") as mock_tpl: + mock_tpl.TemplateResponse.return_value = "page" + await login(mock_request) + + assert "mobile_redirect_uri" not in mock_request.session + + @pytest.mark.asyncio + async def test_mobile_flag_with_empty_redirect_uri_is_rejected(self): + """mobile=1 with an empty redirect_uri is silently ignored.""" + from app.auth import login + + mock_request = MagicMock(spec=Request) + mock_request.query_params = {"mobile": "1", "redirect_uri": ""} + mock_request.session = {} + + with patch("app.auth.templates") as mock_tpl: + mock_tpl.TemplateResponse.return_value = "page" + await login(mock_request) + + assert "mobile_redirect_uri" not in mock_request.session + + +# --------------------------------------------------------------------------- +# social_login() +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestSocialLogin: + """Tests for social_login().""" + + @pytest.mark.asyncio + async def test_unknown_provider_redirects(self): + """Unknown social provider returns redirect to /login?error=...""" + from app.auth import social_login + + mock_request = MagicMock(spec=Request) + + with patch("app.auth.SOCIAL_PROVIDERS", {}): + result = await social_login(mock_request, provider="unknown") + + assert isinstance(result, RedirectResponse) + assert "Unknown+social+provider" in result.headers["location"] + + @pytest.mark.asyncio + async def test_registered_provider_oauth_client_missing_redirects(self): + """Provider is registered but OAuth client not initialised → redirect.""" + from app.auth import social_login + + mock_request = MagicMock(spec=Request) + mock_request.url_for = MagicMock(return_value="http://localhost/social-callback/google") + + fake_providers = {"google": {"name": "Google", "icon": "fab fa-google", "color": "red"}} + + mock_oauth = MagicMock(spec=[]) # no attributes + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + ): + result = await social_login(mock_request, provider="google") + + assert isinstance(result, RedirectResponse) + assert "Provider+not+configured" in result.headers["location"] + + @pytest.mark.asyncio + async def test_registered_provider_initiates_oauth(self): + """Registered provider with OAuth client initiates the OAuth redirect.""" + from app.auth import social_login + + mock_request = MagicMock(spec=Request) + mock_request.url_for = MagicMock(return_value="http://localhost/social-callback/google") + + fake_providers = {"google": {"name": "Google", "icon": "fab fa-google", "color": "red"}} + + mock_google_client = MagicMock() + mock_google_client.authorize_redirect = AsyncMock(return_value="oauth_redirect") + + mock_oauth = MagicMock() + mock_oauth.google = mock_google_client + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + ): + result = await social_login(mock_request, provider="google") + + assert result == "oauth_redirect" + mock_google_client.authorize_redirect.assert_called_once() + + +# --------------------------------------------------------------------------- +# _normalize_social_userinfo() +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestNormalizeSocialUserinfo: + """Tests for _normalize_social_userinfo().""" + + def test_dropbox_provider_normalisation(self): + """Dropbox userinfo is mapped to the common format.""" + from app.auth import _normalize_social_userinfo + + raw = { + "account_id": "dbid:ABC123", + "email": "dan@example.com", + "name": {"display_name": "Dan Dropbox"}, + "profile_photo_url": "https://cdn.dropbox.com/photo.jpg", + } + + result = _normalize_social_userinfo("dropbox", {}, raw) + + assert result["sub"] == "dbid:ABC123" + assert result["email"] == "dan@example.com" + assert result["name"] == "Dan Dropbox" + assert result["preferred_username"] == "dan@example.com" + assert result["picture"] == "https://cdn.dropbox.com/photo.jpg" + + def test_dropbox_string_name_field(self): + """Dropbox userinfo with a string (not dict) name field is handled.""" + from app.auth import _normalize_social_userinfo + + raw = { + "account_id": "dbid:XYZ", + "email": "eve@example.com", + "name": "Eve String", # string instead of dict + } + + result = _normalize_social_userinfo("dropbox", {}, raw) + assert result["name"] == "Eve String" + + def test_dropbox_no_account_id_falls_back_to_email(self): + """Dropbox sub falls back to email when account_id is absent.""" + from app.auth import _normalize_social_userinfo + + raw = {"email": "frank@example.com"} + result = _normalize_social_userinfo("dropbox", {}, raw) + assert result["sub"] == "frank@example.com" + + def test_google_provider_normalisation(self): + """Standard OIDC (Google) userinfo is mapped to the common format.""" + from app.auth import _normalize_social_userinfo + + raw = { + "sub": "google-sub-123", + "email": "grace@gmail.com", + "name": "Grace Google", + "picture": "https://lh3.googleusercontent.com/photo.jpg", + } + + result = _normalize_social_userinfo("google", {}, raw) + + assert result["sub"] == "google-sub-123" + assert result["email"] == "grace@gmail.com" + assert result["preferred_username"] == "grace@gmail.com" + assert result["picture"] == "https://lh3.googleusercontent.com/photo.jpg" + + def test_standard_provider_empty_userinfo(self): + """Standard OIDC provider with empty userinfo returns empty strings.""" + from app.auth import _normalize_social_userinfo + + result = _normalize_social_userinfo("microsoft", {}, {}) + + assert result["sub"] == "" + assert result["email"] == "" + assert result["preferred_username"] == "" + + def test_none_raw_userinfo_treated_as_empty(self): + """raw_userinfo=None is treated as an empty dict.""" + from app.auth import _normalize_social_userinfo + + result = _normalize_social_userinfo("google", {}, None) + + assert result["sub"] == "" + assert result["email"] == "" + + +# --------------------------------------------------------------------------- +# social_callback() +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestSocialCallback: + """Tests for social_callback().""" + + @pytest.mark.asyncio + async def test_unknown_provider_redirects(self): + """Unknown provider returns immediate redirect.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_db = MagicMock() + + with patch("app.auth.SOCIAL_PROVIDERS", {}): + result = await social_callback(mock_request, provider="unknown", db=mock_db) + + assert isinstance(result, RedirectResponse) + assert "Unknown+social+provider" in result.headers["location"] + + @pytest.mark.asyncio + async def test_provider_not_configured_redirects(self): + """Registered provider with no OAuth client returns redirect.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_db = MagicMock() + + fake_providers = {"google": {}} + mock_oauth = MagicMock(spec=[]) + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + ): + result = await social_callback(mock_request, provider="google", db=mock_db) + + assert isinstance(result, RedirectResponse) + assert "Provider+not+configured" in result.headers["location"] + + @pytest.mark.asyncio + async def test_success_stores_user_in_session(self): + """Successful callback stores user in session and redirects.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.headers = {"user-agent": "TestBrowser/1.0"} + mock_db = MagicMock() + + token = {"userinfo": {"sub": "g-sub", "email": "g@gmail.com", "name": "Google User"}} + mock_client = MagicMock() + mock_client.authorize_access_token = AsyncMock(return_value=token) + + fake_providers = {"google": {"name": "Google", "icon": "", "color": "red"}} + mock_oauth = MagicMock() + mock_oauth.google = mock_client + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.utils.session_manager.create_session") as mock_create_session, + ): + mock_user_session = MagicMock() + mock_user_session.session_token = "tok-abc" + mock_create_session.return_value = mock_user_session + + result = await social_callback(mock_request, provider="google", db=mock_db) + + assert isinstance(result, RedirectResponse) + assert "user" in mock_request.session + assert mock_request.session["user"]["email"] == "g@gmail.com" + + @pytest.mark.asyncio + async def test_no_email_redirects(self): + """Callback with user data missing email returns error redirect.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_db = MagicMock() + + token = {"userinfo": {"sub": "g-sub", "name": "No Email"}} + mock_client = MagicMock() + mock_client.authorize_access_token = AsyncMock(return_value=token) + + fake_providers = {"google": {"name": "Google", "icon": "", "color": "red"}} + mock_oauth = MagicMock() + mock_oauth.google = mock_client + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + ): + result = await social_callback(mock_request, provider="google", db=mock_db) + + assert isinstance(result, RedirectResponse) + assert "Could+not+retrieve+email" in result.headers["location"] + + @pytest.mark.asyncio + async def test_exception_in_callback_returns_error_redirect(self): + """Unhandled exception during callback returns error redirect.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_db = MagicMock() + + mock_client = MagicMock() + mock_client.authorize_access_token = AsyncMock(side_effect=Exception("provider error")) + + fake_providers = {"google": {"name": "Google", "icon": "", "color": "red"}} + mock_oauth = MagicMock() + mock_oauth.google = mock_client + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + ): + result = await social_callback(mock_request, provider="google", db=mock_db) + + assert isinstance(result, RedirectResponse) + assert "Social+login+failed" in result.headers["location"] + + @pytest.mark.asyncio + async def test_userinfo_fetched_from_endpoint_when_not_in_token(self): + """When userinfo is not embedded in the token, it's fetched from the endpoint.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.headers = {"user-agent": "TestBrowser/1.0"} + mock_db = MagicMock() + + # Token without embedded userinfo + token = {} + userinfo_resp = {"sub": "g-sub", "email": "fetch@gmail.com", "name": "Fetched User"} + + mock_client = MagicMock() + mock_client.authorize_access_token = AsyncMock(return_value=token) + mock_client.userinfo = AsyncMock(return_value=userinfo_resp) + + fake_providers = {"google": {"name": "Google", "icon": "", "color": "red"}} + mock_oauth = MagicMock() + mock_oauth.google = mock_client + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.utils.session_manager.create_session"), + ): + result = await social_callback(mock_request, provider="google", db=mock_db) + + assert mock_request.session["user"]["email"] == "fetch@gmail.com" + + @pytest.mark.asyncio + async def test_userinfo_endpoint_exception_falls_back_to_empty(self): + """If the userinfo endpoint raises, an empty dict is used and missing email → redirect.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_db = MagicMock() + + token = {} # no userinfo embedded + mock_client = MagicMock() + mock_client.authorize_access_token = AsyncMock(return_value=token) + mock_client.userinfo = AsyncMock(side_effect=Exception("endpoint error")) + + fake_providers = {"google": {"name": "Google", "icon": "", "color": "red"}} + mock_oauth = MagicMock() + mock_oauth.google = mock_client + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + ): + result = await social_callback(mock_request, provider="google", db=mock_db) + + # no email → redirect with error + assert isinstance(result, RedirectResponse) + assert "Could+not+retrieve+email" in result.headers["location"] + + @pytest.mark.asyncio + async def test_social_callback_adds_gravatar_when_no_picture(self): + """social_callback adds a Gravatar URL when the provider returns no picture.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.headers = {} + mock_db = MagicMock() + + token = {"userinfo": {"sub": "g-sub", "email": "nopic@gmail.com", "name": "No Pic"}} + mock_client = MagicMock() + mock_client.authorize_access_token = AsyncMock(return_value=token) + + fake_providers = {"google": {"name": "Google", "icon": "", "color": "red"}} + mock_oauth = MagicMock() + mock_oauth.google = mock_client + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.utils.session_manager.create_session"), + ): + await social_callback(mock_request, provider="google", db=mock_db) + + assert mock_request.session["user"].get("picture", "").startswith("https://www.gravatar.com/") + + @pytest.mark.asyncio + async def test_social_callback_session_token_exception(self): + """Exception during server-side session creation is swallowed.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.headers = {} + mock_db = MagicMock() + + token = {"userinfo": {"sub": "g-sub", "email": "exc@gmail.com"}} + mock_client = MagicMock() + mock_client.authorize_access_token = AsyncMock(return_value=token) + + fake_providers = {"google": {"name": "Google", "icon": "", "color": "red"}} + mock_oauth = MagicMock() + mock_oauth.google = mock_client + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.utils.session_manager.create_session", side_effect=Exception("session error")), + ): + result = await social_callback(mock_request, provider="google", db=mock_db) + + # Should not crash; user still in session + assert "user" in mock_request.session + + @pytest.mark.asyncio + async def test_social_callback_mobile_redirect(self): + """When mobile redirect URI is in session, the mobile redirect is returned.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.headers = {} + mock_db = MagicMock() + + token = {"userinfo": {"sub": "g-sub", "email": "mob@gmail.com"}} + mock_client = MagicMock() + mock_client.authorize_access_token = AsyncMock(return_value=token) + + fake_providers = {"google": {"name": "Google", "icon": "", "color": "red"}} + mock_oauth = MagicMock() + mock_oauth.google = mock_client + + mobile_resp = RedirectResponse(url="docuelevate://callback?token=abc", status_code=302) + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=mobile_resp), + patch("app.utils.session_manager.create_session"), + ): + result = await social_callback(mock_request, provider="google", db=mock_db) + + assert result is mobile_resp + + @pytest.mark.asyncio + async def test_social_callback_onboarding_redirect(self): + """First-time users who haven't completed onboarding are sent to /onboarding.""" + from app.auth import social_callback + from app.models import UserProfile + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.headers = {} + mock_db = MagicMock() + + # Simulate incomplete onboarding + profile = UserProfile(user_id="g-sub", onboarding_completed=False) + mock_db.query.return_value.filter.return_value.first.return_value = profile + + token = {"userinfo": {"sub": "g-sub", "email": "new@gmail.com"}} + mock_client = MagicMock() + mock_client.authorize_access_token = AsyncMock(return_value=token) + + fake_providers = {"google": {"name": "Google", "icon": "", "color": "red"}} + mock_oauth = MagicMock() + mock_oauth.google = mock_client + + with ( + patch("app.auth.SOCIAL_PROVIDERS", fake_providers), + patch("app.auth.oauth", mock_oauth), + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.utils.session_manager.create_session"), + ): + result = await social_callback(mock_request, provider="google", db=mock_db) + + assert isinstance(result, RedirectResponse) + assert result.headers["location"] == "/onboarding" + + +# --------------------------------------------------------------------------- +# _ensure_user_profile — admin update logic +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestEnsureUserProfileAdminUpdate: + """Tests for _ensure_user_profile() admin-specific update logic.""" + + def test_admin_sets_complimentary_and_upgrades_free_tier(self): + """Existing admin profile with is_complimentary=False and free tier gets upgraded.""" + from app.auth import _ensure_user_profile + from app.models import UserProfile + + mock_db = MagicMock() + existing = UserProfile(user_id="admin-u", subscription_tier="free", is_complimentary=False) + mock_db.query.return_value.filter.return_value.first.return_value = existing + + with patch("app.utils.subscription.TIER_ORDER", ["free", "pro", "enterprise"]): + _ensure_user_profile(mock_db, {"sub": "admin-u"}, is_admin=True) + + assert existing.is_complimentary is True + assert existing.subscription_tier == "enterprise" + mock_db.commit.assert_called_once() + + def test_admin_already_complimentary_no_free_tier_no_commit(self): + """Existing admin profile that is already complimentary on a paid tier is left alone.""" + from app.auth import _ensure_user_profile + from app.models import UserProfile + + mock_db = MagicMock() + existing = UserProfile(user_id="admin-u2", subscription_tier="enterprise", is_complimentary=True) + mock_db.query.return_value.filter.return_value.first.return_value = existing + + with patch("app.utils.subscription.TIER_ORDER", ["free", "pro", "enterprise"]): + _ensure_user_profile(mock_db, {"sub": "admin-u2"}, is_admin=True) + + mock_db.commit.assert_not_called() + + def test_admin_complimentary_but_on_free_tier_upgrades(self): + """Admin profile that is complimentary but still on 'free' tier gets upgraded.""" + from app.auth import _ensure_user_profile + from app.models import UserProfile + + mock_db = MagicMock() + existing = UserProfile(user_id="admin-u3", subscription_tier="free", is_complimentary=True) + mock_db.query.return_value.filter.return_value.first.return_value = existing + + with patch("app.utils.subscription.TIER_ORDER", ["free", "pro", "enterprise"]): + _ensure_user_profile(mock_db, {"sub": "admin-u3"}, is_admin=True) + + assert existing.subscription_tier == "enterprise" + mock_db.commit.assert_called_once() + + +# --------------------------------------------------------------------------- +# oauth_callback — session token + mobile/onboarding +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestOAuthCallbackExtended: + """Additional tests for oauth_callback().""" + + @pytest.mark.asyncio + async def test_session_token_stored_when_sub_present(self): + """oauth_callback stores server-side session token in session dict.""" + from app.auth import oauth_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.headers = {"user-agent": "Test/1.0"} + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = None # no profile + + userinfo = {"sub": "oidc-sub", "email": "oidc@example.com"} + mock_authentik = MagicMock() + mock_authentik.authorize_access_token = AsyncMock(return_value={"userinfo": userinfo}) + + mock_session_obj = MagicMock() + mock_session_obj.session_token = "server-side-tok" + + with ( + patch("app.auth.oauth") as mock_oauth, + patch("app.auth.settings") as mock_settings, + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.auth._record_login_event"), + patch("app.utils.session_manager.create_session", return_value=mock_session_obj), + ): + mock_oauth.authentik = mock_authentik + mock_settings.admin_group_name = "admin" + + await oauth_callback(mock_request, db=mock_db) + + assert mock_request.session.get("_session_token") == "server-side-tok" + + @pytest.mark.asyncio + async def test_session_token_exception_is_swallowed(self): + """Exception during server-side session creation is logged and swallowed.""" + from app.auth import oauth_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.headers = {} + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = None + + userinfo = {"sub": "oidc-sub2", "email": "oidc2@example.com"} + mock_authentik = MagicMock() + mock_authentik.authorize_access_token = AsyncMock(return_value={"userinfo": userinfo}) + + with ( + patch("app.auth.oauth") as mock_oauth, + patch("app.auth.settings") as mock_settings, + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.auth._record_login_event"), + patch("app.utils.session_manager.create_session", side_effect=Exception("session create failed")), + ): + mock_oauth.authentik = mock_authentik + mock_settings.admin_group_name = "admin" + + result = await oauth_callback(mock_request, db=mock_db) + + # Should complete without crashing and redirect + assert isinstance(result, RedirectResponse) + + @pytest.mark.asyncio + async def test_oauth_callback_mobile_redirect_returned(self): + """oauth_callback returns mobile redirect when mobile_redirect_uri is in session.""" + from app.auth import oauth_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.headers = {} + mock_db = MagicMock() + + userinfo = {"sub": "mob-sub", "email": "mob@example.com"} + mock_authentik = MagicMock() + mock_authentik.authorize_access_token = AsyncMock(return_value={"userinfo": userinfo}) + + mobile_resp = RedirectResponse(url="docuelevate://callback?token=xyz", status_code=302) + + with ( + patch("app.auth.oauth") as mock_oauth, + patch("app.auth.settings") as mock_settings, + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=mobile_resp), + patch("app.auth._record_login_event"), + patch("app.utils.session_manager.create_session"), + ): + mock_oauth.authentik = mock_authentik + mock_settings.admin_group_name = "admin" + + result = await oauth_callback(mock_request, db=mock_db) + + assert result is mobile_resp + + @pytest.mark.asyncio + async def test_oauth_callback_onboarding_redirect(self): + """oauth_callback sends first-time users to /onboarding.""" + from app.auth import oauth_callback + from app.models import UserProfile + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.headers = {} + mock_db = MagicMock() + + profile = UserProfile(user_id="new-sub", onboarding_completed=False) + mock_db.query.return_value.filter.return_value.first.return_value = profile + + userinfo = {"sub": "new-sub", "email": "new@example.com"} + mock_authentik = MagicMock() + mock_authentik.authorize_access_token = AsyncMock(return_value={"userinfo": userinfo}) + + with ( + patch("app.auth.oauth") as mock_oauth, + patch("app.auth.settings") as mock_settings, + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.auth._record_login_event"), + patch("app.utils.session_manager.create_session"), + ): + mock_oauth.authentik = mock_authentik + mock_settings.admin_group_name = "admin" + + result = await oauth_callback(mock_request, db=mock_db) + + assert isinstance(result, RedirectResponse) + assert result.headers["location"] == "/onboarding" + + +# --------------------------------------------------------------------------- +# _record_login_event — exception handling +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestRecordLoginEvent: + """Tests for _record_login_event().""" + + def test_exception_in_record_event_is_swallowed(self): + """If record_event raises, _record_login_event swallows the error.""" + from app.auth import _record_login_event + + mock_db = MagicMock() + mock_request = MagicMock(spec=Request) + mock_request.headers = {} + mock_request.client = None + + with patch("app.utils.audit_service.record_event", side_effect=Exception("audit DB down")): + # Must not raise + _record_login_event(mock_db, mock_request, "alice", success=True) + + def test_records_successful_login(self): + """Successful login emits a 'login' event with method and no reason.""" + from app.auth import _record_login_event + + mock_db = MagicMock() + mock_request = MagicMock(spec=Request) + mock_request.headers = {"x-forwarded-for": ""} + mock_request.client = None + + with patch("app.utils.audit_service.record_event") as mock_record: + _record_login_event(mock_db, mock_request, "bob", success=True, method="local") + + mock_record.assert_called_once() + call_kwargs = mock_record.call_args[1] + assert call_kwargs["action"] == "login" + assert call_kwargs["severity"] == "info" + + def test_records_failed_login_with_detail(self): + """Failed login emits a 'login.failure' event with the detail reason.""" + from app.auth import _record_login_event + + mock_db = MagicMock() + mock_request = MagicMock(spec=Request) + mock_request.headers = {"x-forwarded-for": ""} + mock_request.client = None + + with patch("app.utils.audit_service.record_event") as mock_record: + _record_login_event(mock_db, mock_request, "carol", success=False, detail="wrong_password") + + mock_record.assert_called_once() + call_kwargs = mock_record.call_args[1] + assert call_kwargs["action"] == "login.failure" + assert call_kwargs["severity"] == "warning" + assert call_kwargs["details"]["reason"] == "wrong_password" + + +# --------------------------------------------------------------------------- +# _create_mobile_redirect() +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestCreateMobileRedirect: + """Tests for _create_mobile_redirect().""" + + def test_returns_none_when_no_redirect_uri(self): + """Returns None when no mobile_redirect_uri is in session.""" + from app.auth import _create_mobile_redirect + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_db = MagicMock() + + result = _create_mobile_redirect(mock_request, mock_db) + assert result is None + + def test_returns_none_when_no_owner_id(self): + """Returns None when user in session has no usable owner identifier.""" + from app.auth import _create_mobile_redirect + + mock_request = MagicMock(spec=Request) + mock_request.session = { + "mobile_redirect_uri": "docuelevate://callback", + "user": {}, # no sub/preferred_username/email/id + } + mock_db = MagicMock() + + result = _create_mobile_redirect(mock_request, mock_db) + assert result is None + + def test_success_returns_redirect_with_token(self): + """Successful mobile redirect creates token and returns RedirectResponse.""" + from app.auth import _create_mobile_redirect + + mock_request = MagicMock(spec=Request) + mock_request.session = { + "mobile_redirect_uri": "docuelevate://callback", + "user": {"sub": "mob-user"}, + } + mock_request.headers = {} + mock_db = MagicMock() + + mock_token_obj = MagicMock() + mock_token_obj.id = 99 + mock_db.add = MagicMock() + mock_db.commit = MagicMock() + + with ( + patch("app.api.api_tokens.generate_api_token", return_value="plaintexttoken12345"), + patch("app.api.api_tokens.hash_token", return_value="hashvalue"), + ): + result = _create_mobile_redirect(mock_request, mock_db) + + assert isinstance(result, RedirectResponse) + location = result.headers["location"] + assert "docuelevate://callback" in location + assert "token=" in location + + def test_db_commit_exception_returns_none(self): + """If db.commit() raises when creating the mobile token, returns None.""" + from app.auth import _create_mobile_redirect + + mock_request = MagicMock(spec=Request) + mock_request.session = { + "mobile_redirect_uri": "docuelevate://callback", + "user": {"email": "mob@example.com"}, + } + mock_db = MagicMock() + mock_db.commit.side_effect = Exception("commit failed") + + with ( + patch("app.api.api_tokens.generate_api_token", return_value="plaintext12345"), + patch("app.api.api_tokens.hash_token", return_value="hashvalue"), + ): + result = _create_mobile_redirect(mock_request, mock_db) + + assert result is None + mock_db.rollback.assert_called_once() + + def test_existing_query_params_preserved(self): + """Token is appended correctly when the redirect URI already has query params.""" + from app.auth import _create_mobile_redirect + + mock_request = MagicMock(spec=Request) + mock_request.session = { + "mobile_redirect_uri": "docuelevate://callback?existing=1", + "user": {"sub": "user1"}, + } + mock_request.headers = {} + mock_db = MagicMock() + + with ( + patch("app.api.api_tokens.generate_api_token", return_value="plaintoken"), + patch("app.api.api_tokens.hash_token", return_value="hashval"), + ): + result = _create_mobile_redirect(mock_request, mock_db) + + location = result.headers["location"] + # Should use "&" separator since "?" already present + assert "&token=" in location + + +# --------------------------------------------------------------------------- +# auth() — multi-user and local user paths +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestAuthLocalUserPaths: + """Tests for auth() covering LocalUser authentication and multi-user mode.""" + + def _make_local_user( + self, + *, + email: str = "alice@example.com", + username: str = "alice", + is_active: bool = True, + is_admin: bool = False, + hashed_password: str = "hashed", # noqa: S107 + ): + """Build a mock LocalUser.""" + user = MagicMock() + user.email = email + user.username = username + user.is_active = is_active + user.is_admin = is_admin + user.hashed_password = hashed_password + return user + + def _make_db(self, local_user=None): + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = local_user + return mock_db + + @pytest.mark.asyncio + async def test_empty_username_in_multi_user_mode_redirects(self): + """Empty username in multi-user mode returns error redirect.""" + from app.auth import auth + + mock_request = MagicMock(spec=Request) + mock_request.form = AsyncMock(return_value={}) # no username + mock_request.session = {} + + with patch("app.auth.settings") as mock_settings: + mock_settings.multi_user_enabled = True + mock_settings.admin_username = "admin" + mock_settings.admin_password = "adminpass" + + result = await auth(mock_request, db=self._make_db(None)) + + assert isinstance(result, RedirectResponse) + assert "Invalid+username+or+password" in result.headers["location"] + + @pytest.mark.asyncio + async def test_inactive_local_user_redirects_with_verify_email_message(self): + """Inactive (unverified) local user gets an error asking for email verification.""" + from app.auth import auth + + local_user = self._make_local_user(is_active=False) + mock_request = MagicMock(spec=Request) + mock_request.form = AsyncMock(return_value={"username": "alice", "password": "pw"}) + mock_request.session = {} + + with ( + patch("app.auth.settings") as mock_settings, + patch("app.auth._record_login_event"), + ): + mock_settings.multi_user_enabled = True + mock_settings.admin_username = "admin" + mock_settings.admin_password = "adminpass" + + result = await auth(mock_request, db=self._make_db(local_user)) + + assert isinstance(result, RedirectResponse) + assert "verify+your+email" in result.headers["location"] + + @pytest.mark.asyncio + async def test_wrong_password_for_local_user_redirects(self): + """Wrong password for a valid local user returns error redirect.""" + from app.auth import auth + + local_user = self._make_local_user() + mock_request = MagicMock(spec=Request) + mock_request.form = AsyncMock(return_value={"username": "alice", "password": "wrongpw"}) + mock_request.session = {} + + with ( + patch("app.auth.settings") as mock_settings, + patch("app.auth._verify_password", return_value=False), + patch("app.auth._record_login_event"), + ): + mock_settings.multi_user_enabled = True + mock_settings.admin_username = "admin" + mock_settings.admin_password = "adminpass" + + result = await auth(mock_request, db=self._make_db(local_user)) + + assert isinstance(result, RedirectResponse) + assert "Invalid+username+or+password" in result.headers["location"] + + @pytest.mark.asyncio + async def test_successful_local_user_login(self): + """Correct password for a local user sets session and redirects.""" + from app.auth import auth + + local_user = self._make_local_user() + mock_request = MagicMock(spec=Request) + mock_request.form = AsyncMock(return_value={"username": "alice", "password": "correctpw"}) + mock_request.session = {} + mock_request.headers = {} + + mock_session_obj = MagicMock() + mock_session_obj.session_token = "local-session-tok" + profile = MagicMock() + profile.onboarding_completed = True + + def db_query_side_effect(model): + """Return appropriate mock based on model being queried.""" + mock_q = MagicMock() + mock_q.filter.return_value.first.return_value = profile + return mock_q + + mock_db = MagicMock() + mock_db.query.side_effect = db_query_side_effect + + with ( + patch("app.auth.settings") as mock_settings, + patch("app.auth._verify_password", return_value=True), + patch("app.auth._build_session_user", return_value={"id": "alice", "email": "alice@example.com"}), + patch("app.auth._record_login_event"), + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.utils.session_manager.create_session", return_value=mock_session_obj), + ): + mock_settings.multi_user_enabled = True + mock_settings.admin_username = "admin" + mock_settings.admin_password = "adminpass" + + result = await auth(mock_request, db=mock_db) + + assert isinstance(result, RedirectResponse) + assert "user" in mock_request.session + assert mock_request.session.get("_session_token") == "local-session-tok" + + @pytest.mark.asyncio + async def test_local_user_mobile_redirect(self): + """Successful local user login returns mobile redirect when URI is in session.""" + from app.auth import auth + + local_user = self._make_local_user() + mock_request = MagicMock(spec=Request) + mock_request.form = AsyncMock(return_value={"username": "alice", "password": "pw"}) + mock_request.session = {} + mock_request.headers = {} + + mobile_resp = RedirectResponse(url="docuelevate://callback?token=tok", status_code=302) + + mock_db = MagicMock() + + with ( + patch("app.auth.settings") as mock_settings, + patch("app.auth._verify_password", return_value=True), + patch("app.auth._build_session_user", return_value={"id": "alice"}), + patch("app.auth._record_login_event"), + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=mobile_resp), + patch("app.utils.session_manager.create_session"), + ): + mock_settings.multi_user_enabled = True + mock_settings.admin_username = "admin" + mock_settings.admin_password = "adminpass" + mock_db.query.return_value.filter.return_value.first.return_value = local_user + + result = await auth(mock_request, db=mock_db) + + assert result is mobile_resp + + @pytest.mark.asyncio + async def test_local_user_session_creation_exception_is_swallowed(self): + """Exception during session creation for local user is swallowed; login still succeeds.""" + from app.auth import auth + + local_user = self._make_local_user() + mock_request = MagicMock(spec=Request) + mock_request.form = AsyncMock(return_value={"username": "alice", "password": "pw"}) + mock_request.session = {} + mock_request.headers = {} + + mock_db = MagicMock() + profile = MagicMock() + profile.onboarding_completed = True + mock_db.query.return_value.filter.return_value.first.side_effect = [local_user, profile] + + with ( + patch("app.auth.settings") as mock_settings, + patch("app.auth._verify_password", return_value=True), + patch("app.auth._build_session_user", return_value={"id": "alice"}), + patch("app.auth._record_login_event"), + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.utils.session_manager.create_session", side_effect=Exception("session fail")), + ): + mock_settings.multi_user_enabled = True + mock_settings.admin_username = "admin" + mock_settings.admin_password = "adminpass" + + result = await auth(mock_request, db=mock_db) + + # Should still redirect successfully despite the session creation failure + assert isinstance(result, RedirectResponse) + assert "user" in mock_request.session + + @pytest.mark.asyncio + async def test_local_user_onboarding_redirect(self): + """Local user who hasn't completed onboarding is sent to /onboarding.""" + from app.auth import auth + from app.models import UserProfile + + local_user = self._make_local_user() + mock_request = MagicMock(spec=Request) + mock_request.form = AsyncMock(return_value={"username": "alice", "password": "pw"}) + mock_request.session = {} + mock_request.headers = {} + + profile = UserProfile(user_id="alice@example.com", onboarding_completed=False) + + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.side_effect = [local_user, profile] + + with ( + patch("app.auth.settings") as mock_settings, + patch("app.auth._verify_password", return_value=True), + patch("app.auth._build_session_user", return_value={"id": "alice"}), + patch("app.auth._record_login_event"), + patch("app.auth._ensure_user_profile"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.utils.session_manager.create_session"), + ): + mock_settings.multi_user_enabled = True + mock_settings.admin_username = "admin" + mock_settings.admin_password = "adminpass" + + result = await auth(mock_request, db=mock_db) + + assert isinstance(result, RedirectResponse) + assert result.headers["location"] == "/onboarding" + + +# --------------------------------------------------------------------------- +# auth() — admin credentials paths +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestAuthAdminExtended: + """Additional coverage for admin auth paths.""" + + def _make_db(self, local_user=None): + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = local_user + return mock_db + + @pytest.mark.asyncio + async def test_admin_login_session_token_exception_is_swallowed(self): + """Exception during admin session token creation is swallowed; redirect is still returned.""" + from app.auth import auth + + mock_request = MagicMock(spec=Request) + mock_request.form = AsyncMock(return_value={"username": "adminuser", "password": "adminpass"}) + mock_request.session = {} + mock_request.headers = {} + + with ( + patch("app.auth.settings") as mock_settings, + patch("app.auth._ensure_user_profile"), + patch("app.auth._record_login_event"), + patch("app.auth._create_mobile_redirect", return_value=None), + patch("app.utils.session_manager.create_session", side_effect=Exception("session fail")), + ): + mock_settings.admin_username = "adminuser" + mock_settings.admin_password = "adminpass" + mock_settings.multi_user_enabled = False + + result = await auth(mock_request, db=self._make_db(None)) + + assert isinstance(result, RedirectResponse) + # Session should still have user even if token creation failed + assert "user" in mock_request.session + + @pytest.mark.asyncio + async def test_admin_login_mobile_redirect(self): + """Admin login returns mobile redirect when mobile_redirect_uri is in session.""" + from app.auth import auth + + mock_request = MagicMock(spec=Request) + mock_request.form = AsyncMock(return_value={"username": "adminuser", "password": "adminpass"}) + mock_request.session = {} + mock_request.headers = {} + + mobile_resp = RedirectResponse(url="docuelevate://callback?token=admin-tok", status_code=302) + + with ( + patch("app.auth.settings") as mock_settings, + patch("app.auth._ensure_user_profile"), + patch("app.auth._record_login_event"), + patch("app.auth._create_mobile_redirect", return_value=mobile_resp), + patch("app.utils.session_manager.create_session"), + ): + mock_settings.admin_username = "adminuser" + mock_settings.admin_password = "adminpass" + mock_settings.multi_user_enabled = False + + result = await auth(mock_request, db=self._make_db(None)) + + assert result is mobile_resp + + +# --------------------------------------------------------------------------- +# logout() — session token revocation +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestLogoutSessionRevocation: + """Tests for logout() session token revocation.""" + + @pytest.mark.asyncio + async def test_logout_revokes_server_side_session(self): + """logout() sets is_revoked=True on the UserSession and commits.""" + from app.auth import logout + + mock_request = MagicMock(spec=Request) + mock_request.session = { + "user": {"preferred_username": "alice"}, + "_session_token": "active-session-token", + } + + mock_user_session = MagicMock() + mock_user_session.is_revoked = False + + mock_db = MagicMock() + + with ( + patch("app.utils.audit_service.record_event"), + patch("app.utils.session_manager.validate_session", return_value=mock_user_session), + ): + result = await logout(mock_request, db=mock_db) + + assert mock_user_session.is_revoked is True + mock_db.commit.assert_called_once() + assert isinstance(result, RedirectResponse) + assert "logged+out" in result.headers["location"] + + @pytest.mark.asyncio + async def test_logout_when_session_token_already_invalid(self): + """logout() handles validate_session returning None gracefully.""" + from app.auth import logout + + mock_request = MagicMock(spec=Request) + mock_request.session = { + "user": {"preferred_username": "alice"}, + "_session_token": "orphan-token", + } + + mock_db = MagicMock() + + with ( + patch("app.utils.audit_service.record_event"), + patch("app.utils.session_manager.validate_session", return_value=None), + ): + result = await logout(mock_request, db=mock_db) + + # No commit since session was already gone + mock_db.commit.assert_not_called() + assert isinstance(result, RedirectResponse) + + @pytest.mark.asyncio + async def test_logout_session_revoke_exception_is_swallowed(self): + """Exception during session revocation is swallowed and logout still completes.""" + from app.auth import logout + + mock_request = MagicMock(spec=Request) + mock_request.session = { + "user": {"email": "bob@example.com"}, + "_session_token": "some-token", + } + + mock_db = MagicMock() + + with ( + patch("app.utils.audit_service.record_event"), + patch("app.utils.session_manager.validate_session", side_effect=Exception("db error")), + ): + result = await logout(mock_request, db=mock_db) + + # Should still redirect successfully + assert isinstance(result, RedirectResponse) + assert "logged+out" in result.headers["location"] + + @pytest.mark.asyncio + async def test_logout_without_session_token_still_completes(self): + """Logout without any _session_token in session completes normally.""" + from app.auth import logout + + mock_request = MagicMock(spec=Request) + mock_request.session = {"user": {"preferred_username": "charlie"}} + + mock_db = MagicMock() + + with patch("app.utils.audit_service.record_event"): + result = await logout(mock_request, db=mock_db) + + assert isinstance(result, RedirectResponse) + assert "logged+out" in result.headers["location"] + assert "user" not in mock_request.session diff --git a/tests/test_automation.py b/tests/test_automation.py new file mode 100644 index 00000000..6f35d8a8 --- /dev/null +++ b/tests/test_automation.py @@ -0,0 +1,432 @@ +"""Tests for the Zapier / Make.com automation integration. + +Covers: +- Automation hook utility functions (payload builder, DB queries, dispatch) +- Automation hook Celery task +- REST hooks API endpoints (subscribe, unsubscribe, list, sample, events) +- Incoming action endpoints (upload) +- Integration with existing webhook dispatch +""" + +import json +import time +from unittest.mock import MagicMock + +import pytest + +from app.models import AutomationHook +from app.utils.automation_hooks import ( + SAMPLE_PAYLOADS, + build_zapier_payload, + dispatch_automation_hooks, + get_active_hooks_for_event, +) +from app.utils.webhook import VALID_EVENTS + +# --------------------------------------------------------------------------- +# Unit tests – build_zapier_payload +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestBuildZapierPayload: + """Tests for the Zapier-compatible payload builder.""" + + def test_contains_required_keys(self): + """Payload must contain id, event, timestamp, plus data fields.""" + payload = build_zapier_payload("document.uploaded", {"document_id": 1}) + assert "id" in payload + assert "event" in payload + assert "timestamp" in payload + assert "document_id" in payload + + def test_id_starts_with_evt(self): + """ID field must start with 'evt_' for Zapier deduplication.""" + payload = build_zapier_payload("document.uploaded", {"document_id": 1}) + assert payload["id"].startswith("evt_") + + def test_event_matches_input(self): + """Event field must match the event argument.""" + payload = build_zapier_payload("document.processed", {"document_id": 2}) + assert payload["event"] == "document.processed" + + def test_timestamp_is_recent(self): + """Timestamp should be close to current time.""" + before = time.time() + payload = build_zapier_payload("document.uploaded", {}) + after = time.time() + assert before <= payload["timestamp"] <= after + + def test_data_is_flat(self): + """Data fields should be merged into top level (flat, no nested 'data' key).""" + payload = build_zapier_payload("document.uploaded", {"filename": "test.pdf", "size": 1024}) + assert payload["filename"] == "test.pdf" + assert payload["size"] == 1024 + assert "data" not in payload + + def test_unique_ids(self): + """Each call should produce a unique ID.""" + ids = {build_zapier_payload("document.uploaded", {})["id"] for _ in range(50)} + assert len(ids) == 50 + + +# --------------------------------------------------------------------------- +# Unit tests – SAMPLE_PAYLOADS +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestSamplePayloads: + """Tests for the sample payloads used by Zapier field mapping.""" + + def test_all_events_have_samples(self): + """Every valid event should have a sample payload.""" + for event in VALID_EVENTS: + assert event in SAMPLE_PAYLOADS, f"Missing sample payload for {event}" + + def test_samples_contain_id_and_event(self): + """Each sample should contain id and event keys.""" + for event, sample in SAMPLE_PAYLOADS.items(): + assert "id" in sample, f"Sample for {event} missing 'id'" + assert sample["event"] == event + + +# --------------------------------------------------------------------------- +# Unit tests – get_active_hooks_for_event +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetActiveHooksForEvent: + """Tests for querying active automation hooks from the database.""" + + def test_returns_matching_hooks(self, mocker): + """Only hooks subscribed to the event should be returned.""" + hook = MagicMock( + id=1, + target_url="https://hooks.zapier.com/1234", + secret="abc", + events=json.dumps(["document.uploaded"]), + is_active=True, + ) + mock_session = MagicMock() + mock_session.query.return_value.filter.return_value.all.return_value = [hook] + mocker.patch("app.utils.automation_hooks.SessionLocal", return_value=mock_session) + + result = get_active_hooks_for_event("document.uploaded") + assert len(result) == 1 + assert result[0]["target_url"] == "https://hooks.zapier.com/1234" + + def test_excludes_non_matching_hooks(self, mocker): + """Hooks for different events should not be returned.""" + hook = MagicMock( + id=1, + target_url="https://hooks.zapier.com/1234", + secret=None, + events=json.dumps(["document.processed"]), + is_active=True, + ) + mock_session = MagicMock() + mock_session.query.return_value.filter.return_value.all.return_value = [hook] + mocker.patch("app.utils.automation_hooks.SessionLocal", return_value=mock_session) + + result = get_active_hooks_for_event("document.uploaded") + assert len(result) == 0 + + def test_empty_when_no_hooks(self, mocker): + """Empty list returned when no hooks exist.""" + mock_session = MagicMock() + mock_session.query.return_value.filter.return_value.all.return_value = [] + mocker.patch("app.utils.automation_hooks.SessionLocal", return_value=mock_session) + + result = get_active_hooks_for_event("document.uploaded") + assert result == [] + + +# --------------------------------------------------------------------------- +# Unit tests – dispatch_automation_hooks +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestDispatchAutomationHooks: + """Tests for the automation hook dispatch function.""" + + def test_ignores_unknown_events(self, mocker): + """Unknown events should be silently ignored.""" + mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=True)) + mock_get = mocker.patch("app.utils.automation_hooks.get_active_hooks_for_event") + dispatch_automation_hooks("bad.event", {}) + mock_get.assert_not_called() + + def test_skips_when_disabled(self, mocker): + """No hooks should fire when automation_hooks_enabled is False.""" + mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=False)) + mock_get = mocker.patch("app.utils.automation_hooks.get_active_hooks_for_event") + dispatch_automation_hooks("document.uploaded", {"file_id": 1}) + mock_get.assert_not_called() + + def test_queues_celery_task_for_each_hook(self, mocker): + """A Celery task is queued for each matching hook.""" + mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=True)) + mocker.patch( + "app.utils.automation_hooks.get_active_hooks_for_event", + return_value=[ + {"id": 1, "target_url": "https://hooks.zapier.com/a", "secret": "s", "events": ["document.uploaded"]}, + {"id": 2, "target_url": "https://hooks.zapier.com/b", "secret": None, "events": ["document.uploaded"]}, + ], + ) + mock_task = mocker.patch("app.tasks.automation_tasks.deliver_automation_hook_task.delay") + + dispatch_automation_hooks("document.uploaded", {"file_id": 42}) + + assert mock_task.call_count == 2 + + def test_no_tasks_when_no_hooks(self, mocker): + """No tasks should be queued when there are no matching hooks.""" + mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=True)) + mocker.patch("app.utils.automation_hooks.get_active_hooks_for_event", return_value=[]) + mock_task = mocker.patch("app.tasks.automation_tasks.deliver_automation_hook_task.delay") + + dispatch_automation_hooks("document.uploaded", {}) + + mock_task.assert_not_called() + + +# --------------------------------------------------------------------------- +# Unit tests – deliver_automation_hook_task +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestDeliverAutomationHookTask: + """Tests for the automation hook Celery task.""" + + def test_returns_success_dict(self, mocker): + """Successful delivery returns status dict.""" + mocker.patch("app.tasks.automation_tasks.deliver_webhook", return_value=True) + + from app.tasks.automation_tasks import deliver_automation_hook_task + + deliver_automation_hook_task.request.retries = 0 + + result = deliver_automation_hook_task.__wrapped__("https://hooks.zapier.com/test", {"event": "test"}, None) + assert result["status"] == "delivered" + assert result["url"] == "https://hooks.zapier.com/test" + + def test_raises_on_failure(self, mocker): + """Failed delivery raises RuntimeError for Celery retry.""" + mocker.patch("app.tasks.automation_tasks.deliver_webhook", return_value=False) + + from app.tasks.automation_tasks import deliver_automation_hook_task + + deliver_automation_hook_task.request.retries = 0 + + with pytest.raises(RuntimeError, match="Automation hook delivery"): + deliver_automation_hook_task.__wrapped__("https://hooks.zapier.com/test", {"event": "test"}, None) + + +# --------------------------------------------------------------------------- +# Integration tests – webhook dispatch integration +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestWebhookDispatchIntegration: + """Test that dispatch_webhook_event also triggers automation hooks.""" + + def test_dispatch_triggers_automation_hooks(self, mocker): + """dispatch_webhook_event should also call dispatch_automation_hooks.""" + mocker.patch("app.utils.webhook.get_active_webhooks_for_event", return_value=[]) + mock_auto = mocker.patch("app.utils.automation_hooks.dispatch_automation_hooks") + + from app.utils.webhook import dispatch_webhook_event + + dispatch_webhook_event("document.uploaded", {"file_id": 1}) + + mock_auto.assert_called_once_with("document.uploaded", {"file_id": 1}) + + +# --------------------------------------------------------------------------- +# Integration tests – API endpoints +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestAutomationAPI: + """Tests for the /api/automation/ endpoints.""" + + def _with_auth(self, client): + """Override auth dependency to simulate an authenticated user.""" + from app.api.automation import _require_api_user + + client.app.dependency_overrides[_require_api_user] = lambda: { + "id": "testuser", + "email": "test@example.com", + "preferred_username": "testuser", + "is_admin": False, + } + return client + + # ── Subscribe / Unsubscribe ────────────────────────────────────── + + def test_subscribe_hook(self, client): + """POST /api/automation/hooks/subscribe creates a new hook.""" + self._with_auth(client) + resp = client.post( + "/api/automation/hooks/subscribe", + json={ + "target_url": "https://hooks.zapier.com/test", + "events": ["document.uploaded"], + "hook_type": "zapier", + "description": "My Zap", + }, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["target_url"] == "https://hooks.zapier.com/test" + assert data["events"] == ["document.uploaded"] + assert data["is_active"] is True + assert data["hook_type"] == "zapier" + + def test_subscribe_with_secret(self, client): + """POST /api/automation/hooks/subscribe with secret masks it.""" + self._with_auth(client) + resp = client.post( + "/api/automation/hooks/subscribe", + json={ + "target_url": "https://hooks.zapier.com/secret", + "events": ["document.processed"], + "secret": "my-signing-secret", + }, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["has_secret"] is True + assert "secret" not in data + + def test_subscribe_invalid_event(self, client): + """POST /api/automation/hooks/subscribe rejects invalid events.""" + self._with_auth(client) + resp = client.post( + "/api/automation/hooks/subscribe", + json={ + "target_url": "https://hooks.zapier.com/bad", + "events": ["bad.event"], + }, + ) + assert resp.status_code == 422 + + def test_unsubscribe_hook(self, client, db_session): + """DELETE /api/automation/hooks/{id} removes the hook.""" + self._with_auth(client) + hook = AutomationHook( + target_url="https://hooks.zapier.com/del", + events=json.dumps(["document.uploaded"]), + is_active=True, + hook_type="zapier", + ) + db_session.add(hook) + db_session.commit() + hook_id = hook.id + + resp = client.delete(f"/api/automation/hooks/{hook_id}") + assert resp.status_code == 204 + + def test_unsubscribe_not_found(self, client): + """DELETE /api/automation/hooks/9999 returns 404.""" + self._with_auth(client) + resp = client.delete("/api/automation/hooks/9999") + assert resp.status_code == 404 + + # ── List hooks ─────────────────────────────────────────────────── + + def test_list_hooks(self, client, db_session): + """GET /api/automation/hooks returns all hooks.""" + self._with_auth(client) + hook = AutomationHook( + target_url="https://hooks.zapier.com/list", + events=json.dumps(["document.processed"]), + is_active=True, + hook_type="make", + ) + db_session.add(hook) + db_session.commit() + + resp = client.get("/api/automation/hooks") + assert resp.status_code == 200 + items = resp.json() + assert any(h["target_url"] == "https://hooks.zapier.com/list" for h in items) + + # ── Sample trigger data ────────────────────────────────────────── + + def test_trigger_sample(self, client): + """GET /api/automation/triggers/sample/{event} returns sample data.""" + self._with_auth(client) + resp = client.get("/api/automation/triggers/sample/document.uploaded") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) == 1 + assert data[0]["event"] == "document.uploaded" + assert "id" in data[0] + + def test_trigger_sample_unknown_event(self, client): + """GET /api/automation/triggers/sample/bad returns 404.""" + self._with_auth(client) + resp = client.get("/api/automation/triggers/sample/bad.event") + assert resp.status_code == 404 + + # ── Events listing ─────────────────────────────────────────────── + + def test_list_events(self, client): + """GET /api/automation/events returns valid event types.""" + self._with_auth(client) + resp = client.get("/api/automation/events") + assert resp.status_code == 200 + events = resp.json() + assert "document.uploaded" in events + assert "document.processed" in events + assert "document.failed" in events + + # ── Incoming action: upload ────────────────────────────────────── + + def test_action_upload(self, client, mocker): + """POST /api/automation/actions/upload accepts a file.""" + self._with_auth(client) + mock_task = MagicMock() + mock_task.id = "task-123" + mocker.patch("app.tasks.process_document.process_document.delay", return_value=mock_task) + + resp = client.post( + "/api/automation/actions/upload", + files={"file": ("test.pdf", b"fake-pdf-content", "application/pdf")}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "accepted" + assert data["filename"] == "test.pdf" + assert data["task_id"] == "task-123" + + def test_action_upload_no_filename(self, client): + """POST /api/automation/actions/upload rejects empty filename.""" + self._with_auth(client) + resp = client.post( + "/api/automation/actions/upload", + files={"file": ("", b"content", "application/pdf")}, + ) + # FastAPI/Starlette may return 422 (multipart validation) or 400 + # (our explicit check) depending on how the empty filename is + # parsed by the underlying multipart parser version. + assert resp.status_code in (400, 422) + + # ── Auth required ──────────────────────────────────────────────── + + def test_requires_auth(self, client): + """Endpoints return 401 without authentication.""" + from app.api.automation import _require_api_user + + client.app.dependency_overrides.pop(_require_api_user, None) + + resp = client.get("/api/automation/hooks") + assert resp.status_code == 401 diff --git a/tests/test_classification_rules.py b/tests/test_classification_rules.py new file mode 100644 index 00000000..a4bacaec --- /dev/null +++ b/tests/test_classification_rules.py @@ -0,0 +1,422 @@ +"""Tests for the rule-based document classification engine. + +Covers the classification engine logic in ``app/utils/classification_rules.py``: +built-in rules, custom rules, confidence scoring, and edge cases. +""" + +import pytest + +from app.utils.classification_rules import ( + BUILTIN_CATEGORIES, + BUILTIN_RULES, + RULE_TYPE_CONTENT, + RULE_TYPE_FILENAME, + RULE_TYPE_METADATA, + ClassificationResult, + ClassificationRule, + MatchedRule, + classify_document, + db_rule_to_engine_rule, +) + +# --------------------------------------------------------------------------- +# Built-in categories & rules smoke tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestBuiltinCategories: + """Verify the pre-built categories and rules are sane.""" + + def test_builtin_categories_not_empty(self): + """There must be at least one built-in category.""" + assert len(BUILTIN_CATEGORIES) > 0 + + def test_unknown_category_exists(self): + """The 'unknown' fallback category must be present.""" + assert "unknown" in BUILTIN_CATEGORIES + + def test_core_categories_present(self): + """Invoice, contract, and receipt categories must exist.""" + for cat in ("invoice", "contract", "receipt"): + assert cat in BUILTIN_CATEGORIES, f"Missing built-in category: {cat}" + + def test_builtin_rules_not_empty(self): + """There must be at least one built-in rule.""" + assert len(BUILTIN_RULES) > 0 + + def test_all_builtin_rules_reference_valid_types(self): + """Every built-in rule must use a valid rule_type.""" + valid_types = {RULE_TYPE_FILENAME, RULE_TYPE_CONTENT, RULE_TYPE_METADATA} + for rule in BUILTIN_RULES: + assert rule.rule_type in valid_types, f"Rule {rule.name!r} has invalid type {rule.rule_type!r}" + + +# --------------------------------------------------------------------------- +# ClassificationRule dataclass validation +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestClassificationRuleValidation: + """Test ClassificationRule dataclass validation.""" + + def test_valid_rule_types(self): + """Valid rule types should not raise.""" + for rt in (RULE_TYPE_FILENAME, RULE_TYPE_CONTENT, RULE_TYPE_METADATA): + rule = ClassificationRule(name="test", category="test", rule_type=rt, pattern="test") + assert rule.rule_type == rt + + def test_invalid_rule_type_raises(self): + """An invalid rule_type should raise ValueError.""" + with pytest.raises(ValueError, match="Invalid rule_type"): + ClassificationRule(name="test", category="test", rule_type="invalid", pattern="test") + + +# --------------------------------------------------------------------------- +# Filename pattern matching +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestFilenamePatternMatching: + """Test classification via filename patterns.""" + + def test_invoice_filename(self): + """A filename containing 'invoice' should classify as invoice.""" + result = classify_document(filename="2024-03-01_Invoice_Acme.pdf") + assert result.category == "invoice" + assert result.confidence > 0 + + def test_german_invoice_filename(self): + """A filename containing 'Rechnung' should classify as invoice.""" + result = classify_document(filename="Rechnung_2024.pdf") + assert result.category == "invoice" + assert result.confidence > 0 + + def test_contract_filename(self): + """A filename containing 'contract' should classify as contract.""" + result = classify_document(filename="Service_Contract_2024.pdf") + assert result.category == "contract" + + def test_receipt_filename(self): + """A filename containing 'receipt' should classify as receipt.""" + result = classify_document(filename="Payment_Receipt.pdf") + assert result.category == "receipt" + + def test_unrecognized_filename(self): + """A generic filename with no keywords should return 'unknown'.""" + result = classify_document(filename="document_12345.pdf") + assert result.category == "unknown" + assert result.confidence == 0 + + def test_empty_filename(self): + """An empty filename should not match any rule.""" + result = classify_document(filename="") + assert result.category == "unknown" + + +# --------------------------------------------------------------------------- +# Content keyword matching +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestContentKeywordMatching: + """Test classification via content keywords.""" + + def test_invoice_content(self): + """Text containing 'invoice number' should classify as invoice.""" + result = classify_document(text="Please pay the invoice number 12345. Amount due: $500") + assert result.category == "invoice" + assert result.confidence > 0 + + def test_contract_content(self): + """Text containing 'terms and conditions' should classify as contract.""" + result = classify_document(text="The parties hereby agree to the following terms and conditions.") + assert result.category == "contract" + + def test_receipt_content(self): + """Text containing 'payment received' should classify as receipt.""" + result = classify_document(text="Thank you. Payment received for order #789.") + assert result.category == "receipt" + + def test_bank_statement_content(self): + """Text containing 'account statement' should classify as bank_statement.""" + result = classify_document(text="Monthly account statement. Opening balance: $1,000.") + assert result.category == "bank_statement" + + def test_empty_text(self): + """Empty text should not match any content rule.""" + result = classify_document(text="") + assert result.category == "unknown" + + def test_case_insensitive_matching(self): + """Content matching should be case-insensitive by default.""" + result = classify_document(text="INVOICE NUMBER 12345") + assert result.category == "invoice" + + +# --------------------------------------------------------------------------- +# Metadata matching +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestMetadataMatching: + """Test classification via metadata field matching.""" + + def test_document_type_invoice(self): + """metadata document_type=Invoice should classify as invoice.""" + result = classify_document(metadata={"document_type": "Invoice"}) + assert result.category == "invoice" + assert result.confidence >= 90 + + def test_document_type_contract(self): + """metadata document_type=Contract should classify as contract.""" + result = classify_document(metadata={"document_type": "Contract"}) + assert result.category == "contract" + + def test_kommunikationsart_rechnung(self): + """German classification metadata should classify as invoice.""" + result = classify_document(metadata={"kommunikationsart": "Rechnung"}) + assert result.category == "invoice" + + def test_no_metadata(self): + """None metadata should not match.""" + result = classify_document(metadata=None) + assert result.category == "unknown" + + def test_empty_metadata(self): + """Empty metadata dict should not match.""" + result = classify_document(metadata={}) + assert result.category == "unknown" + + def test_metadata_case_insensitive(self): + """Metadata matching should be case-insensitive by default.""" + result = classify_document(metadata={"document_type": "invoice"}) + assert result.category == "invoice" + + +# --------------------------------------------------------------------------- +# Combined matching / confidence boosting +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestCombinedMatching: + """Test that multiple matching rules boost confidence.""" + + def test_filename_and_content_boost(self): + """Filename + content matching should produce higher confidence than either alone.""" + filename_only = classify_document(filename="Invoice_2024.pdf") + combined = classify_document(filename="Invoice_2024.pdf", text="Invoice number: 12345. Amount due: $500.") + assert combined.confidence >= filename_only.confidence + assert len(combined.matched_rules) > len(filename_only.matched_rules) + + def test_all_three_signals(self): + """Filename + content + metadata should produce highest confidence.""" + result = classify_document( + filename="Invoice_Acme.pdf", + text="Invoice number: 12345. Amount due: $500.", + metadata={"document_type": "Invoice"}, + ) + assert result.category == "invoice" + assert result.confidence >= 90 + + def test_conflicting_signals_most_matches_wins(self): + """When filename says 'invoice' but content says 'contract', most matches wins.""" + result = classify_document( + filename="Invoice.pdf", + text="The parties hereby agree to the following terms and conditions. " + "This agreement between Company A and Company B is effective immediately.", + ) + # Content has more keyword matches for contract, but filename matches invoice. + # Either is acceptable as long as the result is deterministic. + assert result.category in ("invoice", "contract") + + +# --------------------------------------------------------------------------- +# Custom rules +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestCustomRules: + """Test user-defined custom classification rules.""" + + def test_custom_rule_matches(self): + """A custom filename rule should match when pattern hits.""" + custom = [ + ClassificationRule( + name="custom_hr_doc", + category="hr_document", + rule_type=RULE_TYPE_FILENAME, + pattern=r"(?i)employee|hiring|hr", + ) + ] + result = classify_document(filename="Employee_Handbook.pdf", custom_rules=custom) + assert result.category == "hr_document" + + def test_custom_content_rule(self): + """A custom content keyword rule should match.""" + custom = [ + ClassificationRule( + name="custom_medical", + category="medical", + rule_type=RULE_TYPE_CONTENT, + pattern="diagnosis|prescription|patient record", + ) + ] + result = classify_document(text="Patient record for Jane Doe. Diagnosis: common cold.", custom_rules=custom) + assert result.category == "medical" + + def test_custom_metadata_rule(self): + """A custom metadata rule should match.""" + custom = [ + ClassificationRule( + name="custom_legal", + category="legal", + rule_type=RULE_TYPE_METADATA, + pattern="department=legal", + ) + ] + result = classify_document(metadata={"department": "legal"}, custom_rules=custom) + assert result.category == "legal" + + def test_custom_rule_overrides_builtin(self): + """Custom rules with more matches should override built-in rules.""" + custom = [ + ClassificationRule( + name="custom_internal_invoice", + category="internal_invoice", + rule_type=RULE_TYPE_FILENAME, + pattern=r"(?i)invoice", + priority=100, + ), + ClassificationRule( + name="custom_internal_invoice_content", + category="internal_invoice", + rule_type=RULE_TYPE_CONTENT, + pattern="invoice number", + priority=100, + ), + ] + result = classify_document( + filename="Invoice_2024.pdf", + text="Invoice number: 12345", + custom_rules=custom, + ) + # Both builtin and custom rules for "invoice" patterns match, but custom + # has "internal_invoice" as category. The category with more total matches wins. + assert result.category in ("invoice", "internal_invoice") + assert result.confidence > 0 + + +# --------------------------------------------------------------------------- +# db_rule_to_engine_rule converter +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestDbRuleConversion: + """Test the database model to engine rule converter.""" + + def test_converts_basic_fields(self): + """All basic fields should be mapped correctly.""" + + class FakeDbRule: + name = "test_rule" + category = "invoice" + rule_type = RULE_TYPE_FILENAME + pattern = r"(?i)invoice" + priority = 10 + case_sensitive = True + + engine_rule = db_rule_to_engine_rule(FakeDbRule()) + assert engine_rule.name == "test_rule" + assert engine_rule.category == "invoice" + assert engine_rule.rule_type == RULE_TYPE_FILENAME + assert engine_rule.pattern == r"(?i)invoice" + assert engine_rule.priority == 10 + assert engine_rule.case_sensitive is True + + def test_defaults_case_sensitive_to_false(self): + """When case_sensitive is missing, default to False.""" + + class FakeDbRule: + name = "test" + category = "test" + rule_type = RULE_TYPE_CONTENT + pattern = "test" + priority = 0 + + engine_rule = db_rule_to_engine_rule(FakeDbRule()) + assert engine_rule.case_sensitive is False + + +# --------------------------------------------------------------------------- +# ClassificationResult +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestClassificationResult: + """Test the ClassificationResult dataclass.""" + + def test_default_matched_rules(self): + """matched_rules should default to an empty list.""" + result = ClassificationResult(category="test", confidence=50) + assert result.matched_rules == [] + + def test_with_matched_rules(self): + """matched_rules should be populated when provided.""" + match = MatchedRule(rule_name="test", rule_type=RULE_TYPE_FILENAME, category="invoice", confidence=60) + result = ClassificationResult(category="invoice", confidence=60, matched_rules=[match]) + assert len(result.matched_rules) == 1 + assert result.matched_rules[0].rule_name == "test" + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestEdgeCases: + """Test edge cases in the classification engine.""" + + def test_no_inputs_at_all(self): + """No filename, text, or metadata should return 'unknown'.""" + result = classify_document() + assert result.category == "unknown" + assert result.confidence == 0 + assert result.matched_rules == [] + + def test_metadata_pattern_without_equals(self): + """A metadata pattern without '=' should not match.""" + custom = [ + ClassificationRule( + name="bad_pattern", + category="test", + rule_type=RULE_TYPE_METADATA, + pattern="no_equals_sign", + ) + ] + result = classify_document(metadata={"no_equals_sign": "value"}, custom_rules=custom) + assert result.category == "unknown" + + def test_confidence_capped_at_100(self): + """Confidence should never exceed 100.""" + # Create many rules that all match to test the cap + custom = [ + ClassificationRule( + name=f"flood_{i}", + category="flood", + rule_type=RULE_TYPE_CONTENT, + pattern="test keyword", + ) + for i in range(20) + ] + result = classify_document(text="test keyword is here", custom_rules=custom) + assert result.confidence <= 100 diff --git a/tests/test_classify_document.py b/tests/test_classify_document.py new file mode 100644 index 00000000..60fa2fe5 --- /dev/null +++ b/tests/test_classify_document.py @@ -0,0 +1,232 @@ +"""Tests for the classify_document Celery task. + +Covers the ``classify_document_task`` in ``app/tasks/classify_document.py``. +""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from app.models import ClassificationRuleModel, FileRecord +from app.tasks.classify_document import _load_custom_rules + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_file_record(db_session, **overrides): + """Insert a minimal FileRecord and return it.""" + defaults = { + "owner_id": "test-user", + "filehash": "abc123", + "original_filename": "Invoice_2024.pdf", + "local_filename": "/tmp/test.pdf", + "file_size": 1024, + "mime_type": "application/pdf", + "ocr_text": "Invoice number: 12345. Amount due: $500.", + "ai_metadata": None, + } + defaults.update(overrides) + fr = FileRecord(**defaults) + db_session.add(fr) + db_session.commit() + db_session.refresh(fr) + return fr + + +def _make_rule(db_session, **overrides): + """Insert a ClassificationRuleModel and return it.""" + defaults = { + "owner_id": None, + "name": "test_rule", + "category": "test_category", + "rule_type": "filename_pattern", + "pattern": r"(?i)test", + "priority": 0, + "case_sensitive": False, + "enabled": True, + } + defaults.update(overrides) + rule = ClassificationRuleModel(**defaults) + db_session.add(rule) + db_session.commit() + db_session.refresh(rule) + return rule + + +# --------------------------------------------------------------------------- +# _load_custom_rules +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestLoadCustomRules: + """Test the custom rule loading helper.""" + + @patch("app.tasks.classify_document.SessionLocal") + def test_loads_enabled_rules(self, mock_session_local): + """Should load enabled rules from the database.""" + mock_rule = MagicMock() + mock_rule.name = "rule1" + mock_rule.category = "invoice" + mock_rule.rule_type = "filename_pattern" + mock_rule.pattern = r"(?i)invoice" + mock_rule.priority = 10 + mock_rule.case_sensitive = False + + mock_db = MagicMock() + mock_query = MagicMock() + mock_db.query.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.order_by.return_value = mock_query + mock_query.all.return_value = [mock_rule] + mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db) + mock_session_local.return_value.__exit__ = MagicMock(return_value=False) + + rules = _load_custom_rules(owner_id="test-user") + assert len(rules) == 1 + assert rules[0].name == "rule1" + assert rules[0].category == "invoice" + + +# --------------------------------------------------------------------------- +# classify_document_task (integration-style with mocked DB and Celery) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestClassifyDocumentTask: + """Test the Celery classify_document_task.""" + + @patch("app.tasks.classify_document.log_task_progress") + @patch("app.tasks.classify_document._load_custom_rules", return_value=[]) + @patch("app.tasks.classify_document.SessionLocal") + def test_classify_invoice_file(self, mock_session_local, mock_load_rules, mock_log): + """Should classify a file with invoice filename and text as 'invoice'.""" + mock_file = MagicMock(spec=FileRecord) + mock_file.id = 1 + mock_file.original_filename = "Invoice_2024.pdf" + mock_file.ocr_text = "Invoice number: 12345. Amount due: $500." + mock_file.ai_metadata = None + mock_file.owner_id = "test-user" + + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = mock_file + mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db) + mock_session_local.return_value.__exit__ = MagicMock(return_value=False) + + from app.tasks.classify_document import classify_document_task + + # Call the underlying function directly via .run(), bypassing Celery + result = classify_document_task.run(1, owner_id="test-user") + + assert result["status"] == "success" + assert result["category"] == "invoice" + assert result["confidence"] > 0 + + # Verify ai_metadata was updated + assert mock_file.ai_metadata is not None + metadata = json.loads(mock_file.ai_metadata) + assert "classification" in metadata + assert metadata["classification"]["category"] == "invoice" + + @patch("app.tasks.classify_document.log_task_progress") + @patch("app.tasks.classify_document.SessionLocal") + def test_classify_file_not_found(self, mock_session_local, mock_log): + """Should return error when file record is not found.""" + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = None + mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db) + mock_session_local.return_value.__exit__ = MagicMock(return_value=False) + + from app.tasks.classify_document import classify_document_task + + result = classify_document_task.run(99999) + assert result["status"] == "error" + + @patch("app.tasks.classify_document.log_task_progress") + @patch("app.tasks.classify_document._load_custom_rules", return_value=[]) + @patch("app.tasks.classify_document.SessionLocal") + def test_classify_preserves_existing_metadata(self, mock_session_local, mock_load_rules, mock_log): + """Should preserve existing ai_metadata fields and add classification.""" + existing_meta = json.dumps({"document_type": "Invoice", "tags": ["finance"]}) + + mock_file = MagicMock(spec=FileRecord) + mock_file.id = 2 + mock_file.original_filename = "doc.pdf" + mock_file.ocr_text = "" + mock_file.ai_metadata = existing_meta + mock_file.owner_id = "test-user" + + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = mock_file + mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db) + mock_session_local.return_value.__exit__ = MagicMock(return_value=False) + + from app.tasks.classify_document import classify_document_task + + classify_document_task.run(2) + + # Check that existing fields are preserved + metadata = json.loads(mock_file.ai_metadata) + assert metadata["tags"] == ["finance"] + assert metadata["document_type"] == "Invoice" + assert "classification" in metadata + + @patch("app.tasks.classify_document.log_task_progress") + @patch("app.tasks.classify_document._load_custom_rules", return_value=[]) + @patch("app.tasks.classify_document.SessionLocal") + def test_classify_sets_document_type_when_missing(self, mock_session_local, mock_load_rules, mock_log): + """Should set document_type from classification when not already present.""" + mock_file = MagicMock(spec=FileRecord) + mock_file.id = 3 + mock_file.original_filename = "Invoice_2024.pdf" + mock_file.ocr_text = "Invoice number: 12345" + mock_file.ai_metadata = json.dumps({"tags": ["test"]}) + mock_file.owner_id = "test-user" + + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = mock_file + mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db) + mock_session_local.return_value.__exit__ = MagicMock(return_value=False) + + from app.tasks.classify_document import classify_document_task + + classify_document_task.run(3) + + metadata = json.loads(mock_file.ai_metadata) + assert metadata["document_type"] == "Invoice" + + @patch("app.tasks.classify_document.log_task_progress") + @patch("app.tasks.classify_document._load_custom_rules", return_value=[]) + @patch("app.tasks.classify_document.SessionLocal") + def test_classify_unknown_document(self, mock_session_local, mock_load_rules, mock_log): + """Should classify as 'unknown' when no rules match.""" + mock_file = MagicMock(spec=FileRecord) + mock_file.id = 4 + mock_file.original_filename = "random_file.pdf" + mock_file.ocr_text = "Lorem ipsum dolor sit amet." + mock_file.ai_metadata = None + mock_file.owner_id = "test-user" + + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = mock_file + mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db) + mock_session_local.return_value.__exit__ = MagicMock(return_value=False) + + from app.tasks.classify_document import classify_document_task + + result = classify_document_task.run(4) + + assert result["category"] == "unknown" + assert result["confidence"] == 0 + + def test_classify_document_task_is_celery_task(self): + """Task should be registered as a Celery task.""" + from app.tasks.classify_document import classify_document_task + + assert hasattr(classify_document_task, "apply_async") + assert hasattr(classify_document_task, "delay") + assert callable(classify_document_task) diff --git a/tests/test_comments.py b/tests/test_comments.py new file mode 100644 index 00000000..dda56809 --- /dev/null +++ b/tests/test_comments.py @@ -0,0 +1,525 @@ +"""Tests for the document comments and annotations API.""" + +import pytest + +from app.models import DocumentAnnotation, DocumentComment, FileRecord, UserProfile + + +def _create_file(db_session, owner_id="testuser") -> FileRecord: + """Helper to create a minimal FileRecord for testing.""" + f = FileRecord( + owner_id=owner_id, + filehash="abc123", + original_filename="test.pdf", + local_filename="test.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(f) + db_session.commit() + db_session.refresh(f) + return f + + +# --------------------------------------------------------------------------- +# Comment tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestListComments: + """Tests for GET /api/files/{file_id}/comments.""" + + def test_list_comments_empty(self, client, db_session): + f = _create_file(db_session) + resp = client.get(f"/api/files/{f.id}/comments") + assert resp.status_code == 200 + data = resp.json() + assert data["file_id"] == f.id + assert data["comments"] == [] + assert data["total"] == 0 + + def test_list_comments_file_not_found(self, client): + resp = client.get("/api/files/99999/comments") + assert resp.status_code == 404 + + def test_list_comments_threaded(self, client, db_session): + f = _create_file(db_session) + # Root comment + c1 = DocumentComment(file_id=f.id, user_id="alice", body="Hello") + db_session.add(c1) + db_session.commit() + db_session.refresh(c1) + # Reply + c2 = DocumentComment(file_id=f.id, user_id="bob", parent_id=c1.id, body="Hi back") + db_session.add(c2) + db_session.commit() + + resp = client.get(f"/api/files/{f.id}/comments") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 2 + assert len(data["comments"]) == 1 # only root + assert len(data["comments"][0]["replies"]) == 1 + assert data["comments"][0]["replies"][0]["body"] == "Hi back" + + +@pytest.mark.unit +class TestCreateComment: + """Tests for POST /api/files/{file_id}/comments.""" + + def test_create_comment(self, client, db_session): + f = _create_file(db_session) + resp = client.post( + f"/api/files/{f.id}/comments", + json={"body": "Great document!"}, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["body"] == "Great document!" + assert data["file_id"] == f.id + assert data["parent_id"] is None + + def test_create_comment_with_mention(self, client, db_session): + f = _create_file(db_session) + resp = client.post( + f"/api/files/{f.id}/comments", + json={"body": "Hey @alice please review"}, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["mentions"] == ["alice"] + + def test_create_comment_with_parent(self, client, db_session): + f = _create_file(db_session) + c = DocumentComment(file_id=f.id, user_id="user1", body="root") + db_session.add(c) + db_session.commit() + db_session.refresh(c) + + resp = client.post( + f"/api/files/{f.id}/comments", + json={"body": "reply", "parent_id": c.id}, + ) + assert resp.status_code == 201 + assert resp.json()["parent_id"] == c.id + + def test_create_comment_parent_not_found(self, client, db_session): + f = _create_file(db_session) + resp = client.post( + f"/api/files/{f.id}/comments", + json={"body": "reply", "parent_id": 99999}, + ) + assert resp.status_code == 404 + + def test_create_comment_empty_body(self, client, db_session): + f = _create_file(db_session) + resp = client.post( + f"/api/files/{f.id}/comments", + json={"body": " "}, + ) + assert resp.status_code == 422 + + def test_create_comment_file_not_found(self, client): + resp = client.post( + "/api/files/99999/comments", + json={"body": "test"}, + ) + assert resp.status_code == 404 + + def test_create_comment_body_too_long(self, client, db_session): + f = _create_file(db_session) + resp = client.post( + f"/api/files/{f.id}/comments", + json={"body": "x" * 10_001}, + ) + assert resp.status_code == 422 + + +@pytest.mark.unit +class TestUpdateComment: + """Tests for PUT /api/files/{file_id}/comments/{comment_id}.""" + + def test_update_comment(self, client, db_session): + f = _create_file(db_session) + c = DocumentComment(file_id=f.id, user_id="anonymous", body="old body") + db_session.add(c) + db_session.commit() + db_session.refresh(c) + + resp = client.put( + f"/api/files/{f.id}/comments/{c.id}", + json={"body": "new body @bob"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["body"] == "new body @bob" + assert data["mentions"] == ["bob"] + + def test_update_comment_not_found(self, client, db_session): + f = _create_file(db_session) + resp = client.put( + f"/api/files/{f.id}/comments/99999", + json={"body": "new"}, + ) + assert resp.status_code == 404 + + def test_update_comment_forbidden(self, client, db_session): + f = _create_file(db_session) + c = DocumentComment(file_id=f.id, user_id="other_user", body="old") + db_session.add(c) + db_session.commit() + db_session.refresh(c) + + resp = client.put( + f"/api/files/{f.id}/comments/{c.id}", + json={"body": "new"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.unit +class TestDeleteComment: + """Tests for DELETE /api/files/{file_id}/comments/{comment_id}.""" + + def test_delete_comment(self, client, db_session): + f = _create_file(db_session) + c = DocumentComment(file_id=f.id, user_id="anonymous", body="to delete") + db_session.add(c) + db_session.commit() + db_session.refresh(c) + + resp = client.delete(f"/api/files/{f.id}/comments/{c.id}") + assert resp.status_code == 204 + + # Verify deleted + assert db_session.query(DocumentComment).filter(DocumentComment.id == c.id).first() is None + + def test_delete_comment_not_found(self, client, db_session): + f = _create_file(db_session) + resp = client.delete(f"/api/files/{f.id}/comments/99999") + assert resp.status_code == 404 + + def test_delete_comment_forbidden(self, client, db_session): + f = _create_file(db_session) + c = DocumentComment(file_id=f.id, user_id="other_user", body="mine") + db_session.add(c) + db_session.commit() + db_session.refresh(c) + + resp = client.delete(f"/api/files/{f.id}/comments/{c.id}") + assert resp.status_code == 403 + + +@pytest.mark.unit +class TestResolveComment: + """Tests for PATCH /api/files/{file_id}/comments/{comment_id}/resolve.""" + + def test_resolve_comment(self, client, db_session): + f = _create_file(db_session) + c = DocumentComment(file_id=f.id, user_id="anonymous", body="issue") + db_session.add(c) + db_session.commit() + db_session.refresh(c) + + resp = client.patch( + f"/api/files/{f.id}/comments/{c.id}/resolve", + json={"is_resolved": True}, + ) + assert resp.status_code == 200 + assert resp.json()["is_resolved"] is True + + def test_unresolve_comment(self, client, db_session): + f = _create_file(db_session) + c = DocumentComment(file_id=f.id, user_id="anonymous", body="issue", is_resolved=True) + db_session.add(c) + db_session.commit() + db_session.refresh(c) + + resp = client.patch( + f"/api/files/{f.id}/comments/{c.id}/resolve", + json={"is_resolved": False}, + ) + assert resp.status_code == 200 + assert resp.json()["is_resolved"] is False + + def test_resolve_not_found(self, client, db_session): + f = _create_file(db_session) + resp = client.patch( + f"/api/files/{f.id}/comments/99999/resolve", + json={"is_resolved": True}, + ) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Annotation tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestListAnnotations: + """Tests for GET /api/files/{file_id}/annotations.""" + + def test_list_annotations_empty(self, client, db_session): + f = _create_file(db_session) + resp = client.get(f"/api/files/{f.id}/annotations") + assert resp.status_code == 200 + data = resp.json() + assert data["file_id"] == f.id + assert data["annotations"] == [] + assert data["total"] == 0 + + def test_list_annotations_file_not_found(self, client): + resp = client.get("/api/files/99999/annotations") + assert resp.status_code == 404 + + +@pytest.mark.unit +class TestCreateAnnotation: + """Tests for POST /api/files/{file_id}/annotations.""" + + def test_create_annotation(self, client, db_session): + f = _create_file(db_session) + resp = client.post( + f"/api/files/{f.id}/annotations", + json={ + "page": 1, + "x": 100.0, + "y": 200.0, + "content": "Important note", + }, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["page"] == 1 + assert data["x"] == 100.0 + assert data["y"] == 200.0 + assert data["content"] == "Important note" + assert data["annotation_type"] == "note" + + def test_create_annotation_with_all_fields(self, client, db_session): + f = _create_file(db_session) + resp = client.post( + f"/api/files/{f.id}/annotations", + json={ + "page": 2, + "x": 50.0, + "y": 100.0, + "width": 200.0, + "height": 30.0, + "content": "Highlighted text", + "annotation_type": "highlight", + "color": "#ffff00", + }, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["annotation_type"] == "highlight" + assert data["color"] == "#ffff00" + assert data["width"] == 200.0 + assert data["height"] == 30.0 + + def test_create_annotation_file_not_found(self, client): + resp = client.post( + "/api/files/99999/annotations", + json={"page": 1, "x": 0, "y": 0, "content": "test"}, + ) + assert resp.status_code == 404 + + def test_create_annotation_empty_content(self, client, db_session): + f = _create_file(db_session) + resp = client.post( + f"/api/files/{f.id}/annotations", + json={"page": 1, "x": 0, "y": 0, "content": " "}, + ) + assert resp.status_code == 422 + + def test_create_annotation_invalid_page(self, client, db_session): + f = _create_file(db_session) + resp = client.post( + f"/api/files/{f.id}/annotations", + json={"page": 0, "x": 0, "y": 0, "content": "test"}, + ) + assert resp.status_code == 422 + + def test_create_annotation_invalid_type(self, client, db_session): + f = _create_file(db_session) + resp = client.post( + f"/api/files/{f.id}/annotations", + json={"page": 1, "x": 0, "y": 0, "content": "test", "annotation_type": "invalid"}, + ) + assert resp.status_code == 422 + + def test_create_annotation_content_too_long(self, client, db_session): + f = _create_file(db_session) + resp = client.post( + f"/api/files/{f.id}/annotations", + json={"page": 1, "x": 0, "y": 0, "content": "x" * 5_001}, + ) + assert resp.status_code == 422 + + +@pytest.mark.unit +class TestUpdateAnnotation: + """Tests for PUT /api/files/{file_id}/annotations/{annotation_id}.""" + + def test_update_annotation(self, client, db_session): + f = _create_file(db_session) + a = DocumentAnnotation(file_id=f.id, user_id="anonymous", page=1, x=0, y=0, width=0, height=0, content="old") + db_session.add(a) + db_session.commit() + db_session.refresh(a) + + resp = client.put( + f"/api/files/{f.id}/annotations/{a.id}", + json={"content": "updated note", "color": "#00ff00"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["content"] == "updated note" + assert data["color"] == "#00ff00" + + def test_update_annotation_not_found(self, client, db_session): + f = _create_file(db_session) + resp = client.put( + f"/api/files/{f.id}/annotations/99999", + json={"content": "new"}, + ) + assert resp.status_code == 404 + + def test_update_annotation_forbidden(self, client, db_session): + f = _create_file(db_session) + a = DocumentAnnotation(file_id=f.id, user_id="other_user", page=1, x=0, y=0, width=0, height=0, content="mine") + db_session.add(a) + db_session.commit() + db_session.refresh(a) + + resp = client.put( + f"/api/files/{f.id}/annotations/{a.id}", + json={"content": "hijack"}, + ) + assert resp.status_code == 403 + + def test_update_annotation_invalid_type(self, client, db_session): + f = _create_file(db_session) + a = DocumentAnnotation(file_id=f.id, user_id="anonymous", page=1, x=0, y=0, width=0, height=0, content="old") + db_session.add(a) + db_session.commit() + db_session.refresh(a) + + resp = client.put( + f"/api/files/{f.id}/annotations/{a.id}", + json={"annotation_type": "invalid"}, + ) + assert resp.status_code == 422 + + +@pytest.mark.unit +class TestDeleteAnnotation: + """Tests for DELETE /api/files/{file_id}/annotations/{annotation_id}.""" + + def test_delete_annotation(self, client, db_session): + f = _create_file(db_session) + a = DocumentAnnotation( + file_id=f.id, user_id="anonymous", page=1, x=0, y=0, width=0, height=0, content="to delete" + ) + db_session.add(a) + db_session.commit() + db_session.refresh(a) + + resp = client.delete(f"/api/files/{f.id}/annotations/{a.id}") + assert resp.status_code == 204 + assert db_session.query(DocumentAnnotation).filter(DocumentAnnotation.id == a.id).first() is None + + def test_delete_annotation_not_found(self, client, db_session): + f = _create_file(db_session) + resp = client.delete(f"/api/files/{f.id}/annotations/99999") + assert resp.status_code == 404 + + def test_delete_annotation_forbidden(self, client, db_session): + f = _create_file(db_session) + a = DocumentAnnotation(file_id=f.id, user_id="other_user", page=1, x=0, y=0, width=0, height=0, content="mine") + db_session.add(a) + db_session.commit() + db_session.refresh(a) + + resp = client.delete(f"/api/files/{f.id}/annotations/{a.id}") + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# Mentionable users tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestListMentionableUsers: + """Tests for GET /api/users/mentionable.""" + + def test_list_mentionable_empty(self, client, db_session): + resp = client.get("/api/users/mentionable") + assert resp.status_code == 200 + assert resp.json() == [] + + def test_list_mentionable_users(self, client, db_session): + p1 = UserProfile(user_id="alice", display_name="Alice A") + p2 = UserProfile(user_id="bob", display_name="Bob B") + db_session.add_all([p1, p2]) + db_session.commit() + + resp = client.get("/api/users/mentionable") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 2 + assert data[0]["user_id"] == "alice" + assert data[1]["user_id"] == "bob" + + def test_blocked_users_excluded(self, client, db_session): + p1 = UserProfile(user_id="alice", display_name="Alice A", is_blocked=False) + p2 = UserProfile(user_id="blocked", display_name="Blocked", is_blocked=True) + db_session.add_all([p1, p2]) + db_session.commit() + + resp = client.get("/api/users/mentionable") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["user_id"] == "alice" + + +# --------------------------------------------------------------------------- +# Mention extraction helper tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestExtractMentions: + """Tests for the _extract_mentions helper function.""" + + def test_no_mentions(self): + from app.api.comments import _extract_mentions + + assert _extract_mentions("Hello world") == [] + + def test_single_mention(self): + from app.api.comments import _extract_mentions + + assert _extract_mentions("Hey @alice check this") == ["alice"] + + def test_multiple_mentions(self): + from app.api.comments import _extract_mentions + + assert _extract_mentions("@alice @bob @charlie") == ["alice", "bob", "charlie"] + + def test_duplicate_mentions(self): + from app.api.comments import _extract_mentions + + result = _extract_mentions("@alice and @alice again") + assert result == ["alice"] + + def test_mention_with_dots_and_dashes(self): + from app.api.comments import _extract_mentions + + result = _extract_mentions("@user.name @user-name") + assert result == ["user.name", "user-name"] diff --git a/tests/test_comments_ui.py b/tests/test_comments_ui.py new file mode 100644 index 00000000..27b68017 --- /dev/null +++ b/tests/test_comments_ui.py @@ -0,0 +1,210 @@ +"""Tests for the comments and annotations UI on the file annotations page.""" + +import pytest +from fastapi.testclient import TestClient + +from app.models import FileRecord + + +def _create_file(db_session, tmp_path) -> FileRecord: + """Create a minimal FileRecord with a real file path for the annotations page.""" + file_path = tmp_path / "test.pdf" + file_path.write_bytes(b"%PDF-1.4") + f = FileRecord( + filehash="uihash123", + original_filename="test.pdf", + local_filename=str(file_path), + original_file_path=str(file_path), + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(f) + db_session.commit() + db_session.refresh(f) + return f + + +@pytest.mark.unit +class TestCommentsUIRendering: + """Verify the file annotations page includes the comments panel HTML.""" + + def test_annotations_page_contains_comments_section(self, client: TestClient, db_session, tmp_path): + """The annotations page should render the comments panel container.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/annotations") + assert resp.status_code == 200 + html = resp.text + assert 'id="comments-list"' in html + assert 'id="comment-form"' in html + assert 'id="comment-input"' in html + + def test_annotations_page_contains_annotations_section(self, client: TestClient, db_session, tmp_path): + """The annotations page should render the annotations panel container.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/annotations") + assert resp.status_code == 200 + html = resp.text + assert 'id="annotations-list"' in html + assert 'id="annotation-form"' in html + assert 'id="annotation-content-input"' in html + + def test_annotations_page_loads_comments_js(self, client: TestClient, db_session, tmp_path): + """The annotations page should include the comments JavaScript file.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/annotations") + assert resp.status_code == 200 + assert "js/comments.js" in resp.text + + def test_annotations_page_loads_annotations_js(self, client: TestClient, db_session, tmp_path): + """The annotations page should include the annotations JavaScript file.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/annotations") + assert resp.status_code == 200 + assert "js/annotations.js" in resp.text + + def test_annotations_page_has_mention_dropdown(self, client: TestClient, db_session, tmp_path): + """The mention autocomplete dropdown should be present.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/annotations") + assert resp.status_code == 200 + assert 'id="mention-dropdown"' in resp.text + + def test_annotations_page_has_annotation_form_fields(self, client: TestClient, db_session, tmp_path): + """Annotation form should have page, type, and color inputs.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/annotations") + assert resp.status_code == 200 + html = resp.text + assert 'id="annotation-page-input"' in html + assert 'id="annotation-type-input"' in html + assert 'id="annotation-color-input"' in html + + def test_annotations_page_has_collab_grid(self, client: TestClient, db_session, tmp_path): + """Comments and annotations should be in a side-by-side grid layout.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/annotations") + assert resp.status_code == 200 + assert "collab-grid" in resp.text + + def test_annotations_page_no_comments_for_missing_file(self, client: TestClient): + """When file is not found, no comments section should appear.""" + resp = client.get("/files/99999/annotations") + assert resp.status_code == 200 + # The error block is shown, not the main content + assert 'id="comments-list"' not in resp.text + + def test_annotations_page_annotation_type_options(self, client: TestClient, db_session, tmp_path): + """Annotation type selector should include all four types.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/annotations") + assert resp.status_code == 200 + html = resp.text + assert 'value="note"' in html + assert 'value="highlight"' in html + assert 'value="underline"' in html + assert 'value="strikethrough"' in html + + def test_annotations_page_comments_panel_accessibility(self, client: TestClient, db_session, tmp_path): + """Comments panel should have proper ARIA attributes.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/annotations") + assert resp.status_code == 200 + html = resp.text + assert 'aria-live="polite"' in html + assert 'role="listbox"' in html + + def test_annotations_page_init_script(self, client: TestClient, db_session, tmp_path): + """The init script should call initComments and initAnnotations.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/annotations") + assert resp.status_code == 200 + html = resp.text + assert "initComments" in html + assert "initAnnotations" in html + + def test_comments_url_redirects_to_annotations(self, client: TestClient, db_session, tmp_path): + """The /comments URL should redirect to /annotations.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/comments", follow_redirects=False) + assert resp.status_code == 302 + assert f"/files/{f.id}/annotations" in resp.headers["location"] + + def test_process_page_no_comments_section(self, client: TestClient, db_session, tmp_path): + """The process page should NOT render the comments panel.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/process") + assert resp.status_code == 200 + html = resp.text + assert 'id="comments-list"' not in html + assert 'id="comment-form"' not in html + + def test_detail_page_no_comments_section(self, client: TestClient, db_session, tmp_path): + """The detail page should NOT render the comments panel.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/detail") + assert resp.status_code == 200 + html = resp.text + assert 'id="comments-list"' not in html + assert 'id="annotation-form"' not in html + + def test_annotations_page_has_embedpdf_viewer_for_pdf(self, client: TestClient, db_session, tmp_path): + """The annotations page should include the EmbedPDF viewer for PDF files.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/annotations") + assert resp.status_code == 200 + html = resp.text + assert 'id="embedpdf-viewer"' in html + assert "@embedpdf/snippet" in html + + def test_embedpdf_init_subscribes_to_page_change(self, client: TestClient, db_session, tmp_path): + """The EmbedPDF init script should subscribe to page change events to sync the form.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/annotations") + assert resp.status_code == 200 + html = resp.text + # Verifies the viewer registry is awaited and scroll plugin is used + assert "viewer.registry" in html + assert "onPageChange" in html + assert "annotation-page-input" in html + + def test_embedpdf_init_exposes_scroll_function(self, client: TestClient, db_session, tmp_path): + """The EmbedPDF init script must expose _embedpdfScrollToPage for the annotations panel.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/annotations") + assert resp.status_code == 200 + assert "_embedpdfScrollToPage" in resp.text + assert "scrollToPage" in resp.text + + def test_embedpdf_init_saves_viewer_annotations(self, client: TestClient, db_session, tmp_path): + """The EmbedPDF init script should capture annotation events and POST to the API.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/annotations") + assert resp.status_code == 200 + html = resp.text + assert "onAnnotationEvent" in html + # Verifies the POST target is the annotations API for this file + assert "/api/files/" in html and "/annotations" in html + + def test_embedpdf_init_reloads_annotation_list(self, client: TestClient, db_session, tmp_path): + """After auto-saving a viewer annotation, the panel list should be refreshed.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/annotations") + assert resp.status_code == 200 + assert "_reloadAnnotations" in resp.text + + def test_annotations_page_has_go_to_page_i18n(self, client: TestClient, db_session, tmp_path): + """The annotations i18n bundle should include the go_to_page key.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/annotations") + assert resp.status_code == 200 + assert "go_to_page" in resp.text + + def test_summary_page_renders(self, client: TestClient, db_session, tmp_path): + """The summary page at /files/{id} should render correctly.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}") + assert resp.status_code == 200 + html = resp.text + assert "Document Detail" in html + assert "Processing" in html + assert "Comments" in html or "Annotations" in html diff --git a/tests/test_connections.py b/tests/test_connections.py new file mode 100644 index 00000000..f22aae30 --- /dev/null +++ b/tests/test_connections.py @@ -0,0 +1,494 @@ +"""Tests for the Connections admin page and new authentication providers.""" + +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import status + + +@pytest.mark.unit +class TestDropboxComplianceFix: + """Tests for the Dropbox userinfo compliance fix.""" + + def test_dropbox_compliance_fix_adds_sub(self): + """Test that compliance fix adds 'sub' from account_id.""" + from app.auth import _dropbox_userinfo_compliance_fix + + data = {"account_id": "dbid:abc123", "email": "test@example.com"} + result = _dropbox_userinfo_compliance_fix(None, None, None, data) + assert result["sub"] == "dbid:abc123" + + def test_dropbox_compliance_fix_does_not_overwrite_sub(self): + """Test that compliance fix preserves existing 'sub'.""" + from app.auth import _dropbox_userinfo_compliance_fix + + data = {"account_id": "dbid:abc123", "sub": "existing-sub"} + result = _dropbox_userinfo_compliance_fix(None, None, None, data) + assert result["sub"] == "existing-sub" + + def test_dropbox_compliance_fix_normalizes_name(self): + """Test that compliance fix normalizes nested name object.""" + from app.auth import _dropbox_userinfo_compliance_fix + + data = {"name": {"display_name": "John Doe", "given_name": "John"}} + result = _dropbox_userinfo_compliance_fix(None, None, None, data) + assert result["name"] == "John Doe" + + def test_dropbox_compliance_fix_handles_no_name(self): + """Test compliance fix works when no name is present.""" + from app.auth import _dropbox_userinfo_compliance_fix + + data = {"email": "test@example.com"} + result = _dropbox_userinfo_compliance_fix(None, None, None, data) + assert "email" in result + + +@pytest.mark.unit +class TestGitHubNormalization: + """Tests for GitHub userinfo normalization.""" + + def test_github_normalize_standard(self): + """Test GitHub userinfo normalization with standard response.""" + from app.auth import _normalize_social_userinfo + + raw = { + "id": 12345, + "login": "octocat", + "name": "The Octocat", + "email": "octocat@github.com", + "avatar_url": "https://avatars.githubusercontent.com/u/12345", + } + result = _normalize_social_userinfo("github", {}, raw) + assert result["sub"] == "12345" + assert result["email"] == "octocat@github.com" + assert result["name"] == "The Octocat" + assert result["preferred_username"] == "octocat" + assert result["picture"] == "https://avatars.githubusercontent.com/u/12345" + + def test_github_normalize_no_name_uses_login(self): + """Test GitHub normalization falls back to login when name is empty.""" + from app.auth import _normalize_social_userinfo + + raw = {"id": 12345, "login": "octocat", "name": "", "email": "octocat@github.com"} + result = _normalize_social_userinfo("github", {}, raw) + assert result["name"] == "octocat" + + def test_github_normalize_missing_fields(self): + """Test GitHub normalization handles missing fields gracefully.""" + from app.auth import _normalize_social_userinfo + + result = _normalize_social_userinfo("github", {}, {}) + assert result["sub"] == "" + assert result["email"] == "" + assert result["name"] == "" + assert result["preferred_username"] == "" + + +@pytest.mark.unit +class TestSSOAutoLogin: + """Tests for SSO Auto Login configuration.""" + + def test_sso_auto_login_default_false(self): + """Test that SSO auto login defaults to False.""" + from app.config import Settings + + s = Settings( + _env_file=None, + auth_enabled=True, + ) + assert s.sso_auto_login is False + + def test_sso_auto_login_can_be_enabled(self): + """Test that SSO auto login can be set to True.""" + from app.config import Settings + + s = Settings( + _env_file=None, + auth_enabled=True, + sso_auto_login=True, + ) + assert s.sso_auto_login is True + + +@pytest.mark.unit +class TestNewConfigFields: + """Tests for new configuration fields.""" + + def test_github_config_defaults(self): + """Test GitHub social auth config defaults.""" + from app.config import Settings + + s = Settings(_env_file=None, auth_enabled=True) + assert s.social_auth_github_enabled is False + assert s.social_auth_github_client_id is None + assert s.social_auth_github_client_secret is None + + def test_keycloak_config_defaults(self): + """Test Keycloak social auth config defaults.""" + from app.config import Settings + + s = Settings(_env_file=None, auth_enabled=True) + assert s.social_auth_keycloak_enabled is False + assert s.social_auth_keycloak_client_id is None + assert s.social_auth_keycloak_server_url is None + assert s.social_auth_keycloak_realm is None + + def test_generic_oauth2_config_defaults(self): + """Test Generic OAuth2 config defaults.""" + from app.config import Settings + + s = Settings(_env_file=None, auth_enabled=True) + assert s.social_auth_generic_oauth2_enabled is False + assert s.social_auth_generic_oauth2_scope == "openid profile email" + assert s.social_auth_generic_oauth2_name == "OAuth2" + + def test_saml2_config_defaults(self): + """Test SAML2 config defaults.""" + from app.config import Settings + + s = Settings(_env_file=None, auth_enabled=True) + assert s.social_auth_saml2_enabled is False + assert s.social_auth_saml2_name == "SAML2" + + def test_telegram_config_defaults(self): + """Test Telegram config defaults.""" + from app.config import Settings + + s = Settings(_env_file=None, auth_enabled=True) + assert s.telegram_enabled is False + assert s.telegram_bot_token is None + assert s.telegram_chat_id is None + + +@pytest.mark.unit +class TestSettingsMetadata: + """Tests that new settings have metadata entries.""" + + def test_github_settings_have_metadata(self): + """Test GitHub settings are in SETTING_METADATA.""" + from app.utils.settings_service import SETTING_METADATA + + assert "social_auth_github_enabled" in SETTING_METADATA + assert "social_auth_github_client_id" in SETTING_METADATA + assert "social_auth_github_client_secret" in SETTING_METADATA + + def test_keycloak_settings_have_metadata(self): + """Test Keycloak settings are in SETTING_METADATA.""" + from app.utils.settings_service import SETTING_METADATA + + assert "social_auth_keycloak_enabled" in SETTING_METADATA + assert "social_auth_keycloak_client_id" in SETTING_METADATA + assert "social_auth_keycloak_server_url" in SETTING_METADATA + assert "social_auth_keycloak_realm" in SETTING_METADATA + + def test_generic_oauth2_settings_have_metadata(self): + """Test Generic OAuth2 settings are in SETTING_METADATA.""" + from app.utils.settings_service import SETTING_METADATA + + assert "social_auth_generic_oauth2_enabled" in SETTING_METADATA + assert "social_auth_generic_oauth2_authorize_url" in SETTING_METADATA + assert "social_auth_generic_oauth2_token_url" in SETTING_METADATA + + def test_saml2_settings_have_metadata(self): + """Test SAML2 settings are in SETTING_METADATA.""" + from app.utils.settings_service import SETTING_METADATA + + assert "social_auth_saml2_enabled" in SETTING_METADATA + assert "social_auth_saml2_sso_url" in SETTING_METADATA + assert "social_auth_saml2_entity_id" in SETTING_METADATA + + def test_telegram_settings_have_metadata(self): + """Test Telegram settings are in SETTING_METADATA.""" + from app.utils.settings_service import SETTING_METADATA + + assert "telegram_enabled" in SETTING_METADATA + assert "telegram_bot_token" in SETTING_METADATA + assert "telegram_chat_id" in SETTING_METADATA + + def test_sso_auto_login_has_metadata(self): + """Test SSO auto login has metadata.""" + from app.utils.settings_service import SETTING_METADATA + + assert "sso_auto_login" in SETTING_METADATA + meta = SETTING_METADATA["sso_auto_login"] + assert meta["category"] == "Authentication" + assert meta["type"] == "boolean" + + def test_github_category_is_social_login(self): + """Test GitHub settings are in Social Login category.""" + from app.utils.settings_service import SETTING_METADATA + + assert SETTING_METADATA["social_auth_github_enabled"]["category"] == "Social Login" + + def test_github_secret_is_sensitive(self): + """Test GitHub client secret is marked sensitive.""" + from app.utils.settings_service import SETTING_METADATA + + assert SETTING_METADATA["social_auth_github_client_secret"]["sensitive"] is True + + def test_github_has_help_link(self): + """Test GitHub has a help link to developer settings.""" + from app.utils.settings_service import SETTING_METADATA + + assert "help_link" in SETTING_METADATA["social_auth_github_enabled"] + + +@pytest.mark.unit +class TestConnectionsPageRoute: + """Tests for the /admin/connections route.""" + + @pytest.mark.asyncio + async def test_connections_page_non_admin_redirected(self): + """Test that non-admin users are redirected from connections page.""" + + mock_request = MagicMock() + mock_request.session = {"user": {"is_admin": False}} + mock_db = MagicMock() + + # The require_admin_access decorator should handle this, so we test the decorator + from app.views.settings import require_admin_access + + @require_admin_access + async def dummy_view(request): + return "success" + + result = await dummy_view(mock_request) + assert result.status_code == status.HTTP_302_FOUND + + @pytest.mark.asyncio + async def test_connections_page_returns_services(self): + """Test that connections page includes expected services in context.""" + from app.views.settings import connections_page + + mock_request = MagicMock() + mock_request.session = {"user": {"is_admin": True}} + mock_db = MagicMock() + + with ( + patch("app.views.settings.get_all_settings_from_db", return_value={}), + patch("app.views.settings.templates") as mock_templates, + patch("app.views.settings.SETTING_METADATA", {}), + patch("app.views.settings.get_setting_metadata", return_value={}), + ): + mock_templates.TemplateResponse.return_value = "response" + result = await connections_page(mock_request, db=mock_db) + + # Check TemplateResponse was called + mock_templates.TemplateResponse.assert_called_once() + call_args = mock_templates.TemplateResponse.call_args + template_name = call_args[0][0] + context = call_args[0][1] + + assert template_name == "admin_connections.html" + assert "services" in context + assert "service_settings" in context + assert "sso_auto_login" in context + + # Verify expected service keys + service_keys = [s["key"] for s in context["services"]] + assert "google" in service_keys + assert "github" in service_keys + assert "keycloak" in service_keys + assert "generic_oauth2" in service_keys + assert "saml2" in service_keys + assert "smtp" in service_keys + assert "telegram" in service_keys + + @pytest.mark.asyncio + async def test_connections_page_linked_status_from_db(self): + """Linked status is derived from DB/effective settings, not SOCIAL_PROVIDERS.""" + from app.views.settings import connections_page + + mock_request = MagicMock() + mock_request.session = {"user": {"is_admin": True}} + mock_db = MagicMock() + + # Simulate GitHub configured only in DB (not in SOCIAL_PROVIDERS yet) + db_values = { + "social_auth_github_enabled": "true", + "social_auth_github_client_id": "gh-id", + "social_auth_github_client_secret": "gh-secret", + } + + with ( + patch("app.views.settings.get_all_settings_from_db", return_value=db_values), + patch("app.views.settings.templates") as mock_templates, + patch("app.views.settings.SETTING_METADATA", {}), + patch("app.views.settings.get_setting_metadata", return_value={}), + ): + mock_templates.TemplateResponse.return_value = "response" + await connections_page(mock_request, db=mock_db) + + context = mock_templates.TemplateResponse.call_args[0][1] + services_by_key = {s["key"]: s for s in context["services"]} + + # GitHub should be linked because DB values say so + assert services_by_key["github"]["linked"] is True + + @pytest.mark.asyncio + async def test_connections_page_unlinked_when_credentials_missing(self): + """Provider is unlinked when enabled=true but credentials are absent.""" + from app.views.settings import connections_page + + mock_request = MagicMock() + mock_request.session = {"user": {"is_admin": True}} + mock_db = MagicMock() + + # enabled but no credentials + db_values = {"social_auth_github_enabled": "true"} + + with ( + patch("app.views.settings.get_all_settings_from_db", return_value=db_values), + patch("app.views.settings.templates") as mock_templates, + patch("app.views.settings.SETTING_METADATA", {}), + patch("app.views.settings.get_setting_metadata", return_value={}), + ): + mock_templates.TemplateResponse.return_value = "response" + await connections_page(mock_request, db=mock_db) + + context = mock_templates.TemplateResponse.call_args[0][1] + services_by_key = {s["key"]: s for s in context["services"]} + assert services_by_key["github"]["linked"] is False + + @pytest.mark.asyncio + async def test_connections_page_oidc_linked_from_db(self): + """OIDC linked status derives from DB effective settings.""" + from app.views.settings import connections_page + + mock_request = MagicMock() + mock_request.session = {"user": {"is_admin": True}} + mock_db = MagicMock() + + db_values = { + "authentik_client_id": "my-client-id", + "authentik_client_secret": "my-secret", + "oauth_provider_name": "My SSO", + } + + with ( + patch("app.views.settings.get_all_settings_from_db", return_value=db_values), + patch("app.views.settings.templates") as mock_templates, + patch("app.views.settings.SETTING_METADATA", {}), + patch("app.views.settings.get_setting_metadata", return_value={}), + ): + mock_templates.TemplateResponse.return_value = "response" + await connections_page(mock_request, db=mock_db) + + context = mock_templates.TemplateResponse.call_args[0][1] + services_by_key = {s["key"]: s for s in context["services"]} + assert services_by_key["oidc"]["linked"] is True + assert services_by_key["oidc"]["name"] == "My SSO" + # oauth_configured template var should also reflect the DB state + assert context["oauth_configured"] is True + + +@pytest.mark.unit +class TestRefreshSocialProviders: + """Tests for the refresh_social_providers() mechanism.""" + + def test_refresh_social_providers_exists(self): + """refresh_social_providers is importable from app.auth.""" + from app.auth import refresh_social_providers + + assert callable(refresh_social_providers) + + def test_refresh_social_providers_clears_and_repopulates(self): + """After refresh, SOCIAL_PROVIDERS reflects current settings.""" + import app.auth as auth_module + + with ( + patch.object(auth_module, "AUTH_ENABLED", True), + patch.object(auth_module, "settings") as mock_settings, + ): + mock_settings.authentik_client_id = None + mock_settings.authentik_client_secret = None + mock_settings.social_auth_google_enabled = True + mock_settings.social_auth_google_client_id = "gid" + mock_settings.social_auth_google_client_secret = "gsecret" + mock_settings.social_auth_google_use_global_credentials = False + # All other providers disabled + for attr in ( + "social_auth_microsoft_enabled", + "social_auth_apple_enabled", + "social_auth_dropbox_enabled", + "social_auth_github_enabled", + "social_auth_keycloak_enabled", + "social_auth_generic_oauth2_enabled", + ): + setattr(mock_settings, attr, False) + + with patch.object(auth_module, "_register_oauth_client"): + auth_module._setup_social_providers() + + assert "google" in auth_module.SOCIAL_PROVIDERS + assert auth_module.OAUTH_CONFIGURED is False + + def test_refresh_clears_previous_providers(self): + """Providers removed from settings are cleared after refresh.""" + import app.auth as auth_module + + # Pre-populate with a stale entry + auth_module.SOCIAL_PROVIDERS["stale_provider"] = {"name": "Stale", "icon": "", "color": ""} + + with ( + patch.object(auth_module, "AUTH_ENABLED", True), + patch.object(auth_module, "settings") as mock_settings, + ): + mock_settings.authentik_client_id = None + mock_settings.authentik_client_secret = None + for attr in ( + "social_auth_google_enabled", + "social_auth_microsoft_enabled", + "social_auth_apple_enabled", + "social_auth_dropbox_enabled", + "social_auth_github_enabled", + "social_auth_keycloak_enabled", + "social_auth_generic_oauth2_enabled", + ): + setattr(mock_settings, attr, False) + + with patch.object(auth_module, "_register_oauth_client"): + auth_module._setup_social_providers() + + assert "stale_provider" not in auth_module.SOCIAL_PROVIDERS + + def test_register_oauth_client_clears_cache(self): + """_register_oauth_client removes the cached client before re-registering.""" + import app.auth as auth_module + + # Inject a fake cached client + auth_module.oauth._clients["test_provider"] = object() + + with patch.object(auth_module.oauth, "register"): + auth_module._register_oauth_client("test_provider", client_id="x", client_secret="y") + assert "test_provider" not in auth_module.oauth._clients + + +@pytest.mark.unit +class TestTranslationKeys: + """Tests for new translation keys.""" + + def test_connections_translation_keys_exist(self): + """Test that connections translation keys are in en.json.""" + import json + from pathlib import Path + + en_path = Path(__file__).parents[1] / "frontend" / "translations" / "en.json" + translations = json.loads(en_path.read_text()) + + expected_keys = [ + "connections.title", + "connections.description", + "connections.configure", + "connections.linked", + "connections.unlinked", + "connections.sso_auto_login", + "connections.sso_auto_login_title", + "connections.sso_auto_login_description", + "connections.mobile_upload_title", + "connections.qr_code_enabled", + "connections.unlinked_services", + "nav.connections", + ] + for key in expected_keys: + assert key in translations, f"Missing translation key: {key}" diff --git a/tests/test_convert_to_pdfa.py b/tests/test_convert_to_pdfa.py index 12ee6243..58564289 100644 --- a/tests/test_convert_to_pdfa.py +++ b/tests/test_convert_to_pdfa.py @@ -38,6 +38,11 @@ class TestConvertPdfToPdfa: assert "pdfa-2" in cmd assert "--quiet" in cmd assert "--invalidate-digital-signatures" in cmd + # SECURITY: Verify `--` end-of-options separator is present and precedes + # the file paths to prevent option/argument injection. + assert "--" in cmd + assert cmd.index("--") < cmd.index("/input.pdf") + assert cmd.index("--") < cmd.index("/output.pdf") assert "/input.pdf" in cmd assert "/output.pdf" in cmd @@ -72,6 +77,8 @@ class TestConvertPdfToPdfa: _convert_pdf_to_pdfa("/input.pdf", "/output.pdf", fmt) cmd = mock_run.call_args[0][0] assert f"pdfa-{fmt}" in cmd + assert "--" in cmd + assert cmd.index("--") < cmd.index("/input.pdf") def test_invalid_pdfa_format_rejected(self): """Test that invalid PDF/A format values are rejected.""" diff --git a/tests/test_database.py b/tests/test_database.py index 64e0e70e..3bd822f7 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -998,3 +998,49 @@ class TestAlembicUpgrade: # Verify head is reachable heads = script.get_heads() assert len(heads) == 1 # Should be a single linear chain + + +@pytest.mark.unit +class TestEnginePoolConfiguration: + """Tests for database engine pool configuration (pool class and options).""" + + def test_sqlite_engine_uses_null_pool(self): + """SQLite engines must use NullPool to prevent QueuePool exhaustion.""" + from sqlalchemy.pool import NullPool + + from app.database import engine + + # The test environment uses SQLite, so NullPool should be in effect. + assert isinstance(engine.pool, NullPool) + + def test_create_engine_sqlite_null_pool(self): + """Explicitly create a SQLite engine to confirm NullPool is applied.""" + from sqlalchemy import create_engine + from sqlalchemy.pool import NullPool + + test_engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=NullPool, + ) + assert isinstance(test_engine.pool, NullPool) + test_engine.dispose() + + def test_pool_settings_exist_in_config(self): + """Verify that pool tuning settings are exposed through config.""" + from app.config import settings + + assert hasattr(settings, "db_pool_size") + assert hasattr(settings, "db_max_overflow") + assert hasattr(settings, "db_pool_timeout") + assert hasattr(settings, "db_pool_recycle") + + def test_pool_settings_have_sensible_defaults(self): + """Default pool settings should be larger than SQLAlchemy's built-in defaults.""" + from app.config import settings + + # SQLAlchemy defaults: pool_size=5, max_overflow=10 + assert settings.db_pool_size >= 10 + assert settings.db_max_overflow >= 20 + assert settings.db_pool_timeout >= 30 + assert settings.db_pool_recycle >= 1800 diff --git a/tests/test_diagnostic.py b/tests/test_diagnostic.py index fa47acc1..8d3f917a 100644 --- a/tests/test_diagnostic.py +++ b/tests/test_diagnostic.py @@ -5,6 +5,86 @@ from unittest.mock import MagicMock, patch import pytest +@pytest.mark.unit +class TestLivenessProbe: + """Tests for GET /api/diagnostic/healthz/live (unauthenticated).""" + + def test_liveness_returns_200(self, client): + """Liveness probe always returns 200 OK.""" + response = client.get("/api/diagnostic/healthz/live") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + + +@pytest.mark.unit +class TestReadinessProbe: + """Tests for GET /api/diagnostic/healthz/ready (unauthenticated).""" + + def test_readiness_returns_200_when_all_ok(self, client): + """Readiness probe returns 200 when database and Redis are reachable.""" + with ( + patch("app.api.diagnostic.engine") as mock_engine, + patch("app.api.diagnostic.redis_lib") as mock_redis, + ): + mock_conn = MagicMock() + mock_engine.connect.return_value.__enter__ = MagicMock(return_value=mock_conn) + mock_engine.connect.return_value.__exit__ = MagicMock(return_value=False) + mock_redis_inst = MagicMock() + mock_redis.from_url.return_value = mock_redis_inst + + response = client.get("/api/diagnostic/healthz/ready") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ready" + assert data["checks"]["database"]["status"] == "ok" + + def test_readiness_returns_503_when_database_fails(self, client): + """Readiness probe returns 503 when database is unreachable.""" + with ( + patch("app.api.diagnostic.engine") as mock_engine, + patch("app.api.diagnostic.redis_lib") as mock_redis, + ): + mock_engine.connect.side_effect = Exception("DB unavailable") + mock_redis_inst = MagicMock() + mock_redis.from_url.return_value = mock_redis_inst + + response = client.get("/api/diagnostic/healthz/ready") + + assert response.status_code == 503 + data = response.json() + assert data["status"] == "not_ready" + assert data["checks"]["database"]["status"] == "error" + + def test_readiness_returns_200_when_redis_fails(self, client): + """Readiness remains 200 when only Redis is down (non-critical).""" + with ( + patch("app.api.diagnostic.engine") as mock_engine, + patch("app.api.diagnostic.redis_lib") as mock_redis, + ): + mock_conn = MagicMock() + mock_engine.connect.return_value.__enter__ = MagicMock(return_value=mock_conn) + mock_engine.connect.return_value.__exit__ = MagicMock(return_value=False) + mock_redis.from_url.return_value = MagicMock() + mock_redis.from_url.return_value.ping.side_effect = Exception("Connection refused") + + response = client.get("/api/diagnostic/healthz/ready") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ready" + assert data["checks"]["redis"]["status"] == "error" + + def test_readiness_contains_checks_keys(self, client): + """Readiness response always contains database and redis checks.""" + response = client.get("/api/diagnostic/healthz/ready") + data = response.json() + assert "checks" in data + assert "database" in data["checks"] + assert "redis" in data["checks"] + + @pytest.mark.unit class TestHealthEndpoint: """Tests for GET /api/diagnostic/health endpoint.""" diff --git a/tests/test_duplicates.py b/tests/test_duplicates.py index 45486168..987b1599 100644 --- a/tests/test_duplicates.py +++ b/tests/test_duplicates.py @@ -3,11 +3,12 @@ Covers: - ``GET /api/duplicates`` — list all exact-duplicate groups - ``GET /api/files/{id}/duplicates`` — per-file exact + near-duplicate info -- ``POST /api/ui-upload`` — exact-duplicate warning in upload response +- ``POST /api/ui-upload`` — exact-duplicate rejection at upload time - ``GET /duplicates`` — duplicate management UI page """ import json +import os from unittest.mock import patch import pytest @@ -283,17 +284,25 @@ class TestGetFileDuplicates: # --------------------------------------------------------------------------- -# POST /api/ui-upload — exact-duplicate warning +# POST /api/ui-upload — exact-duplicate rejection # --------------------------------------------------------------------------- -class TestUploadDuplicateWarning: - """Tests for duplicate warning injected into the upload response.""" +class TestUploadDuplicateRejection: + """Tests for duplicate rejection at upload time. + + When ``ENABLE_DEDUPLICATION`` is ``True`` (the default) and the uploaded + file's SHA-256 hash matches an already-processed document, the upload + endpoint must: + - return ``status: "duplicate"`` instead of ``"queued"`` + - **not** enqueue a Celery task + - clean up the temporary file from disk + """ @pytest.mark.integration @patch("app.tasks.process_document.process_document.delay") def test_no_warning_for_unique_file(self, mock_delay, client: TestClient, tmp_path): - """Uploading a unique file should not produce a duplicate_warning.""" + """Uploading a unique file should not produce a duplicate response.""" mock_delay.return_value.id = "task-unique" pdf = tmp_path / "unique.pdf" pdf.write_bytes(b"%PDF-1.4\n%%EOF") @@ -306,14 +315,12 @@ class TestUploadDuplicateWarning: assert response.status_code == 200 data = response.json() - assert "duplicate_warning" not in data or data.get("duplicate_warning") is None + assert data["status"] == "queued" + assert "duplicate_of" not in data @pytest.mark.integration - @patch("app.tasks.process_document.process_document.delay") - def test_warning_for_exact_duplicate(self, mock_delay, client: TestClient, db_session, tmp_path): - """Uploading a file with the same hash as an existing record returns a warning.""" - mock_delay.return_value.id = "task-dup" - + def test_exact_duplicate_rejected(self, client: TestClient, db_session, tmp_path): + """Uploading a file with the same hash as an existing record is rejected.""" # Create a real PDF with known content pdf_bytes = b"%PDF-1.4\nsome unique content for test\n%%EOF" pdf = tmp_path / "existing.pdf" @@ -335,16 +342,14 @@ class TestUploadDuplicateWarning: assert response.status_code == 200 data = response.json() - assert "duplicate_warning" in data - assert data["duplicate_warning"]["duplicate_type"] == "exact" - assert data["duplicate_warning"]["original_file_id"] == existing.id + assert data["status"] == "duplicate" + assert "duplicate_of" in data + assert data["duplicate_of"]["duplicate_type"] == "exact" + assert data["duplicate_of"]["original_file_id"] == existing.id @pytest.mark.integration - @patch("app.tasks.process_document.process_document.delay") - def test_upload_still_queued_despite_warning(self, mock_delay, client: TestClient, db_session, tmp_path): - """Even when a duplicate is detected, the file should still be queued.""" - mock_delay.return_value.id = "task-still-queued" - + def test_duplicate_not_enqueued(self, client: TestClient, db_session, tmp_path): + """When a duplicate is detected, no Celery task should be created.""" pdf_bytes = b"%PDF-1.4\nqueue test content\n%%EOF" pdf = tmp_path / "queue_test.pdf" pdf.write_bytes(pdf_bytes) @@ -354,16 +359,47 @@ class TestUploadDuplicateWarning: filehash = hash_file(str(pdf)) _make_file(db_session, filehash=filehash, filename="queue_orig.pdf") - with open(pdf, "rb") as f: - response = client.post( - "/api/ui-upload", - files={"file": ("queue_test.pdf", f, "application/pdf")}, - ) + with patch("app.tasks.process_document.process_document.delay") as mock_delay: + with open(pdf, "rb") as f: + response = client.post( + "/api/ui-upload", + files={"file": ("queue_test.pdf", f, "application/pdf")}, + ) assert response.status_code == 200 data = response.json() - assert "task_id" in data - assert data["status"] == "queued" + assert data["status"] == "duplicate" + assert "task_id" not in data + mock_delay.assert_not_called() + + @pytest.mark.integration + def test_duplicate_temp_file_cleaned_up(self, client: TestClient, db_session, tmp_path): + """The temporary file saved to disk should be removed for a duplicate.""" + pdf_bytes = b"%PDF-1.4\ncleanup test content\n%%EOF" + pdf = tmp_path / "cleanup_test.pdf" + pdf.write_bytes(pdf_bytes) + + from app.utils.file_operations import hash_file + + filehash = hash_file(str(pdf)) + _make_file(db_session, filehash=filehash, filename="cleanup_orig.pdf") + + with patch("app.tasks.process_document.process_document.delay"): + with open(pdf, "rb") as f: + response = client.post( + "/api/ui-upload", + files={"file": ("cleanup_test.pdf", f, "application/pdf")}, + ) + + assert response.status_code == 200 + data = response.json() + # The stored_filename is returned so we can verify cleanup + stored = data.get("stored_filename") + assert stored is not None + + from app.config import settings + + assert not os.path.exists(os.path.join(settings.workdir, stored)) # --------------------------------------------------------------------------- diff --git a/tests/test_frontend_build.py b/tests/test_frontend_build.py new file mode 100644 index 00000000..528bbfff --- /dev/null +++ b/tests/test_frontend_build.py @@ -0,0 +1,145 @@ +"""Tests for frontend build configuration and Docker build consistency. + +Validates that the frontend build toolchain (Tailwind CSS) is correctly +configured in package.json and that the Dockerfile installs all required +dependencies for the build step. +""" + +import json +import re +from pathlib import Path + +import pytest + +# Resolve the project root from the test file location +PROJECT_ROOT = Path(__file__).resolve().parent.parent +FRONTEND_DIR = PROJECT_ROOT / "frontend" +DOCKERFILE_PATH = PROJECT_ROOT / "Dockerfile" + + +@pytest.mark.unit +class TestFrontendPackageJson: + """Validate frontend/package.json structure and scripts.""" + + def test_package_json_exists(self) -> None: + """package.json must exist in the frontend directory.""" + pkg_path = FRONTEND_DIR / "package.json" + assert pkg_path.exists(), "frontend/package.json not found" + + def test_package_json_is_valid_json(self) -> None: + """package.json must be parseable JSON.""" + pkg_path = FRONTEND_DIR / "package.json" + data = json.loads(pkg_path.read_text(encoding="utf-8")) + assert isinstance(data, dict), "package.json must be a JSON object" + + def test_build_script_defined(self) -> None: + """A 'build' script must be defined in package.json.""" + pkg_path = FRONTEND_DIR / "package.json" + data = json.loads(pkg_path.read_text(encoding="utf-8")) + scripts = data.get("scripts", {}) + assert "build" in scripts, "Missing 'build' script in package.json" + + def test_build_script_uses_tailwindcss(self) -> None: + """The build script must invoke the tailwindcss CLI.""" + pkg_path = FRONTEND_DIR / "package.json" + data = json.loads(pkg_path.read_text(encoding="utf-8")) + build_cmd = data["scripts"]["build"] + assert "tailwindcss" in build_cmd, f"Build script does not reference tailwindcss: {build_cmd}" + + def test_tailwindcss_listed_as_dependency(self) -> None: + """tailwindcss must be listed in dependencies or devDependencies.""" + pkg_path = FRONTEND_DIR / "package.json" + data = json.loads(pkg_path.read_text(encoding="utf-8")) + deps = data.get("dependencies", {}) + dev_deps = data.get("devDependencies", {}) + all_deps = {**deps, **dev_deps} + assert "tailwindcss" in all_deps, "tailwindcss is not listed in dependencies or devDependencies" + + +@pytest.mark.unit +class TestFrontendBuildAssets: + """Validate that required frontend build source files exist.""" + + def test_input_css_exists(self) -> None: + """The Tailwind CSS input file must exist.""" + input_css = FRONTEND_DIR / "input.css" + assert input_css.exists(), "frontend/input.css not found" + + def test_input_css_has_tailwind_directives(self) -> None: + """input.css must include Tailwind CSS directives.""" + input_css = FRONTEND_DIR / "input.css" + content = input_css.read_text(encoding="utf-8") + assert "@tailwind base" in content, "Missing @tailwind base directive" + assert "@tailwind components" in content, "Missing @tailwind components directive" + assert "@tailwind utilities" in content, "Missing @tailwind utilities directive" + + def test_tailwind_config_exists(self) -> None: + """tailwind.config.js must exist in the frontend directory.""" + config_path = FRONTEND_DIR / "tailwind.config.js" + assert config_path.exists(), "frontend/tailwind.config.js not found" + + def test_package_lock_exists(self) -> None: + """package-lock.json must exist for reproducible installs.""" + lock_path = FRONTEND_DIR / "package-lock.json" + assert lock_path.exists(), "frontend/package-lock.json not found" + + +@pytest.mark.unit +class TestDockerfileFrontendBuilder: + """Validate the Dockerfile frontend-builder stage installs build dependencies.""" + + def test_dockerfile_exists(self) -> None: + """Production Dockerfile must exist at the project root.""" + assert DOCKERFILE_PATH.exists(), "Dockerfile not found at project root" + + def test_dockerfile_has_frontend_builder_stage(self) -> None: + """Dockerfile must define a frontend-builder stage.""" + content = DOCKERFILE_PATH.read_text(encoding="utf-8") + assert "AS frontend-builder" in content, "Dockerfile does not define a frontend-builder stage" + + def test_dockerfile_npm_ci_does_not_omit_dev(self) -> None: + """npm ci must NOT use --omit=dev in the frontend-builder stage. + + The tailwindcss CLI is a devDependency required at build time. + Using --omit=dev would skip installing it, causing the build to + fail with 'tailwindcss: not found'. + """ + content = DOCKERFILE_PATH.read_text(encoding="utf-8") + + # Extract the frontend-builder stage content + # Look for the stage start and the next stage (or end of file) + stage_pattern = re.compile( + r"FROM\s+\S+\s+AS\s+frontend-builder\b(.*?)(?=FROM\s|\Z)", + re.DOTALL, + ) + match = stage_pattern.search(content) + assert match is not None, "Could not find frontend-builder stage in Dockerfile" + + stage_content = match.group(1) + assert "--omit=dev" not in stage_content, ( + "Dockerfile frontend-builder stage uses 'npm ci --omit=dev' which " + "excludes tailwindcss (a devDependency) needed for the build step. " + "Use 'npm ci' instead to install all dependencies." + ) + + def test_dockerfile_runs_npm_build(self) -> None: + """Dockerfile frontend-builder stage must run npm run build.""" + content = DOCKERFILE_PATH.read_text(encoding="utf-8") + + stage_pattern = re.compile( + r"FROM\s+\S+\s+AS\s+frontend-builder\b(.*?)(?=FROM\s|\Z)", + re.DOTALL, + ) + match = stage_pattern.search(content) + assert match is not None, "Could not find frontend-builder stage in Dockerfile" + + stage_content = match.group(1) + assert "npm run build" in stage_content, "Dockerfile frontend-builder stage does not run 'npm run build'" + + def test_dockerfile_copies_compiled_css(self) -> None: + """Dockerfile must copy the compiled styles.css from the frontend-builder stage.""" + content = DOCKERFILE_PATH.read_text(encoding="utf-8") + assert "COPY --from=frontend-builder" in content, ( + "Dockerfile does not copy assets from the frontend-builder stage" + ) + assert "styles.css" in content, "Dockerfile does not reference the compiled styles.css" diff --git a/tests/test_imap_profiles.py b/tests/test_imap_profiles.py index 1c18459f..ab2cf7bb 100644 --- a/tests/test_imap_profiles.py +++ b/tests/test_imap_profiles.py @@ -1,7 +1,13 @@ """Tests for app/api/imap_profiles.py and app/utils/allowed_types category helpers.""" import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool +from app.database import Base, get_db +from app.models import ImapIngestionProfile from app.utils.allowed_types import ( ALL_CATEGORIES, DEFAULT_CATEGORIES, @@ -9,6 +15,104 @@ from app.utils.allowed_types import ( get_allowed_types_for_categories, ) +# --------------------------------------------------------------------------- +# Integration test constants +# --------------------------------------------------------------------------- + +_OWNER = "profile_user@example.com" +_OTHER = "other_user@example.com" + + +# --------------------------------------------------------------------------- +# Shared integration fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def profile_engine(): + """In-memory SQLite engine for IMAP profile tests.""" + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + yield engine + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture() +def profile_session(profile_engine): + """DB session scoped to one test.""" + Session = sessionmaker(bind=profile_engine) + session = Session() + yield session + session.close() + + +@pytest.fixture() +def profile_client(profile_engine): + """TestClient authenticated as _OWNER with DB overridden.""" + from app.api.imap_profiles import _get_owner_id + from app.main import app + + def override_db(): + Session = sessionmaker(bind=profile_engine) + session = Session() + try: + yield session + finally: + session.close() + + def override_owner(): + return _OWNER + + app.dependency_overrides[get_db] = override_db + app.dependency_overrides[_get_owner_id] = override_owner + with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as c: + yield c + app.dependency_overrides.clear() + + +@pytest.fixture() +def anon_client(profile_engine): + """TestClient without authentication (DB still overridden).""" + from app.main import app + + def override_db(): + Session = sessionmaker(bind=profile_engine) + session = Session() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db] = override_db + with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as c: + yield c + app.dependency_overrides.clear() + + +def _make_profile( + session, + owner: str | None = _OWNER, + name: str = "Test Profile", + categories_json: str = '["pdf","office"]', + is_builtin: bool = False, +) -> ImapIngestionProfile: + """Create an ImapIngestionProfile row in the database.""" + prof = ImapIngestionProfile( + name=name, + description="A test profile", + owner_id=owner, + allowed_categories=categories_json, + is_builtin=is_builtin, + ) + session.add(prof) + session.commit() + session.refresh(prof) + return prof + @pytest.mark.unit class TestFileTypeCategories: @@ -156,3 +260,357 @@ class TestImapProfilesApiLogic: result = _to_response(profile) assert result["allowed_categories"] == [] + + def test_get_owner_id_returns_owner_when_authenticated(self): + """Test that _get_owner_id returns the owner_id when get_current_owner_id succeeds.""" + from unittest.mock import MagicMock, patch + + from app.api.imap_profiles import _get_owner_id + + request = MagicMock() + with patch("app.api.imap_profiles.get_current_owner_id", return_value="user@example.com"): + result = _get_owner_id(request) + assert result == "user@example.com" + + +# --------------------------------------------------------------------------- +# Integration tests – list categories endpoint +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestListCategories: + """Tests for GET /api/imap-profiles/categories.""" + + def test_list_categories_returns_all(self, profile_client): + """Authenticated request returns all available file-type categories.""" + resp = profile_client.get("/api/imap-profiles/categories") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + keys = {item["key"] for item in data} + assert keys == set(FILE_TYPE_CATEGORIES.keys()) + for item in data: + assert "key" in item + assert "label" in item + assert "description" in item + + def test_list_categories_unauthenticated(self, anon_client): + """Unauthenticated request returns 401.""" + resp = anon_client.get("/api/imap-profiles/categories") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# Integration tests – list profiles endpoint +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestListProfiles: + """Tests for GET /api/imap-profiles/.""" + + def test_list_empty(self, profile_client, profile_session): + """Listing profiles when none exist returns an empty list.""" + resp = profile_client.get("/api/imap-profiles/") + assert resp.status_code == 200 + assert resp.json() == [] + + def test_list_includes_own_profiles(self, profile_client, profile_session): + """Returns profiles owned by the current user.""" + _make_profile(profile_session, owner=_OWNER, name="My Profile") + resp = profile_client.get("/api/imap-profiles/") + assert resp.status_code == 200 + data = resp.json() + assert any(p["name"] == "My Profile" for p in data) + + def test_list_includes_global_profiles(self, profile_client, profile_session): + """Returns system-global profiles (owner_id=None).""" + _make_profile(profile_session, owner=None, name="Global Profile", is_builtin=True) + resp = profile_client.get("/api/imap-profiles/") + assert resp.status_code == 200 + data = resp.json() + assert any(p["name"] == "Global Profile" for p in data) + + def test_list_excludes_other_users_profiles(self, profile_client, profile_session): + """Profiles owned by other users are not returned.""" + _make_profile(profile_session, owner=_OTHER, name="Other Profile") + resp = profile_client.get("/api/imap-profiles/") + assert resp.status_code == 200 + data = resp.json() + assert not any(p["name"] == "Other Profile" for p in data) + + def test_list_unauthenticated(self, anon_client): + """Unauthenticated request returns 401.""" + resp = anon_client.get("/api/imap-profiles/") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# Integration tests – create profile endpoint +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestCreateProfile: + """Tests for POST /api/imap-profiles/.""" + + def test_create_success(self, profile_client, profile_session): + """Creating a valid profile returns 201 with the new profile data.""" + payload = {"name": "New Profile", "description": "desc", "allowed_categories": ["pdf", "office"]} + resp = profile_client.post("/api/imap-profiles/", json=payload) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "New Profile" + assert data["description"] == "desc" + assert data["allowed_categories"] == ["pdf", "office"] + assert data["is_builtin"] is False + assert data["owner_id"] == _OWNER + assert "id" in data + + def test_create_deduplicates_categories(self, profile_client): + """Duplicate categories in the request are de-duplicated.""" + payload = {"name": "Dedup Profile", "allowed_categories": ["pdf", "pdf", "office"]} + resp = profile_client.post("/api/imap-profiles/", json=payload) + assert resp.status_code == 201 + assert resp.json()["allowed_categories"] == ["pdf", "office"] + + def test_create_invalid_category_returns_422(self, profile_client): + """Unknown category keys cause a 422 response.""" + payload = {"name": "Bad Profile", "allowed_categories": ["pdf", "nonexistent"]} + resp = profile_client.post("/api/imap-profiles/", json=payload) + assert resp.status_code == 422 + + def test_create_missing_name_returns_422(self, profile_client): + """Missing required 'name' field causes a 422 response.""" + payload = {"allowed_categories": ["pdf"]} + resp = profile_client.post("/api/imap-profiles/", json=payload) + assert resp.status_code == 422 + + def test_create_empty_categories_returns_422(self, profile_client): + """An empty allowed_categories list causes a 422 response.""" + payload = {"name": "Empty Cats", "allowed_categories": []} + resp = profile_client.post("/api/imap-profiles/", json=payload) + assert resp.status_code == 422 + + def test_create_unauthenticated(self, anon_client): + """Unauthenticated request returns 401.""" + payload = {"name": "X", "allowed_categories": ["pdf"]} + resp = anon_client.post("/api/imap-profiles/", json=payload) + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# Integration tests – get single profile endpoint +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestGetProfile: + """Tests for GET /api/imap-profiles/{id}.""" + + def test_get_own_profile(self, profile_client, profile_session): + """Owner can retrieve their own profile.""" + prof = _make_profile(profile_session, owner=_OWNER) + resp = profile_client.get(f"/api/imap-profiles/{prof.id}") + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == prof.id + assert data["name"] == prof.name + + def test_get_global_profile(self, profile_client, profile_session): + """Any authenticated user can retrieve a global (owner_id=None) profile.""" + prof = _make_profile(profile_session, owner=None, name="Builtin", is_builtin=True) + resp = profile_client.get(f"/api/imap-profiles/{prof.id}") + assert resp.status_code == 200 + assert resp.json()["name"] == "Builtin" + + def test_get_not_found(self, profile_client): + """Requesting a non-existent profile returns 404.""" + resp = profile_client.get("/api/imap-profiles/99999") + assert resp.status_code == 404 + + def test_get_other_user_profile_returns_404(self, profile_client, profile_session): + """Accessing another user's private profile returns 404.""" + prof = _make_profile(profile_session, owner=_OTHER, name="Private") + resp = profile_client.get(f"/api/imap-profiles/{prof.id}") + assert resp.status_code == 404 + + def test_get_unauthenticated(self, anon_client, profile_session): + """Unauthenticated request returns 401.""" + prof = _make_profile(profile_session, owner=None, is_builtin=True) + resp = anon_client.get(f"/api/imap-profiles/{prof.id}") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# Integration tests – update profile endpoint +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestUpdateProfile: + """Tests for PUT /api/imap-profiles/{id}.""" + + def test_update_name(self, profile_client, profile_session): + """Updating the name of an owned profile returns the updated profile.""" + prof = _make_profile(profile_session, owner=_OWNER, name="Old Name") + resp = profile_client.put(f"/api/imap-profiles/{prof.id}", json={"name": "New Name"}) + assert resp.status_code == 200 + assert resp.json()["name"] == "New Name" + + def test_update_categories(self, profile_client, profile_session): + """Updating allowed_categories replaces the previous value.""" + prof = _make_profile(profile_session, owner=_OWNER, categories_json='["pdf"]') + resp = profile_client.put(f"/api/imap-profiles/{prof.id}", json={"allowed_categories": ["office", "text"]}) + assert resp.status_code == 200 + assert resp.json()["allowed_categories"] == ["office", "text"] + + def test_update_description(self, profile_client, profile_session): + """Setting description via model_fields_set path updates it.""" + prof = _make_profile(profile_session, owner=_OWNER) + resp = profile_client.put(f"/api/imap-profiles/{prof.id}", json={"description": "Updated desc"}) + assert resp.status_code == 200 + assert resp.json()["description"] == "Updated desc" + + def test_update_builtin_returns_403(self, profile_client, profile_session): + """Attempting to update a built-in profile returns 403.""" + prof = _make_profile(profile_session, owner=None, is_builtin=True) + resp = profile_client.put(f"/api/imap-profiles/{prof.id}", json={"name": "Renamed"}) + assert resp.status_code == 403 + + def test_update_not_found(self, profile_client): + """Updating a non-existent profile returns 404.""" + resp = profile_client.put("/api/imap-profiles/99999", json={"name": "X"}) + assert resp.status_code == 404 + + def test_update_other_user_profile_returns_404(self, profile_client, profile_session): + """Updating another user's profile returns 404.""" + prof = _make_profile(profile_session, owner=_OTHER) + resp = profile_client.put(f"/api/imap-profiles/{prof.id}", json={"name": "X"}) + assert resp.status_code == 404 + + def test_update_invalid_category_returns_422(self, profile_client, profile_session): + """Updating with an invalid category key returns 422.""" + prof = _make_profile(profile_session, owner=_OWNER) + resp = profile_client.put(f"/api/imap-profiles/{prof.id}", json={"allowed_categories": ["badcat"]}) + assert resp.status_code == 422 + + def test_update_unauthenticated(self, anon_client, profile_session): + """Unauthenticated request returns 401.""" + prof = _make_profile(profile_session, owner=_OWNER) + resp = anon_client.put(f"/api/imap-profiles/{prof.id}", json={"name": "X"}) + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# Integration tests – delete profile endpoint +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestDeleteProfile: + """Tests for DELETE /api/imap-profiles/{id}.""" + + def test_delete_success(self, profile_client, profile_session): + """Deleting an owned profile returns 204 and removes the row.""" + prof = _make_profile(profile_session, owner=_OWNER) + resp = profile_client.delete(f"/api/imap-profiles/{prof.id}") + assert resp.status_code == 204 + # Verify it is gone + get_resp = profile_client.get(f"/api/imap-profiles/{prof.id}") + assert get_resp.status_code == 404 + + def test_delete_builtin_returns_403(self, profile_client, profile_session): + """Attempting to delete a built-in profile returns 403.""" + prof = _make_profile(profile_session, owner=None, is_builtin=True) + resp = profile_client.delete(f"/api/imap-profiles/{prof.id}") + assert resp.status_code == 403 + + def test_delete_not_found(self, profile_client): + """Deleting a non-existent profile returns 404.""" + resp = profile_client.delete("/api/imap-profiles/99999") + assert resp.status_code == 404 + + def test_delete_other_user_profile_returns_404(self, profile_client, profile_session): + """Deleting another user's private profile returns 404.""" + prof = _make_profile(profile_session, owner=_OTHER) + resp = profile_client.delete(f"/api/imap-profiles/{prof.id}") + assert resp.status_code == 404 + + def test_delete_unauthenticated(self, anon_client, profile_session): + """Unauthenticated request returns 401.""" + prof = _make_profile(profile_session, owner=_OWNER) + resp = anon_client.delete(f"/api/imap-profiles/{prof.id}") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# Integration tests – DB error rollback paths +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestDbErrorRollback: + """Tests for exception-handling / rollback paths in create, update, and delete.""" + + def _make_failing_client(self, profile_engine, profile_session, *, fail_on: str = "commit"): + """Return a TestClient whose DB session raises RuntimeError on commit/delete.""" + from app.api.imap_profiles import _get_owner_id + from app.main import app + + Session = sessionmaker(bind=profile_engine) + + def override_db(): + session = Session() + + def raise_error(*args, **kwargs): + raise RuntimeError("Simulated DB failure") + + setattr(session, fail_on, raise_error) + try: + yield session + finally: + session.close() + + def override_owner(): + return _OWNER + + app.dependency_overrides[get_db] = override_db + app.dependency_overrides[_get_owner_id] = override_owner + return TestClient(app, base_url="http://localhost", raise_server_exceptions=False) + + def test_create_db_error_returns_500(self, profile_engine, profile_session): + """A DB error during create triggers rollback and returns 500.""" + client = self._make_failing_client(profile_engine, profile_session) + try: + resp = client.post("/api/imap-profiles/", json={"name": "X", "allowed_categories": ["pdf"]}) + finally: + from app.main import app + + app.dependency_overrides.clear() + assert resp.status_code == 500 + + def test_update_db_error_returns_500(self, profile_engine, profile_session): + """A DB error during update triggers rollback and returns 500.""" + prof = _make_profile(profile_session, owner=_OWNER) + client = self._make_failing_client(profile_engine, profile_session) + try: + resp = client.put(f"/api/imap-profiles/{prof.id}", json={"name": "New"}) + finally: + from app.main import app + + app.dependency_overrides.clear() + assert resp.status_code == 500 + + def test_delete_db_error_returns_500(self, profile_engine, profile_session): + """A DB error during delete triggers rollback and returns 500.""" + prof = _make_profile(profile_session, owner=_OWNER) + client = self._make_failing_client(profile_engine, profile_session, fail_on="delete") + try: + resp = client.delete(f"/api/imap-profiles/{prof.id}") + finally: + from app.main import app + + app.dependency_overrides.clear() + assert resp.status_code == 500 diff --git a/tests/test_imap_tasks.py b/tests/test_imap_tasks.py index 36a73ed5..694d4bee 100644 --- a/tests/test_imap_tasks.py +++ b/tests/test_imap_tasks.py @@ -8,6 +8,9 @@ from unittest.mock import MagicMock, patch import pytest from app.tasks.imap_tasks import ( + _decrypt_imap_password, + _pull_user_imap_accounts, + _resolve_categories_for_profile, acquire_lock, check_and_pull_mailbox, cleanup_old_entries, @@ -678,7 +681,6 @@ class TestPullInbox: mock_mail.store.assert_called_with(b"1", "-FLAGS", "\\Seen") mock_save.assert_called() - @patch("app.tasks.imap_tasks.is_private_ip", new=lambda _: False) @patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue") @patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL") @patch("app.tasks.imap_tasks.load_processed_emails") @@ -922,7 +924,6 @@ class TestPullInbox: # Should not process the message mock_mail.store.assert_not_called() - @patch("app.tasks.imap_tasks.is_private_ip", new=lambda _: False) @patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL") @patch("app.tasks.imap_tasks.load_processed_emails") def test_handles_fetch_failure(self, mock_load, mock_imap_class): @@ -1025,7 +1026,6 @@ class TestPullInbox: # Processed emails cache should still be updated mock_save.assert_called() - @patch("app.tasks.imap_tasks.is_private_ip", new=lambda _: False) @patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue") @patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL") @patch("app.tasks.imap_tasks.load_processed_emails") @@ -1385,7 +1385,6 @@ class TestAcquireReleaseLockEdgeCases: class TestPullInboxEdgeCases: """Test edge cases for pull_inbox function.""" - @patch("app.tasks.imap_tasks.is_private_ip", new=lambda _: False) @patch("app.tasks.imap_tasks.load_processed_emails") @patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL") @patch("app.tasks.imap_tasks.settings") @@ -1436,7 +1435,6 @@ class TestPullInboxEdgeCases: # Should skip processing since no Message-ID mock_fetch.assert_not_called() - @patch("app.tasks.imap_tasks.is_private_ip", new=lambda _: False) @patch("app.tasks.imap_tasks.load_processed_emails") @patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL") @patch("app.tasks.imap_tasks.settings") @@ -1735,3 +1733,406 @@ class TestPullAllInboxesCallsIntegrations: pull_all_inboxes() mock_legacy.assert_called_once() mock_integ.assert_called_once() + + +# --------------------------------------------------------------------------- +# Tests for _decrypt_imap_password +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestDecryptImapPassword: + """Tests for _decrypt_imap_password function.""" + + def test_returns_decrypted_value(self): + """_decrypt_imap_password should delegate to decrypt_value.""" + with patch("app.utils.encryption.decrypt_value", return_value="decrypted") as mock_decrypt: + result = _decrypt_imap_password("enc:something") + mock_decrypt.assert_called_once_with("enc:something") + assert result == "decrypted" + + def test_returns_none_for_none_input(self): + """_decrypt_imap_password should return None for None input.""" + with patch("app.utils.encryption.decrypt_value", return_value=None): + result = _decrypt_imap_password(None) + assert result is None + + def test_returns_plaintext_unchanged(self): + """_decrypt_imap_password returns plaintext passwords unchanged.""" + with patch("app.utils.encryption.decrypt_value", return_value="plain") as mock_decrypt: + result = _decrypt_imap_password("plain") + mock_decrypt.assert_called_once_with("plain") + assert result == "plain" + + +# --------------------------------------------------------------------------- +# Tests for _resolve_categories_for_profile +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestResolveCategoriesForProfile: + """Tests for _resolve_categories_for_profile function.""" + + @patch("app.tasks.imap_tasks._get_db_session") + def test_returns_profile_categories_when_profile_found(self, mock_session_factory): + """Returns categories from the profile when profile exists in DB.""" + import json + + mock_profile = MagicMock() + mock_profile.allowed_categories = json.dumps(["pdf", "images"]) + + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = mock_profile + mock_session_factory.return_value = mock_db + + result = _resolve_categories_for_profile(1) + assert result == ["pdf", "images"] + mock_db.close.assert_called_once() + + @patch("app.tasks.imap_tasks._get_db_session") + def test_falls_back_to_default_when_profile_not_found(self, mock_session_factory): + """Falls back to global default when profile_id exists but profile is not in DB.""" + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = None + mock_session_factory.return_value = mock_db + + with patch("app.tasks.imap_tasks.settings") as mock_settings: + mock_settings.imap_attachment_filter = "documents_only" + result = _resolve_categories_for_profile(99) + + assert result == DEFAULT_CATEGORIES + + @patch("app.tasks.imap_tasks._get_db_session") + def test_falls_back_when_db_raises_exception(self, mock_session_factory): + """Falls back to global default when DB query raises an exception.""" + mock_session_factory.side_effect = Exception("DB error") + + with patch("app.tasks.imap_tasks.settings") as mock_settings: + mock_settings.imap_attachment_filter = "documents_only" + result = _resolve_categories_for_profile(5) + + assert result == DEFAULT_CATEGORIES + + def test_returns_all_categories_when_filter_is_all(self): + """Returns ALL_CATEGORIES when profile_id is None and filter is 'all'.""" + with patch("app.tasks.imap_tasks.settings") as mock_settings: + mock_settings.imap_attachment_filter = "all" + result = _resolve_categories_for_profile(None) + assert result == ALL_CATEGORIES + + def test_returns_default_categories_when_filter_is_not_all(self): + """Returns DEFAULT_CATEGORIES when profile_id is None and filter is not 'all'.""" + with patch("app.tasks.imap_tasks.settings") as mock_settings: + mock_settings.imap_attachment_filter = "documents_only" + result = _resolve_categories_for_profile(None) + assert result == DEFAULT_CATEGORIES + + +# --------------------------------------------------------------------------- +# Tests for _pull_user_imap_accounts +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestPullUserImapAccounts: + """Tests for _pull_user_imap_accounts function.""" + + @patch("app.tasks.imap_tasks._get_db_session") + @patch("app.tasks.imap_tasks.pull_inbox") + def test_polls_active_accounts(self, mock_pull, mock_session_factory): + """Active accounts should be polled and last_checked_at updated.""" + mock_acct = MagicMock() + mock_acct.id = 1 + mock_acct.owner_id = "user-1" + mock_acct.host = "imap.example.com" + mock_acct.port = 993 + mock_acct.username = "user@example.com" + mock_acct.password = "enc:pass" + mock_acct.use_ssl = True + mock_acct.delete_after_process = False + mock_acct.profile_id = None + + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.all.return_value = [mock_acct] + mock_session_factory.return_value = mock_db + + with patch("app.utils.encryption.decrypt_value", return_value="plainpass"): + _pull_user_imap_accounts() + + mock_pull.assert_called_once() + call_kwargs = mock_pull.call_args.kwargs + assert call_kwargs["host"] == "imap.example.com" + assert call_kwargs["owner_id"] == "user-1" + assert mock_acct.last_error is None + assert mock_acct.last_checked_at is not None + mock_db.commit.assert_called() + mock_db.close.assert_called_once() + + @patch("app.tasks.imap_tasks._get_db_session") + @patch("app.tasks.imap_tasks.pull_inbox") + def test_records_error_on_pull_failure(self, mock_pull, mock_session_factory): + """Errors during pull_inbox should be recorded on the account.""" + mock_acct = MagicMock() + mock_acct.id = 2 + mock_acct.owner_id = "user-2" + mock_acct.host = "imap.bad.com" + mock_acct.port = 993 + mock_acct.username = "u@bad.com" + mock_acct.password = "enc:bad" + mock_acct.use_ssl = True + mock_acct.delete_after_process = False + mock_acct.profile_id = None + + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.all.return_value = [mock_acct] + mock_session_factory.return_value = mock_db + + mock_pull.side_effect = Exception("Connection refused") + + with patch("app.utils.encryption.decrypt_value", return_value="p"): + _pull_user_imap_accounts() + + assert mock_acct.last_error is not None + assert "Connection refused" in mock_acct.last_error + mock_db.commit.assert_called() + + @patch("app.tasks.imap_tasks._get_db_session") + @patch("app.tasks.imap_tasks.pull_inbox") + def test_rollback_when_error_commit_fails(self, mock_pull, mock_session_factory): + """When both pull_inbox and the error-recording commit fail, db.rollback is called.""" + mock_acct = MagicMock() + mock_acct.id = 3 + mock_acct.owner_id = "user-3" + mock_acct.host = "imap.fail.com" + mock_acct.port = 993 + mock_acct.username = "u@fail.com" + mock_acct.password = "enc:bad" + mock_acct.use_ssl = True + mock_acct.delete_after_process = False + mock_acct.profile_id = None + + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.all.return_value = [mock_acct] + # The error-recording commit itself raises + mock_db.commit.side_effect = Exception("DB unavailable") + mock_session_factory.return_value = mock_db + + mock_pull.side_effect = Exception("IMAP error") + + with patch("app.utils.encryption.decrypt_value", return_value="p"): + _pull_user_imap_accounts() + + mock_db.rollback.assert_called() + + @patch("app.tasks.imap_tasks._get_db_session") + def test_handles_db_failure_gracefully(self, mock_session_factory): + """DB failures when loading accounts should be caught gracefully.""" + mock_session_factory.side_effect = Exception("DB unavailable") + # Should not raise + _pull_user_imap_accounts() + + @patch("app.tasks.imap_tasks._get_db_session") + @patch("app.tasks.imap_tasks.pull_inbox") + def test_no_accounts_does_not_call_pull(self, mock_pull, mock_session_factory): + """When no active accounts exist, pull_inbox should not be called.""" + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.all.return_value = [] + mock_session_factory.return_value = mock_db + + _pull_user_imap_accounts() + + mock_pull.assert_not_called() + mock_db.close.assert_called_once() + + +# --------------------------------------------------------------------------- +# Tests for _pull_user_integration_imap rollback path +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestPullUserIntegrationImapRollback: + """Tests for the db.rollback path in _pull_user_integration_imap.""" + + @patch("app.tasks.imap_tasks._get_db_session") + @patch("app.tasks.imap_tasks.pull_inbox") + def test_rollback_when_error_commit_fails(self, mock_pull, mock_session_factory): + """When both pull_inbox and the error-recording commit raise, db.rollback is called.""" + from app.tasks.imap_tasks import _pull_user_integration_imap + + mock_integ = MagicMock() + mock_integ.id = 99 + mock_integ.owner_id = "owner-fail" + mock_integ.config = '{"host": "bad.host", "port": 993, "username": "u@x.com", "use_ssl": true}' + mock_integ.credentials = "enc:encrypted" + mock_integ.is_active = True + + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.all.return_value = [mock_integ] + # The error-recording commit itself raises + mock_db.commit.side_effect = Exception("DB unavailable") + mock_session_factory.return_value = mock_db + + mock_pull.side_effect = Exception("Connection refused") + + with patch("app.utils.encryption.decrypt_value", return_value='{"password": "p"}'): + _pull_user_integration_imap() + + mock_db.rollback.assert_called() + + +# --------------------------------------------------------------------------- +# Tests for pull_inbox with allowed_categories=None (resolves via profile) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestPullInboxAllowedCategoriesDefault: + """Tests for pull_inbox when allowed_categories is not provided.""" + + @patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL") + @patch("app.tasks.imap_tasks.load_processed_emails") + @patch("app.tasks.imap_tasks._resolve_categories_for_profile") + def test_resolves_categories_when_none_passed(self, mock_resolve, mock_load, mock_imap_class): + """pull_inbox should call _resolve_categories_for_profile(None) when allowed_categories=None.""" + mock_resolve.return_value = DEFAULT_CATEGORIES + mock_load.return_value = {} + + mock_mail = MagicMock() + mock_imap_class.return_value = mock_mail + mock_mail.login.return_value = ("OK", []) + mock_mail.select.return_value = ("OK", []) + mock_mail.search.return_value = ("OK", [b""]) + + pull_inbox( + mailbox_key="imap1", + host="imap.example.com", + port=993, + username="user", + password=_TEST_CREDENTIAL, + use_ssl=True, + delete_after_process=False, + allowed_categories=None, # explicit None triggers resolve + ) + + mock_resolve.assert_called_once_with(None) + + @patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL") + @patch("app.tasks.imap_tasks.load_processed_emails") + @patch("app.tasks.imap_tasks._resolve_categories_for_profile") + def test_skips_resolve_when_categories_provided(self, mock_resolve, mock_load, mock_imap_class): + """pull_inbox should not call _resolve_categories_for_profile when allowed_categories is given.""" + mock_load.return_value = {} + + mock_mail = MagicMock() + mock_imap_class.return_value = mock_mail + mock_mail.login.return_value = ("OK", []) + mock_mail.select.return_value = ("OK", []) + mock_mail.search.return_value = ("OK", [b""]) + + pull_inbox( + mailbox_key="imap1", + host="imap.example.com", + port=993, + username="user", + password=_TEST_CREDENTIAL, + use_ssl=True, + delete_after_process=False, + allowed_categories=DEFAULT_CATEGORIES, # non-None skips resolve + ) + + mock_resolve.assert_not_called() + + +# --------------------------------------------------------------------------- +# Tests for fetch_attachments_and_enqueue: extension-only match (not PDF, not MIME) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestFetchAttachmentsExtensionOnlyMatch: + """Tests for fetch_attachments_and_enqueue when file passes only via extension.""" + + @patch("app.tasks.imap_tasks.process_document") + @patch("app.tasks.imap_tasks.convert_to_pdf") + def test_extension_match_without_mime_type_match(self, mock_convert, mock_process, tmp_path): + """A file with an allowed extension but wrong MIME type (not PDF) hits the else branch.""" + # .docx is in allowed extensions, but application/octet-stream is not in allowed MIME types + # and it's not a PDF by extension -> passes the filter but skips both dispatch branches + msg = EmailMessage() + msg["Subject"] = "Test" + msg.add_attachment( + b"docx content", + maintype="application", + subtype="octet-stream", # wrong MIME type + filename="document.docx", # allowed extension + ) + + doc_mime, doc_ext = get_allowed_types_for_categories(DEFAULT_CATEGORIES) + with patch("app.tasks.imap_tasks.settings") as mock_settings: + mock_settings.workdir = str(tmp_path) + result = fetch_attachments_and_enqueue(msg, effective_mime_types=doc_mime, effective_extensions=doc_ext) + + # File is "accepted" (has_attachment=True) but no task is dispatched because + # neither the PDF nor the mime-type branch matched + assert result is True + mock_process.delay.assert_not_called() + mock_convert.delay.assert_not_called() + + +# --------------------------------------------------------------------------- +# Tests for find_all_mail_folder: XLIST returns None +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestFindAllMailFolderXlistNone: + """Tests for find_all_mail_folder when XLIST is available but returns None.""" + + @patch("app.tasks.imap_tasks.find_all_mail_xlist") + @patch("app.tasks.imap_tasks.get_capabilities") + def test_returns_none_when_xlist_finds_nothing(self, mock_get_caps, mock_xlist): + """Should return None when XLIST is supported but finds no All Mail folder.""" + mock_mail = MagicMock() + mock_mail.select.return_value = ("NO", None) # All common names fail + mock_get_caps.return_value = ["XLIST", "IMAP4REV1"] + mock_xlist.return_value = None # XLIST also found nothing + + result = find_all_mail_folder(mock_mail) + assert result is None + mock_xlist.assert_called_once_with(mock_mail) + + +# --------------------------------------------------------------------------- +# Tests for find_all_mail_xlist edge cases +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestFindAllMailXlistEdgeCasesExtended: + """Additional edge-case tests for find_all_mail_xlist.""" + + def test_returns_none_when_readline_returns_empty_bytes(self): + """Should break out of loop and return None when readline returns empty bytes.""" + mock_mail = MagicMock() + mock_mail._new_tag.return_value = b"A001" + # readline returns empty bytes immediately -> break on first iteration + mock_mail.readline.return_value = b"" + + result = find_all_mail_xlist(mock_mail) + assert result is None + + def test_allmail_line_without_quoted_folder_name(self): + """XLIST AllMail line where regex finds no quoted name should not set folder.""" + mock_mail = MagicMock() + mock_mail._new_tag.return_value = b"A001" + # XLIST response where AllMail flag is present but no double-quoted folder name at end + # -> regex r'"([^"]+)"$' will not match so all_mail_folder stays None + mock_mail.readline.side_effect = [ + b"* XLIST (\\AllMail) / NoQuotesHere\r\n", + b"A001 OK XLIST completed\r\n", + ] + + result = find_all_mail_xlist(mock_mail) + assert result is None diff --git a/tests/test_local_auth.py b/tests/test_local_auth.py index 8a53604c..babbbc30 100644 --- a/tests/test_local_auth.py +++ b/tests/test_local_auth.py @@ -322,6 +322,35 @@ def test_signup_duplicate_username(la_client, active_user): assert "Username" in resp.json()["detail"] +@pytest.mark.integration +def test_signup_invalid_username_with_dot(la_client): + """POST /api/auth/signup returns 422 with a list detail when username contains a dot. + + This is a regression test for the bug where ``data.detail`` was an array, + causing the frontend to display ``[object Object]`` instead of a message. + """ + with patch("app.api.local_auth.settings") as mock_settings: + mock_settings.allow_local_signup = True + mock_settings.multi_user_enabled = True + mock_settings.email_host = "smtp.example.com" + resp = la_client.post( + "/api/auth/signup", + json={ + "email": "a@example.com", + "username": "christian.louis", + "password": "password1", + "password_confirm": "password1", + }, + ) + assert resp.status_code == 422 + detail = resp.json()["detail"] + # FastAPI returns a list of validation errors for Pydantic constraint failures. + # Each entry must be a dict with a "msg" key so the frontend can extract a readable message. + assert isinstance(detail, list), "detail should be a list for Pydantic validation errors" + assert len(detail) > 0 + assert "msg" in detail[0] + + @pytest.mark.integration def test_signup_smtp_failure_cleans_up(la_client, la_session): """POST /api/auth/signup cleans up user records if email send fails.""" diff --git a/tests/test_sentry.py b/tests/test_sentry.py index ca0b12bb..a24fcd25 100644 --- a/tests/test_sentry.py +++ b/tests/test_sentry.py @@ -210,6 +210,141 @@ class TestGetAppVersion: assert _get_app_version() is None +@pytest.mark.unit +class TestSentryJsTemplateContext: + """Test that Sentry Browser SDK config is injected into the template context.""" + + def test_sentry_dsn_exposed_when_configured(self, mocker): + """sentry_dsn is set in the template context when SENTRY_DSN is configured.""" + mock_settings = mocker.patch("app.views.base.settings") + mock_settings.sentry_dsn = "https://key@o1.ingest.sentry.io/1" + mock_settings.sentry_environment = "production" + mock_settings.sentry_js_traces_sample_rate = 0.1 + mock_settings.sentry_js_replay_session_sample_rate = 0.0 + mock_settings.sentry_js_replay_on_error_sample_rate = 0.1 + + from app.views.base import _inject_global_context + + ctx: dict = {} + _inject_global_context(ctx) + + assert ctx["sentry_dsn"] == "https://key@o1.ingest.sentry.io/1" + + def test_sentry_dsn_none_when_not_configured(self, mocker): + """sentry_dsn is None in the template context when SENTRY_DSN is unset.""" + mock_settings = mocker.patch("app.views.base.settings") + mock_settings.sentry_dsn = None + mock_settings.sentry_environment = "production" + mock_settings.sentry_js_traces_sample_rate = 0.0 + mock_settings.sentry_js_replay_session_sample_rate = 0.0 + mock_settings.sentry_js_replay_on_error_sample_rate = 0.1 + + from app.views.base import _inject_global_context + + ctx: dict = {} + _inject_global_context(ctx) + + assert ctx["sentry_dsn"] is None + + def test_sentry_dsn_empty_string_becomes_none(self, mocker): + """An empty-string SENTRY_DSN is normalised to None in the template context.""" + mock_settings = mocker.patch("app.views.base.settings") + mock_settings.sentry_dsn = "" + mock_settings.sentry_environment = "production" + mock_settings.sentry_js_traces_sample_rate = 0.0 + mock_settings.sentry_js_replay_session_sample_rate = 0.0 + mock_settings.sentry_js_replay_on_error_sample_rate = 0.1 + + from app.views.base import _inject_global_context + + ctx: dict = {} + _inject_global_context(ctx) + + assert ctx["sentry_dsn"] is None + + def test_js_sample_rates_exposed_in_context(self, mocker): + """Browser SDK sample rates are passed through to the template context.""" + mock_settings = mocker.patch("app.views.base.settings") + mock_settings.sentry_dsn = "https://key@o1.ingest.sentry.io/1" + mock_settings.sentry_environment = "staging" + mock_settings.sentry_js_traces_sample_rate = 0.5 + mock_settings.sentry_js_replay_session_sample_rate = 0.2 + mock_settings.sentry_js_replay_on_error_sample_rate = 0.8 + + from app.views.base import _inject_global_context + + ctx: dict = {} + _inject_global_context(ctx) + + assert ctx["sentry_environment"] == "staging" + assert ctx["sentry_js_traces_sample_rate"] == 0.5 + assert ctx["sentry_js_replay_session_sample_rate"] == 0.2 + assert ctx["sentry_js_replay_on_error_sample_rate"] == 0.8 + + def test_js_sample_rates_default_values(self, mocker): + """Browser SDK sample rates fall back to safe defaults when attrs are absent.""" + mock_settings = mocker.patch("app.views.base.settings") + # Simulate settings object without the new JS attributes + del mock_settings.sentry_js_traces_sample_rate + del mock_settings.sentry_js_replay_session_sample_rate + del mock_settings.sentry_js_replay_on_error_sample_rate + mock_settings.sentry_dsn = None + mock_settings.sentry_environment = "production" + + from app.views.base import _inject_global_context + + ctx: dict = {} + _inject_global_context(ctx) + + assert ctx["sentry_js_traces_sample_rate"] == 0.0 + assert ctx["sentry_js_replay_session_sample_rate"] == 0.0 + assert ctx["sentry_js_replay_on_error_sample_rate"] == 0.1 + + +_MINIMAL_SETTINGS_KWARGS = { + "database_url": "sqlite:///./test.db", + "redis_url": "redis://localhost:6379", + "openai_api_key": "test", + "azure_ai_key": "test", + "azure_region": "test", + "azure_endpoint": "https://test.example.com", + "gotenberg_url": "http://localhost:3000", + "workdir": "/tmp", + "auth_enabled": False, + "session_secret": "a-test-secret-that-is-at-least-32-chars-long!", +} + + +@pytest.mark.unit +class TestSentryJsConfig: + """Test the new JS-specific Settings fields.""" + + def test_js_traces_sample_rate_default(self): + """SENTRY_JS_TRACES_SAMPLE_RATE defaults to 0.0.""" + from app.config import settings + + assert settings.sentry_js_traces_sample_rate == 0.0 + + def test_js_replay_session_sample_rate_default(self): + """SENTRY_JS_REPLAY_SESSION_SAMPLE_RATE defaults to 0.0.""" + from app.config import settings + + assert settings.sentry_js_replay_session_sample_rate == 0.0 + + def test_js_replay_on_error_sample_rate_default(self): + """SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE defaults to 0.1.""" + from app.config import settings + + assert settings.sentry_js_replay_on_error_sample_rate == 0.1 + + def test_js_traces_sample_rate_can_be_set(self): + """SENTRY_JS_TRACES_SAMPLE_RATE can be set directly via constructor.""" + from app.config import Settings + + s = Settings(**_MINIMAL_SETTINGS_KWARGS, sentry_js_traces_sample_rate=0.5) + assert s.sentry_js_traces_sample_rate == 0.5 + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/tests/test_setup_wizard.py b/tests/test_setup_wizard.py index a7e1a30f..3b2c39e8 100644 --- a/tests/test_setup_wizard.py +++ b/tests/test_setup_wizard.py @@ -36,6 +36,63 @@ class TestGetRequiredSettings: assert "session_secret" in keys assert "openai_api_key" in keys + def test_sensitive_flags_correct(self): + """Test that sensitive flag is set correctly for each setting.""" + settings_map = {s["key"]: s for s in get_required_settings()} + # Sensitive settings + assert settings_map["session_secret"]["sensitive"] is True + assert settings_map["admin_password"]["sensitive"] is True + assert settings_map["openai_api_key"]["sensitive"] is True + # Non-sensitive settings + assert settings_map["database_url"]["sensitive"] is False + assert settings_map["redis_url"]["sensitive"] is False + assert settings_map["workdir"]["sensitive"] is False + assert settings_map["admin_username"]["sensitive"] is False + + def test_wizard_step_assignments(self): + """Test that settings are assigned to the correct wizard steps.""" + settings_map = {s["key"]: s for s in get_required_settings()} + # Step 1: Core infrastructure + assert settings_map["database_url"]["wizard_step"] == 1 + assert settings_map["redis_url"]["wizard_step"] == 1 + assert settings_map["workdir"]["wizard_step"] == 1 + assert settings_map["gotenberg_url"]["wizard_step"] == 1 + # Step 2: Security + assert settings_map["session_secret"]["wizard_step"] == 2 + assert settings_map["admin_username"]["wizard_step"] == 2 + assert settings_map["admin_password"]["wizard_step"] == 2 + # Step 3: AI Services + assert settings_map["ai_provider"]["wizard_step"] == 3 + assert settings_map["openai_api_key"]["wizard_step"] == 3 + assert settings_map["openai_model"]["wizard_step"] == 3 + + def test_wizard_categories_correct(self): + """Test that wizard categories are set correctly.""" + settings_map = {s["key"]: s for s in get_required_settings()} + assert settings_map["database_url"]["wizard_category"] == "Core Infrastructure" + assert settings_map["session_secret"]["wizard_category"] == "Security" + assert settings_map["ai_provider"]["wizard_category"] == "AI Services" + + def test_ai_provider_has_options(self): + """Test that ai_provider setting has a list of options.""" + settings_map = {s["key"]: s for s in get_required_settings()} + ai_provider = settings_map["ai_provider"] + assert "options" in ai_provider + assert isinstance(ai_provider["options"], list) + assert len(ai_provider["options"]) > 0 + assert "openai" in ai_provider["options"] + + def test_settings_have_string_type(self): + """Test that all settings have the 'string' type.""" + for setting in get_required_settings(): + assert setting["type"] == "string", f"Expected string type for {setting['key']}" + + def test_total_settings_count(self): + """Test that the expected number of required settings is returned.""" + # Ensures no accidental additions or removals + result = get_required_settings() + assert len(result) == 10 + @pytest.mark.unit class TestIsSetupRequired: @@ -46,32 +103,55 @@ class TestIsSetupRequired: result = is_setup_required() assert isinstance(result, bool) - def test_setup_required_with_test_key(self): - """Test that setup is required when using test-key placeholder.""" - # In test environment, openai_api_key is "test-key" which is a placeholder + def test_setup_required_when_admin_password_is_none(self): + """Test that setup is required when admin_password is None (test environment default).""" + # In the test environment, admin_password defaults to None which is a placeholder value result = is_setup_required() assert result is True @patch("app.utils.setup_wizard.settings") def test_setup_not_required_with_real_values(self, mock_settings): - """Test that setup is not required with real values.""" + """Test that setup is not required when both critical settings have real values. + + is_setup_required() only checks session_secret and admin_password, so only + these two attributes need to be configured on the mock. + """ mock_settings.session_secret = "a_very_long_real_session_secret_that_is_definitely_not_placeholder" mock_settings.admin_password = "my_real_secure_password_123" - mock_settings.openai_api_key = "sk-real-key-12345" - mock_settings.azure_ai_key = "real-azure-key-12345" result = is_setup_required() assert result is False @patch("app.utils.setup_wizard.settings") - def test_handles_exception_gracefully(self, mock_settings): - """Test that exceptions are handled gracefully.""" - mock_settings.session_secret = property(lambda self: (_ for _ in ()).throw(Exception("test"))) - # getattr on a mock with side_effect - type(mock_settings).session_secret = property(lambda s: (_ for _ in ()).throw(RuntimeError("boom"))) - # This should not raise - it returns False on error + def test_setup_required_with_insecure_session_secret(self, mock_settings): + """Test that setup is required when session_secret is the insecure default.""" + mock_settings.session_secret = "INSECURE_DEFAULT_FOR_DEVELOPMENT_ONLY_DO_NOT_USE_IN_PRODUCTION_MINIMUM_32_CHARS" + mock_settings.admin_password = "real_password_123" result = is_setup_required() - # May return True or False depending on which setting fails, but shouldn't raise - assert isinstance(result, bool) + assert result is True + + @pytest.mark.parametrize( + "placeholder", + [None, "", "your_secure_password", "changeme", "admin"], + ) + @patch("app.utils.setup_wizard.settings") + def test_setup_required_for_each_admin_password_placeholder(self, mock_settings, placeholder): + """Test that setup is required for each admin_password placeholder value.""" + mock_settings.session_secret = "a_very_long_real_session_secret_that_is_definitely_not_placeholder" + mock_settings.admin_password = placeholder + result = is_setup_required() + assert result is True + + @patch("app.utils.setup_wizard.settings") + def test_handles_exception_gracefully(self, mock_settings): + """Test that exceptions are handled gracefully and return False (fail open).""" + + def raise_error(): + raise RuntimeError("boom") + + type(mock_settings).session_secret = property(lambda s: raise_error()) + # This should not raise - it returns False on error (fail open) + result = is_setup_required() + assert result is False @pytest.mark.unit @@ -89,6 +169,102 @@ class TestGetMissingRequiredSettings: # In test environment, openai_api_key is "test-key" which is a placeholder assert "openai_api_key" in missing + @patch("app.utils.setup_wizard.settings") + def test_returns_empty_when_all_configured(self, mock_settings): + """Test that returns empty list when all settings are properly configured.""" + mock_settings.database_url = "sqlite:///./real.db" + mock_settings.redis_url = "redis://localhost:6379/0" + mock_settings.workdir = "/data/workdir" + mock_settings.gotenberg_url = "http://gotenberg:3000" + mock_settings.session_secret = "a_very_long_real_session_secret_minimum_32_chars" + mock_settings.admin_username = "admin" + mock_settings.admin_password = "real_secure_password_456" + mock_settings.ai_provider = "openai" + mock_settings.openai_api_key = "sk-real-key-12345" + mock_settings.openai_model = "gpt-4o-mini" + result = get_missing_required_settings() + assert result == [] + + @patch("app.utils.setup_wizard.settings") + def test_detects_none_value_as_missing(self, mock_settings): + """Test that a None value is detected as missing.""" + mock_settings.database_url = None + mock_settings.redis_url = "redis://localhost:6379/0" + mock_settings.workdir = "/data/workdir" + mock_settings.gotenberg_url = "http://gotenberg:3000" + mock_settings.session_secret = "a_very_long_real_session_secret_minimum_32_chars" + mock_settings.admin_username = "admin" + mock_settings.admin_password = "real_secure_password_456" + mock_settings.ai_provider = "openai" + mock_settings.openai_api_key = "sk-real-key-12345" + mock_settings.openai_model = "gpt-4o-mini" + missing = get_missing_required_settings() + assert "database_url" in missing + + @patch("app.utils.setup_wizard.settings") + def test_detects_empty_string_as_missing(self, mock_settings): + """Test that an empty string value is detected as missing.""" + mock_settings.database_url = "sqlite:///./real.db" + mock_settings.redis_url = "redis://localhost:6379/0" + mock_settings.workdir = "/data/workdir" + mock_settings.gotenberg_url = "http://gotenberg:3000" + mock_settings.session_secret = "a_very_long_real_session_secret_minimum_32_chars" + mock_settings.admin_username = "admin" + mock_settings.admin_password = "" + mock_settings.ai_provider = "openai" + mock_settings.openai_api_key = "sk-real-key-12345" + mock_settings.openai_model = "gpt-4o-mini" + missing = get_missing_required_settings() + assert "admin_password" in missing + + @patch("app.utils.setup_wizard.settings") + def test_detects_insecure_default_as_missing(self, mock_settings): + """Test that the insecure default session_secret is detected as missing.""" + mock_settings.database_url = "sqlite:///./real.db" + mock_settings.redis_url = "redis://localhost:6379/0" + mock_settings.workdir = "/data/workdir" + mock_settings.gotenberg_url = "http://gotenberg:3000" + mock_settings.session_secret = "INSECURE_DEFAULT_FOR_DEVELOPMENT_ONLY_DO_NOT_USE_IN_PRODUCTION_MINIMUM_32_CHARS" + mock_settings.admin_username = "admin" + mock_settings.admin_password = "real_secure_password_456" + mock_settings.ai_provider = "openai" + mock_settings.openai_api_key = "sk-real-key-12345" + mock_settings.openai_model = "gpt-4o-mini" + missing = get_missing_required_settings() + assert "session_secret" in missing + + @patch("app.utils.setup_wizard.settings") + def test_detects_placeholder_bracket_format_as_missing(self, mock_settings): + """Test that formatted placeholders are detected as missing.""" + mock_settings.database_url = "sqlite:///./real.db" + mock_settings.redis_url = "redis://localhost:6379/0" + mock_settings.workdir = "/data/workdir" + mock_settings.gotenberg_url = "http://gotenberg:3000" + mock_settings.session_secret = "a_very_long_real_session_secret_minimum_32_chars" + mock_settings.admin_username = "admin" + mock_settings.admin_password = "real_secure_password_456" + mock_settings.ai_provider = "openai" + mock_settings.openai_api_key = "" + mock_settings.openai_model = "gpt-4o-mini" + missing = get_missing_required_settings() + assert "openai_api_key" in missing + + @patch("app.utils.setup_wizard.settings") + def test_detects_test_key_placeholder_as_missing(self, mock_settings): + """Test that 'test-key' is detected as a missing placeholder.""" + mock_settings.database_url = "sqlite:///./real.db" + mock_settings.redis_url = "redis://localhost:6379/0" + mock_settings.workdir = "/data/workdir" + mock_settings.gotenberg_url = "http://gotenberg:3000" + mock_settings.session_secret = "a_very_long_real_session_secret_minimum_32_chars" + mock_settings.admin_username = "admin" + mock_settings.admin_password = "real_secure_password_456" + mock_settings.ai_provider = "openai" + mock_settings.openai_api_key = "test-key" + mock_settings.openai_model = "gpt-4o-mini" + missing = get_missing_required_settings() + assert "openai_api_key" in missing + @pytest.mark.unit class TestGetWizardSteps: @@ -121,3 +297,42 @@ class TestGetWizardSteps: required_keys = [s["key"] for s in get_required_settings()] for key in required_keys: assert key in all_step_keys, f"Setting {key} not assigned to any wizard step" + + def test_has_three_steps(self): + """Test that there are exactly three wizard steps.""" + steps = get_wizard_steps() + assert len(steps) == 3 + assert set(steps.keys()) == {1, 2, 3} + + def test_step_1_contains_infrastructure_settings(self): + """Test that step 1 contains the core infrastructure settings.""" + steps = get_wizard_steps() + step_1_keys = [s["key"] for s in steps[1]] + assert "database_url" in step_1_keys + assert "redis_url" in step_1_keys + assert "workdir" in step_1_keys + assert "gotenberg_url" in step_1_keys + + def test_step_2_contains_security_settings(self): + """Test that step 2 contains the security settings.""" + steps = get_wizard_steps() + step_2_keys = [s["key"] for s in steps[2]] + assert "session_secret" in step_2_keys + assert "admin_username" in step_2_keys + assert "admin_password" in step_2_keys + + def test_step_3_contains_ai_settings(self): + """Test that step 3 contains the AI service settings.""" + steps = get_wizard_steps() + step_3_keys = [s["key"] for s in steps[3]] + assert "ai_provider" in step_3_keys + assert "openai_api_key" in step_3_keys + assert "openai_model" in step_3_keys + + def test_settings_not_duplicated_across_steps(self): + """Test that no setting appears in more than one step.""" + steps = get_wizard_steps() + all_keys = [] + for settings_list in steps.values(): + all_keys.extend([s["key"] for s in settings_list]) + assert len(all_keys) == len(set(all_keys)), "Some settings appear in multiple steps" diff --git a/tests/test_sharing.py b/tests/test_sharing.py new file mode 100644 index 00000000..23b590ee --- /dev/null +++ b/tests/test_sharing.py @@ -0,0 +1,691 @@ +"""Tests for the file sharing API (FileShare model and /api/files/{id}/shares endpoints).""" + +import pytest + +from app.models import FILE_SHARE_ROLE_EDITOR, FILE_SHARE_ROLE_VIEWER, FileRecord, FileShare, UserProfile + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _create_file(db_session, owner_id="owner1") -> FileRecord: + """Create a minimal owned FileRecord.""" + f = FileRecord( + owner_id=owner_id, + filehash="sharehash", + original_filename="shared.pdf", + local_filename="shared.pdf", + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(f) + db_session.commit() + db_session.refresh(f) + return f + + +def _create_unowned_file(db_session) -> FileRecord: + """Create a FileRecord with no owner.""" + f = FileRecord( + owner_id=None, + filehash="unownedhash", + original_filename="unowned.pdf", + local_filename="unowned.pdf", + file_size=512, + mime_type="application/pdf", + ) + db_session.add(f) + db_session.commit() + db_session.refresh(f) + return f + + +def _create_profile(db_session, user_id: str, display_name: str | None = None) -> UserProfile: + p = UserProfile(user_id=user_id, display_name=display_name) + db_session.add(p) + db_session.commit() + db_session.refresh(p) + return p + + +# --------------------------------------------------------------------------- +# get_file_role helper +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetFileRole: + """Tests for the get_file_role() utility.""" + + def test_owner_returns_owner(self, db_session, monkeypatch): + from app.config import settings as real_settings + from app.utils.user_scope import get_file_role + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + f = _create_file(db_session, owner_id="alice") + assert get_file_role(f, "alice", db_session) == "owner" + + def test_non_owner_no_share_returns_none(self, db_session, monkeypatch): + from app.config import settings as real_settings + from app.utils.user_scope import get_file_role + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + f = _create_file(db_session, owner_id="alice") + assert get_file_role(f, "bob", db_session) is None + + def test_shared_viewer_returns_viewer(self, db_session, monkeypatch): + from app.config import settings as real_settings + from app.utils.user_scope import get_file_role + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + f = _create_file(db_session, owner_id="alice") + share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role=FILE_SHARE_ROLE_VIEWER) + db_session.add(share) + db_session.commit() + assert get_file_role(f, "bob", db_session) == FILE_SHARE_ROLE_VIEWER + + def test_shared_editor_returns_editor(self, db_session, monkeypatch): + from app.config import settings as real_settings + from app.utils.user_scope import get_file_role + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + f = _create_file(db_session, owner_id="alice") + share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="carol", role=FILE_SHARE_ROLE_EDITOR) + db_session.add(share) + db_session.commit() + assert get_file_role(f, "carol", db_session) == FILE_SHARE_ROLE_EDITOR + + def test_unowned_file_returns_viewer_when_setting_allows(self, db_session, monkeypatch): + from app.utils import user_scope + + monkeypatch.setattr(user_scope.settings, "multi_user_enabled", True) + monkeypatch.setattr(user_scope.settings, "unowned_docs_visible_to_all", True) + f = _create_unowned_file(db_session) + role = user_scope.get_file_role(f, "anyone", db_session) + assert role == FILE_SHARE_ROLE_VIEWER + + def test_none_user_returns_none(self, db_session, monkeypatch): + from app.config import settings as real_settings + from app.utils.user_scope import get_file_role + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + f = _create_file(db_session, owner_id="alice") + assert get_file_role(f, None, db_session) is None + + +# --------------------------------------------------------------------------- +# has_file_role helper +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestHasFileRole: + """Tests for the has_file_role() utility.""" + + def test_owner_satisfies_viewer(self, db_session, monkeypatch): + from app.config import settings as real_settings + from app.utils.user_scope import has_file_role + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + f = _create_file(db_session, owner_id="alice") + assert has_file_role(f, "alice", db_session, minimum_role="viewer") is True + + def test_owner_satisfies_editor(self, db_session, monkeypatch): + from app.config import settings as real_settings + from app.utils.user_scope import has_file_role + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + f = _create_file(db_session, owner_id="alice") + assert has_file_role(f, "alice", db_session, minimum_role="editor") is True + + def test_owner_satisfies_owner(self, db_session, monkeypatch): + from app.config import settings as real_settings + from app.utils.user_scope import has_file_role + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + f = _create_file(db_session, owner_id="alice") + assert has_file_role(f, "alice", db_session, minimum_role="owner") is True + + def test_viewer_does_not_satisfy_editor(self, db_session, monkeypatch): + from app.config import settings as real_settings + from app.utils.user_scope import has_file_role + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + f = _create_file(db_session, owner_id="alice") + share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role=FILE_SHARE_ROLE_VIEWER) + db_session.add(share) + db_session.commit() + assert has_file_role(f, "bob", db_session, minimum_role="editor") is False + + def test_editor_satisfies_viewer(self, db_session, monkeypatch): + from app.config import settings as real_settings + from app.utils.user_scope import has_file_role + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + f = _create_file(db_session, owner_id="alice") + share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="carol", role=FILE_SHARE_ROLE_EDITOR) + db_session.add(share) + db_session.commit() + assert has_file_role(f, "carol", db_session, minimum_role="viewer") is True + + def test_no_access_returns_false(self, db_session, monkeypatch): + from app.config import settings as real_settings + from app.utils.user_scope import has_file_role + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + f = _create_file(db_session, owner_id="alice") + assert has_file_role(f, "stranger", db_session) is False + + +# --------------------------------------------------------------------------- +# List shares +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestListShares: + """Tests for GET /api/files/{file_id}/shares.""" + + def test_owner_can_list_shares(self, client, db_session, monkeypatch): + import app.api.sharing as sharing_mod + + f = _create_file(db_session, owner_id="alice") + share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role=FILE_SHARE_ROLE_VIEWER) + db_session.add(share) + db_session.commit() + + monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice") + + resp = client.get(f"/api/files/{f.id}/shares") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) == 1 + assert data[0]["shared_with_user_id"] == "bob" + assert data[0]["role"] == FILE_SHARE_ROLE_VIEWER + + def test_non_owner_cannot_list_shares(self, client, db_session, monkeypatch): + import app.api.sharing as sharing_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + f = _create_file(db_session, owner_id="alice") + + monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "bob") + # bob has no access to alice's file — the get_file_role call in list_shares + # will return None for bob, giving 404 not 403 (file not found for bob) + resp = client.get(f"/api/files/{f.id}/shares") + assert resp.status_code in (403, 404) + + def test_list_shares_file_not_found(self, client, db_session, monkeypatch): + import app.api.sharing as sharing_mod + + monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice") + resp = client.get("/api/files/99999/shares") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Create share +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestCreateShare: + """Tests for POST /api/files/{file_id}/shares.""" + + def test_owner_can_share_with_viewer(self, client, db_session, monkeypatch): + import app.api.sharing as sharing_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice") + + f = _create_file(db_session, owner_id="alice") + resp = client.post( + f"/api/files/{f.id}/shares", + json={"shared_with_user_id": "bob", "role": "viewer"}, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["shared_with_user_id"] == "bob" + assert data["role"] == "viewer" + assert data["file_id"] == f.id + + def test_owner_can_share_with_editor(self, client, db_session, monkeypatch): + import app.api.sharing as sharing_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice") + + f = _create_file(db_session, owner_id="alice") + resp = client.post( + f"/api/files/{f.id}/shares", + json={"shared_with_user_id": "carol", "role": "editor"}, + ) + assert resp.status_code == 201 + assert resp.json()["role"] == "editor" + + def test_non_owner_cannot_share(self, client, db_session, monkeypatch): + import app.api.sharing as sharing_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "bob") + + f = _create_file(db_session, owner_id="alice") + resp = client.post( + f"/api/files/{f.id}/shares", + json={"shared_with_user_id": "carol", "role": "viewer"}, + ) + # bob doesn't own the file; get_file_role returns None → 404 for non-owner + assert resp.status_code in (403, 404) + + def test_share_with_self_rejected(self, client, db_session, monkeypatch): + import app.api.sharing as sharing_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice") + + f = _create_file(db_session, owner_id="alice") + resp = client.post( + f"/api/files/{f.id}/shares", + json={"shared_with_user_id": "alice", "role": "viewer"}, + ) + assert resp.status_code == 422 + + def test_invalid_role_rejected(self, client, db_session, monkeypatch): + import app.api.sharing as sharing_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice") + + f = _create_file(db_session, owner_id="alice") + resp = client.post( + f"/api/files/{f.id}/shares", + json={"shared_with_user_id": "bob", "role": "admin"}, + ) + assert resp.status_code == 422 + + def test_empty_user_id_rejected(self, client, db_session, monkeypatch): + import app.api.sharing as sharing_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice") + + f = _create_file(db_session, owner_id="alice") + resp = client.post( + f"/api/files/{f.id}/shares", + json={"shared_with_user_id": " ", "role": "viewer"}, + ) + assert resp.status_code == 422 + + def test_duplicate_share_updates_role(self, client, db_session, monkeypatch): + """Creating a share for an already-shared user updates the role.""" + import app.api.sharing as sharing_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice") + + f = _create_file(db_session, owner_id="alice") + share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role="viewer") + db_session.add(share) + db_session.commit() + + resp = client.post( + f"/api/files/{f.id}/shares", + json={"shared_with_user_id": "bob", "role": "editor"}, + ) + assert resp.status_code == 201 + assert resp.json()["role"] == "editor" + + +# --------------------------------------------------------------------------- +# Update share role +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestUpdateShare: + """Tests for PUT /api/files/{file_id}/shares/{share_id}.""" + + def test_owner_can_update_role(self, client, db_session, monkeypatch): + import app.api.sharing as sharing_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice") + + f = _create_file(db_session, owner_id="alice") + share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role="viewer") + db_session.add(share) + db_session.commit() + db_session.refresh(share) + + resp = client.put( + f"/api/files/{f.id}/shares/{share.id}", + json={"role": "editor"}, + ) + assert resp.status_code == 200 + assert resp.json()["role"] == "editor" + + def test_non_owner_cannot_update_role(self, client, db_session, monkeypatch): + import app.api.sharing as sharing_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "bob") + + f = _create_file(db_session, owner_id="alice") + share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role="viewer") + db_session.add(share) + db_session.commit() + db_session.refresh(share) + + resp = client.put( + f"/api/files/{f.id}/shares/{share.id}", + json={"role": "editor"}, + ) + assert resp.status_code in (403, 404) + + def test_invalid_role_rejected(self, client, db_session, monkeypatch): + import app.api.sharing as sharing_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice") + + f = _create_file(db_session, owner_id="alice") + share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role="viewer") + db_session.add(share) + db_session.commit() + db_session.refresh(share) + + resp = client.put( + f"/api/files/{f.id}/shares/{share.id}", + json={"role": "superuser"}, + ) + assert resp.status_code == 422 + + def test_share_not_found(self, client, db_session, monkeypatch): + import app.api.sharing as sharing_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice") + + f = _create_file(db_session, owner_id="alice") + resp = client.put( + f"/api/files/{f.id}/shares/99999", + json={"role": "editor"}, + ) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Revoke share +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestRevokeShare: + """Tests for DELETE /api/files/{file_id}/shares/{share_id}.""" + + def test_owner_can_revoke(self, client, db_session, monkeypatch): + import app.api.sharing as sharing_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice") + + f = _create_file(db_session, owner_id="alice") + share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role="viewer") + db_session.add(share) + db_session.commit() + db_session.refresh(share) + + resp = client.delete(f"/api/files/{f.id}/shares/{share.id}") + assert resp.status_code == 200 + assert resp.json()["status"] == "success" + + # Confirm the share is gone + db_session.expire_all() + assert db_session.query(FileShare).filter(FileShare.id == share.id).first() is None + + def test_non_owner_cannot_revoke(self, client, db_session, monkeypatch): + import app.api.sharing as sharing_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "carol") + + f = _create_file(db_session, owner_id="alice") + share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role="viewer") + db_session.add(share) + db_session.commit() + db_session.refresh(share) + + resp = client.delete(f"/api/files/{f.id}/shares/{share.id}") + assert resp.status_code in (403, 404) + + def test_revoke_not_found(self, client, db_session, monkeypatch): + import app.api.sharing as sharing_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice") + + f = _create_file(db_session, owner_id="alice") + resp = client.delete(f"/api/files/{f.id}/shares/99999") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# List shared-with +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestListSharedWith: + """Tests for GET /api/files/{file_id}/shared-with.""" + + def test_owner_can_see_shared_with(self, client, db_session, monkeypatch): + import app.api.sharing as sharing_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice") + + f = _create_file(db_session, owner_id="alice") + _create_profile(db_session, "bob", "Bob Smith") + share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role="viewer") + db_session.add(share) + db_session.commit() + + resp = client.get(f"/api/files/{f.id}/shared-with") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["user_id"] == "bob" + assert data[0]["display_name"] == "Bob Smith" + assert data[0]["role"] == "viewer" + + def test_viewer_can_see_shared_with(self, client, db_session, monkeypatch): + import app.api.sharing as sharing_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "bob") + + f = _create_file(db_session, owner_id="alice") + share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role="viewer") + db_session.add(share) + db_session.commit() + + resp = client.get(f"/api/files/{f.id}/shared-with") + assert resp.status_code == 200 + + def test_unauthorized_user_gets_404(self, client, db_session, monkeypatch): + import app.api.sharing as sharing_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "stranger") + + f = _create_file(db_session, owner_id="alice") + resp = client.get(f"/api/files/{f.id}/shared-with") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Auto-share on mention +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestAutoShareOnMention: + """Tests that @mentioning a user in a comment auto-shares the file.""" + + def test_mention_auto_shares_with_viewer(self, client, db_session, monkeypatch): + """When multi_user_enabled is True, mentioning a user auto-shares the file.""" + import app.api.comments as comments_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + monkeypatch.setattr(comments_mod, "get_current_owner_id", lambda req: "alice") + monkeypatch.setattr(comments_mod, "get_current_user_id", lambda req: "alice") + + f = _create_file(db_session, owner_id="alice") + + resp = client.post( + f"/api/files/{f.id}/comments", + json={"body": "Hey @bob, please look at this."}, + ) + assert resp.status_code == 201 + + # bob should now have a viewer share on the file + share = ( + db_session.query(FileShare) + .filter(FileShare.file_id == f.id, FileShare.shared_with_user_id == "bob") + .first() + ) + assert share is not None + assert share.role == FILE_SHARE_ROLE_VIEWER + + def test_mention_does_not_duplicate_share(self, client, db_session, monkeypatch): + """Mentioning a user that already has a share does not create a duplicate.""" + import app.api.comments as comments_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + monkeypatch.setattr(comments_mod, "get_current_owner_id", lambda req: "alice") + monkeypatch.setattr(comments_mod, "get_current_user_id", lambda req: "alice") + + f = _create_file(db_session, owner_id="alice") + existing = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role="editor") + db_session.add(existing) + db_session.commit() + existing_id = existing.id + + resp = client.post( + f"/api/files/{f.id}/comments", + json={"body": "Hey @bob again!"}, + ) + assert resp.status_code == 201 + + shares = ( + db_session.query(FileShare).filter(FileShare.file_id == f.id, FileShare.shared_with_user_id == "bob").all() + ) + assert len(shares) == 1 + assert shares[0].id == existing_id + assert shares[0].role == "editor" # role unchanged + + def test_mention_skipped_when_single_user_mode(self, client, db_session, monkeypatch): + import app.api.comments as comments_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", False) + monkeypatch.setattr(comments_mod, "get_current_owner_id", lambda req: "alice") + monkeypatch.setattr(comments_mod, "get_current_user_id", lambda req: "alice") + + f = _create_file(db_session, owner_id="alice") + + resp = client.post( + f"/api/files/{f.id}/comments", + json={"body": "Hey @carol, look here."}, + ) + assert resp.status_code == 201 + + share = ( + db_session.query(FileShare) + .filter(FileShare.file_id == f.id, FileShare.shared_with_user_id == "carol") + .first() + ) + assert share is None + + +# --------------------------------------------------------------------------- +# Delete file – owner-only enforcement +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestDeleteFileOwnerOnly: + """Ensure non-owners (shared viewers/editors) cannot delete files.""" + + def test_owner_can_delete_in_multi_user_mode(self, client, db_session, monkeypatch): + import app.api.files as files_mod + import app.utils.user_scope as user_scope_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + monkeypatch.setattr(real_settings, "allow_file_delete", True) + monkeypatch.setattr(files_mod, "get_current_owner_id", lambda req: "alice") + monkeypatch.setattr(user_scope_mod, "get_current_owner_id", lambda req: "alice") + + f = _create_file(db_session, owner_id="alice") + + resp = client.delete(f"/api/files/{f.id}") + assert resp.status_code == 200 + + def test_viewer_cannot_delete(self, client, db_session, monkeypatch): + import app.api.files as files_mod + import app.utils.user_scope as user_scope_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + monkeypatch.setattr(real_settings, "allow_file_delete", True) + monkeypatch.setattr(files_mod, "get_current_owner_id", lambda req: "bob") + monkeypatch.setattr(user_scope_mod, "get_current_owner_id", lambda req: "bob") + + f = _create_file(db_session, owner_id="alice") + share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role="viewer") + db_session.add(share) + db_session.commit() + + resp = client.delete(f"/api/files/{f.id}") + assert resp.status_code == 403 + + def test_editor_cannot_delete(self, client, db_session, monkeypatch): + import app.api.files as files_mod + import app.utils.user_scope as user_scope_mod + from app.config import settings as real_settings + + monkeypatch.setattr(real_settings, "multi_user_enabled", True) + monkeypatch.setattr(real_settings, "allow_file_delete", True) + monkeypatch.setattr(files_mod, "get_current_owner_id", lambda req: "carol") + monkeypatch.setattr(user_scope_mod, "get_current_owner_id", lambda req: "carol") + + f = _create_file(db_session, owner_id="alice") + share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="carol", role="editor") + db_session.add(share) + db_session.commit() + + resp = client.delete(f"/api/files/{f.id}") + assert resp.status_code == 403 diff --git a/tests/test_upload_rate_limit.py b/tests/test_upload_rate_limit.py new file mode 100644 index 00000000..4e23e6dc --- /dev/null +++ b/tests/test_upload_rate_limit.py @@ -0,0 +1,265 @@ +"""Tests for per-user health-aware upload rate limiting (app/middleware/upload_rate_limit.py).""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from app.middleware.upload_rate_limit import compute_effective_limit + +# --------------------------------------------------------------------------- +# Tests for compute_effective_limit (pure function, no Redis needed) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestComputeEffectiveLimit: + """Tests for the health-aware effective-limit calculation.""" + + def test_normal_conditions_return_base_limit(self): + """Under normal conditions the full base limit should be returned.""" + effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=0.0) + assert effective == 20 + assert factor == 1.0 + assert reason == "normal" + + def test_moderate_queue_halves_limit(self): + """Queue depth > 50 should halve the base limit.""" + effective, factor, reason = compute_effective_limit(20, queue_depth=60, cpu_load_ratio=0.0) + assert effective == 10 + assert factor == 0.5 + assert "moderate_queue" in reason + + def test_high_queue_quarters_limit(self): + """Queue depth > 100 should quarter the base limit.""" + effective, factor, reason = compute_effective_limit(20, queue_depth=120, cpu_load_ratio=0.0) + assert effective == 5 + assert factor == 0.25 + assert "high_queue" in reason + + def test_critical_queue_drops_to_ten_percent(self): + """Queue depth > 200 should drop to 10% of base limit.""" + effective, factor, reason = compute_effective_limit(20, queue_depth=250, cpu_load_ratio=0.0) + assert effective == 2 + assert factor == 0.10 + assert "critical_queue" in reason + + def test_moderate_cpu_halves_limit(self): + """CPU load ratio > 1.5 should halve the base limit.""" + effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=1.8) + assert effective == 10 + assert factor == 0.5 + assert "moderate_cpu" in reason + + def test_high_cpu_quarters_limit(self): + """CPU load ratio > 2.0 should quarter the base limit.""" + effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=2.5) + assert effective == 5 + assert factor == 0.25 + assert "high_cpu" in reason + + def test_critical_cpu_drops_to_ten_percent(self): + """CPU load ratio > 3.0 should drop to 10% of base limit.""" + effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=4.0) + assert effective == 2 + assert factor == 0.10 + assert "critical_cpu" in reason + + def test_worst_metric_wins(self): + """The lowest factor from queue and CPU should be applied.""" + # Queue says 0.5, CPU says 0.25 → 0.25 wins + effective, factor, reason = compute_effective_limit(20, queue_depth=60, cpu_load_ratio=2.5) + assert effective == 5 + assert factor == 0.25 + + def test_minimum_effective_limit_is_one(self): + """Even under extreme load the effective limit must be ≥ 1.""" + effective, _factor, _reason = compute_effective_limit(1, queue_depth=999, cpu_load_ratio=10.0) + assert effective >= 1 + + def test_zero_base_limit_returns_zero(self): + """A base limit of 0 (disabled) should clamp to at least 1.""" + effective, _factor, _reason = compute_effective_limit(0, queue_depth=0, cpu_load_ratio=0.0) + # max(1, int(0 * 1.0)) = max(1, 0) = 1 + # A base_limit of 0 means "disabled" and is handled upstream + # (the dependency skips the check entirely), but the pure function + # still clamps to 1 as a safety net. + assert effective == 1 + + +# --------------------------------------------------------------------------- +# Tests for the FastAPI dependency (mocked Redis) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestRequireUploadRateLimit: + """Tests for the require_upload_rate_limit FastAPI dependency.""" + + @pytest.mark.asyncio + async def test_allows_request_when_redis_unavailable(self): + """When Redis is down the dependency should fail open (allow the request).""" + from app.middleware.upload_rate_limit import require_upload_rate_limit + + mock_request = MagicMock() + mock_request.session = {} + mock_request.client = MagicMock() + mock_request.client.host = "127.0.0.1" + + with patch("app.middleware.upload_rate_limit._get_redis", return_value=None): + # Should NOT raise + result = await require_upload_rate_limit(mock_request) + assert result is None + + @pytest.mark.asyncio + async def test_allows_request_under_limit(self): + """A user below the rate limit should be allowed through.""" + from app.middleware.upload_rate_limit import require_upload_rate_limit + + mock_request = MagicMock() + mock_request.session = {"user": {"username": "testuser"}} + mock_request.client = MagicMock() + mock_request.client.host = "10.0.0.1" + + mock_redis = MagicMock() + mock_pipe = MagicMock() + mock_pipe.execute.return_value = [ + 0, # zremrangebyscore result + 5, # zcard — current count (under limit of 20) + [], # zrange oldest + ] + mock_redis.pipeline.return_value = mock_pipe + mock_redis.llen.return_value = 0 # empty queues + + mock_pipe2 = MagicMock() + mock_pipe2.execute.return_value = [True, True] + # The second pipeline call (record upload) + mock_redis.pipeline.side_effect = [mock_pipe, mock_pipe2] + + with ( + patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis), + patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value="testuser"), + patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.1), + ): + result = await require_upload_rate_limit(mock_request) + assert result is None + + @pytest.mark.asyncio + async def test_rejects_request_over_limit(self): + """A user at or over the rate limit should receive a 429.""" + from fastapi import HTTPException + + from app.middleware.upload_rate_limit import require_upload_rate_limit + + mock_request = MagicMock() + mock_request.session = {"user": {"username": "spammer"}} + mock_request.client = MagicMock() + mock_request.client.host = "10.0.0.2" + + mock_redis = MagicMock() + mock_pipe = MagicMock() + mock_pipe.execute.return_value = [ + 0, # zremrangebyscore + 20, # zcard — at limit + [("oldest_entry", 1000000.0)], # oldest entry for retry_after + ] + mock_redis.pipeline.return_value = mock_pipe + mock_redis.llen.return_value = 0 + + with ( + patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis), + patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value="spammer"), + patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.0), + ): + with pytest.raises(HTTPException) as exc_info: + await require_upload_rate_limit(mock_request) + assert exc_info.value.status_code == 429 + assert "Retry-After" in exc_info.value.headers + + @pytest.mark.asyncio + async def test_health_reduces_effective_limit(self): + """When queues are deep, the effective limit should drop, causing a 429 sooner.""" + from fastapi import HTTPException + + from app.middleware.upload_rate_limit import require_upload_rate_limit + + mock_request = MagicMock() + mock_request.session = {"user": {"username": "normaluser"}} + mock_request.client = MagicMock() + mock_request.client.host = "10.0.0.3" + + mock_redis = MagicMock() + mock_pipe = MagicMock() + # 12 uploads already — under normal limit of 20 but over health-reduced limit + mock_pipe.execute.return_value = [ + 0, # zremrangebyscore + 12, # zcard — 12 uploads in window + [("oldest", 1000000.0)], + ] + mock_redis.pipeline.return_value = mock_pipe + # Simulate deep queue (>100) → effective limit = 25% of 20 = 5 + mock_redis.llen.return_value = 40 # 40 per queue * 3 = 120 total + + with ( + patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis), + patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value="normaluser"), + patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.0), + ): + with pytest.raises(HTTPException) as exc_info: + await require_upload_rate_limit(mock_request) + assert exc_info.value.status_code == 429 + + @pytest.mark.asyncio + async def test_falls_back_to_ip_when_no_user(self): + """Unauthenticated requests should use IP-based rate limiting.""" + from app.middleware.upload_rate_limit import require_upload_rate_limit + + mock_request = MagicMock() + mock_request.session = {} + mock_request.client = MagicMock() + mock_request.client.host = "192.168.1.100" + + mock_redis = MagicMock() + mock_pipe = MagicMock() + mock_pipe.execute.return_value = [0, 0, []] + mock_redis.pipeline.return_value = mock_pipe + mock_redis.llen.return_value = 0 + + mock_pipe2 = MagicMock() + mock_pipe2.execute.return_value = [True, True] + mock_redis.pipeline.side_effect = [mock_pipe, mock_pipe2] + + with ( + patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis), + patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value=None), + patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.0), + ): + result = await require_upload_rate_limit(mock_request) + assert result is None + + +# --------------------------------------------------------------------------- +# Tests for configuration +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestUploadRateLimitConfig: + """Tests for upload rate limit configuration settings.""" + + def test_settings_exist(self): + """Verify per-user upload rate limit settings are exposed in config.""" + from app.config import settings + + assert hasattr(settings, "upload_rate_limit_per_user") + assert hasattr(settings, "upload_rate_limit_window") + + def test_sensible_defaults(self): + """Default values should be reasonable for a multi-user system.""" + from app.config import settings + + assert settings.upload_rate_limit_per_user >= 10 + assert settings.upload_rate_limit_per_user <= 100 + assert settings.upload_rate_limit_window >= 30 + assert settings.upload_rate_limit_window <= 300 diff --git a/tests/test_upload_to_icloud.py b/tests/test_upload_to_icloud.py index 72e1e804..804fd1f3 100644 --- a/tests/test_upload_to_icloud.py +++ b/tests/test_upload_to_icloud.py @@ -203,3 +203,192 @@ class TestUploadIcloudHandler: assert result["status"] == "Completed" assert result["icloud_folder"] == "/" mock_api.drive.upload.assert_called_once() + + +# --------------------------------------------------------------------------- +# upload_to_icloud Celery task +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestUploadToIcloudTask: + """Tests for the upload_to_icloud Celery task.""" + + @patch("app.tasks.upload_to_icloud.log_task_progress") + def test_raises_file_not_found(self, mock_log): + """Task raises FileNotFoundError when the file does not exist.""" + from app.tasks.upload_to_icloud import upload_to_icloud + + upload_to_icloud.request.id = TASK_ID + with pytest.raises(FileNotFoundError, match="File not found"): + upload_to_icloud.__wrapped__(file_path="/nonexistent/file.pdf") + + @patch("app.tasks.upload_to_icloud.log_task_progress") + @patch("app.tasks.upload_to_icloud.settings") + def test_raises_when_credentials_not_configured(self, mock_settings, mock_log, tmp_path): + """Task raises ValueError when iCloud credentials are absent.""" + from app.tasks.upload_to_icloud import upload_to_icloud + + mock_settings.icloud_username = None + mock_settings.icloud_password = None + + fp = str(tmp_path / "doc.pdf") + _write_file(fp) + + upload_to_icloud.request.id = TASK_ID + with pytest.raises(ValueError, match="iCloud credentials are not configured"): + upload_to_icloud.__wrapped__(file_path=fp) + + @patch("app.tasks.upload_to_icloud.log_task_progress") + @patch("app.tasks.upload_to_icloud.settings") + def test_raises_when_password_not_configured(self, mock_settings, mock_log, tmp_path): + """Task raises ValueError when iCloud password is absent.""" + from app.tasks.upload_to_icloud import upload_to_icloud + + mock_settings.icloud_username = "user@example.com" + mock_settings.icloud_password = None + + fp = str(tmp_path / "doc.pdf") + _write_file(fp) + + upload_to_icloud.request.id = TASK_ID + with pytest.raises(ValueError, match="iCloud credentials are not configured"): + upload_to_icloud.__wrapped__(file_path=fp) + + @patch("app.tasks.upload_to_icloud.log_task_progress") + @patch("app.tasks.upload_to_icloud.settings") + def test_successful_upload_with_folder(self, mock_settings, mock_log, tmp_path): + """Task uploads to the configured folder and returns success dict.""" + from app.tasks.upload_to_icloud import upload_to_icloud + + mock_settings.icloud_username = "user@example.com" + mock_settings.icloud_password = "secret" # noqa: S105 + mock_settings.icloud_folder = "Documents" + mock_settings.icloud_cookie_directory = None + + fp = str(tmp_path / "report.pdf") + _write_file(fp) + + mock_api = MagicMock() + mock_api.requires_2sa = False + mock_api.requires_2fa = False + + mock_folder = MagicMock() + mock_api.drive.dir.return_value = [] + mock_api.drive.mkdir.return_value = mock_folder + + upload_to_icloud.request.id = TASK_ID + with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}): + result = upload_to_icloud.__wrapped__(file_path=fp, file_id=42) + + assert result["status"] == "Completed" + assert result["file"] == fp + assert result["icloud_folder"] == "Documents" + mock_folder.upload.assert_called_once() + + @patch("app.tasks.upload_to_icloud.log_task_progress") + @patch("app.tasks.upload_to_icloud.settings") + def test_successful_upload_to_root_when_no_folder_configured(self, mock_settings, mock_log, tmp_path): + """Task uploads to iCloud Drive root when ICLOUD_FOLDER is empty.""" + from app.tasks.upload_to_icloud import upload_to_icloud + + mock_settings.icloud_username = "user@example.com" + mock_settings.icloud_password = "secret" # noqa: S105 + mock_settings.icloud_folder = "" + mock_settings.icloud_cookie_directory = None + + fp = str(tmp_path / "doc.pdf") + _write_file(fp) + + mock_api = MagicMock() + mock_api.requires_2sa = False + mock_api.requires_2fa = False + + upload_to_icloud.request.id = TASK_ID + with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}): + result = upload_to_icloud.__wrapped__(file_path=fp) + + assert result["status"] == "Completed" + assert result["icloud_folder"] == "/" + mock_api.drive.upload.assert_called_once() + + @patch("app.tasks.upload_to_icloud.log_task_progress") + @patch("app.tasks.upload_to_icloud.settings") + def test_folder_override_takes_precedence_over_settings(self, mock_settings, mock_log, tmp_path): + """folder_override replaces the value from settings.icloud_folder.""" + from app.tasks.upload_to_icloud import upload_to_icloud + + mock_settings.icloud_username = "user@example.com" + mock_settings.icloud_password = "secret" # noqa: S105 + mock_settings.icloud_folder = "DefaultFolder" + mock_settings.icloud_cookie_directory = None + + fp = str(tmp_path / "doc.pdf") + _write_file(fp) + + mock_api = MagicMock() + mock_api.requires_2sa = False + mock_api.requires_2fa = False + + mock_folder = MagicMock() + mock_api.drive.dir.return_value = [] + mock_api.drive.mkdir.return_value = mock_folder + + upload_to_icloud.request.id = TASK_ID + with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}): + result = upload_to_icloud.__wrapped__(file_path=fp, folder_override="OverrideFolder") + + assert result["icloud_folder"] == "OverrideFolder" + + @patch("app.tasks.upload_to_icloud.log_task_progress") + @patch("app.tasks.upload_to_icloud.settings") + def test_exception_during_upload_raises_runtime_error(self, mock_settings, mock_log, tmp_path): + """Any exception from pyicloud is wrapped in RuntimeError.""" + from app.tasks.upload_to_icloud import upload_to_icloud + + mock_settings.icloud_username = "user@example.com" + mock_settings.icloud_password = "secret" # noqa: S105 + mock_settings.icloud_folder = "" + mock_settings.icloud_cookie_directory = None + + fp = str(tmp_path / "doc.pdf") + _write_file(fp) + + mock_api = MagicMock() + mock_api.requires_2sa = False + mock_api.requires_2fa = False + mock_api.drive.upload.side_effect = OSError("disk full") + + upload_to_icloud.request.id = TASK_ID + with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}): + with pytest.raises(RuntimeError, match="Error uploading"): + upload_to_icloud.__wrapped__(file_path=fp) + + @patch("app.tasks.upload_to_icloud.log_task_progress") + @patch("app.tasks.upload_to_icloud.settings") + def test_cookie_directory_passed_to_api(self, mock_settings, mock_log, tmp_path): + """Task forwards icloud_cookie_directory to _get_icloud_api.""" + from app.tasks.upload_to_icloud import upload_to_icloud + + mock_settings.icloud_username = "user@example.com" + mock_settings.icloud_password = "secret" # noqa: S105 + mock_settings.icloud_folder = "" + mock_settings.icloud_cookie_directory = "/tmp/icloud_cookies" + + fp = str(tmp_path / "doc.pdf") + _write_file(fp) + + mock_api = MagicMock() + mock_api.requires_2sa = False + mock_api.requires_2fa = False + mock_mod = _mock_pyicloud_module(mock_api) + + upload_to_icloud.request.id = TASK_ID + with patch.dict("sys.modules", {"pyicloud": mock_mod}): + upload_to_icloud.__wrapped__(file_path=fp) + + mock_mod.PyiCloudService.assert_called_once_with( + "user@example.com", + "secret", + cookie_directory="/tmp/icloud_cookies", + ) diff --git a/tests/test_views_dropbox.py b/tests/test_views_dropbox.py index d42b9304..79dad26c 100644 --- a/tests/test_views_dropbox.py +++ b/tests/test_views_dropbox.py @@ -144,3 +144,37 @@ class TestDropboxViews: assert response.status_code == 200 assert b"/Documents/Uploads" in response.content assert b"Back to Integrations" in response.content + + +@pytest.mark.integration +class TestDropboxCallbackUrl: + """Tests that the callback_url is correctly passed to templates.""" + + def test_setup_page_includes_callback_url(self, client): + """Setup page should include the callback_url variable in its response.""" + response = client.get("/dropbox-setup") + assert response.status_code == 200 + # callback_url is embedded in the JS as the dropboxCallbackUrl constant + assert b"dropboxCallbackUrl" in response.content + + def test_callback_page_includes_callback_url(self, client): + """Callback page should embed the server-side callback URL.""" + response = client.get("/dropbox-callback?code=testcode") + assert response.status_code == 200 + # callback_url is used as the redirectUri + assert b"redirectUri" in response.content + + def test_setup_page_uses_public_base_url_when_set(self, client): + """When PUBLIC_BASE_URL is configured, it should appear in the redirect URI hint.""" + with patch("app.views.dropbox.settings") as mock_settings: + mock_settings.public_base_url = "https://configured.example.com" + mock_settings.dropbox_app_key = "" + mock_settings.dropbox_app_secret = "" + mock_settings.dropbox_refresh_token = "" + mock_settings.dropbox_folder = "" + mock_settings.dropbox_allow_global_credentials_for_integrations = False + response = client.get("/dropbox-setup") + assert response.status_code == 200 + # The configured public_base_url hostname must appear in the page (redirect URI display) + page_text = response.text + assert "configured.example.com/dropbox-callback" in page_text diff --git a/tests/test_views_files_comprehensive.py b/tests/test_views_files_comprehensive.py index d522a637..acaa8acb 100644 --- a/tests/test_views_files_comprehensive.py +++ b/tests/test_views_files_comprehensive.py @@ -6,6 +6,7 @@ Target: Bring coverage from 8.77% to 70%+ """ import json +import uuid from datetime import datetime, timedelta from unittest.mock import Mock, patch @@ -206,12 +207,12 @@ class TestFileDetailPage: db_session.add(file) db_session.commit() - response = client.get(f"/files/{file.id}/detail") + response = client.get(f"/files/{file.id}/process") assert response.status_code == 200 def test_file_detail_page_not_found(self, client: TestClient, db_session): """Test file detail page for non-existent file.""" - response = client.get("/files/99999/detail") + response = client.get("/files/99999/process") assert response.status_code == 200 # Still renders template with error def test_file_detail_page_with_processing_logs(self, client: TestClient, db_session, tmp_path): @@ -244,7 +245,7 @@ class TestFileDetailPage: db_session.add(log2) db_session.commit() - response = client.get(f"/files/{file.id}/detail") + response = client.get(f"/files/{file.id}/process") assert response.status_code == 200 def test_file_detail_page_with_metadata_json(self, client: TestClient, db_session, tmp_path): @@ -272,7 +273,7 @@ class TestFileDetailPage: db_session.add(file) db_session.commit() - response = client.get(f"/files/{file.id}/detail") + response = client.get(f"/files/{file.id}/process") assert response.status_code == 200 def test_file_detail_checks_original_file_exists(self, client: TestClient, db_session, tmp_path): @@ -289,7 +290,7 @@ class TestFileDetailPage: db_session.add(file) db_session.commit() - response = client.get(f"/files/{file.id}/detail") + response = client.get(f"/files/{file.id}/process") assert response.status_code == 200 def test_file_detail_error_handling(self, client: TestClient, db_session): @@ -1113,7 +1114,7 @@ class TestFileDetailPageAdditional: db_session.add(file) db_session.commit() - response = client.get(f"/files/{file.id}/detail") + response = client.get(f"/files/{file.id}/process") assert response.status_code == 200 def test_file_detail_step_summary_fallback(self, client: TestClient, db_session, tmp_path): @@ -1144,7 +1145,7 @@ class TestFileDetailPageAdditional: db_session.commit() with patch("app.utils.step_manager.get_step_summary", side_effect=Exception("Table not found")): - response = client.get(f"/files/{file.id}/detail") + response = client.get(f"/files/{file.id}/process") assert response.status_code == 200 def test_file_detail_error_handling(self, client: TestClient, db_session): @@ -1155,7 +1156,7 @@ class TestFileDetailPageAdditional: Mock(status_code=200), ] try: - response = client.get("/files/1/detail") + response = client.get("/files/1/process") assert response.status_code in (200, 500) except Exception: pass @@ -1638,7 +1639,7 @@ class TestFileDetailNoJsonSidecar: db_session.add(file) db_session.commit() - response = client.get(f"/files/{file.id}/detail") + response = client.get(f"/files/{file.id}/process") assert response.status_code == 200 @@ -1936,7 +1937,7 @@ class TestPipelineInfoInViews: pipeline = self._make_system_pipeline(db_session) file_rec = self._make_file(db_session, pipeline_id=None) - response = client.get(f"/files/{file_rec.id}/detail") + response = client.get(f"/files/{file_rec.id}/process") assert response.status_code == 200 assert b"Standard Processing Pipeline" in response.content @@ -1946,7 +1947,7 @@ class TestPipelineInfoInViews: self._make_system_pipeline(db_session) file_rec = self._make_file(db_session, pipeline_id=None) - response = client.get(f"/files/{file_rec.id}/detail") + response = client.get(f"/files/{file_rec.id}/process") assert response.status_code == 200 assert b"System Default" in response.content @@ -1956,28 +1957,153 @@ class TestPipelineInfoInViews: pipeline = self._make_custom_pipeline(db_session) file_rec = self._make_file(db_session, pipeline_id=pipeline.id) - response = client.get(f"/files/{file_rec.id}/detail") + response = client.get(f"/files/{file_rec.id}/process") assert response.status_code == 200 assert b"My Custom Pipeline" in response.content assert b"Custom" in response.content def test_file_view_page_includes_pipeline_name(self, client, db_session): - """GET /files/{id} response body contains the pipeline name in the sidebar.""" + """GET /files/{id}/detail response body contains the pipeline name in the sidebar.""" pipeline = self._make_system_pipeline(db_session) file_rec = self._make_file(db_session, pipeline_id=None) - response = client.get(f"/files/{file_rec.id}") + response = client.get(f"/files/{file_rec.id}/detail") assert response.status_code == 200 assert b"Standard Processing Pipeline" in response.content def test_file_view_page_no_pipeline_shows_standard(self, client, db_session): - """When no pipeline exists, file view shows 'Standard' fallback text.""" + """When no pipeline exists, file detail view shows 'Standard' fallback text.""" # No pipeline in DB file_rec = self._make_file(db_session, pipeline_id=None) - response = client.get(f"/files/{file_rec.id}") + response = client.get(f"/files/{file_rec.id}/detail") assert response.status_code == 200 assert b"Standard" in response.content + + +# --------------------------------------------------------------------------- +# Owner display and claim ownership tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestOwnerDisplayAndClaim: + """Tests that owner info and claim button appear correctly on file views.""" + + def _make_file(self, db_session, owner_id=None) -> FileRecord: + file_rec = FileRecord( + filehash=uuid.uuid4().hex, + original_filename="doc.pdf", + local_filename="/tmp/doc.pdf", + file_size=512, + mime_type="application/pdf", + owner_id=owner_id, + ) + db_session.add(file_rec) + db_session.commit() + db_session.refresh(file_rec) + return file_rec + + # ── /files/{id} (file_summary.html) ────────────────────────────────── + + def test_summary_shows_owner_when_multi_user_enabled(self, client, db_session): + """Owner ID is rendered in file summary when multi-user mode is on.""" + file_rec = self._make_file(db_session, owner_id="alice@example.com") + with patch("app.config.settings.multi_user_enabled", True): + response = client.get(f"/files/{file_rec.id}") + assert response.status_code == 200 + assert b"alice@example.com" in response.content + + def test_summary_shows_unowned_label_for_unowned_file(self, client, db_session): + """'Unowned' label is rendered in file summary for files without an owner.""" + file_rec = self._make_file(db_session, owner_id=None) + with patch("app.config.settings.multi_user_enabled", True): + response = client.get(f"/files/{file_rec.id}") + assert response.status_code == 200 + assert b"Unowned" in response.content + + def test_summary_shows_claim_button_for_unowned_file(self, client, db_session): + """Claim Ownership button appears on file summary for an unowned file.""" + file_rec = self._make_file(db_session, owner_id=None) + with patch("app.config.settings.multi_user_enabled", True): + response = client.get(f"/files/{file_rec.id}") + assert response.status_code == 200 + assert b"Claim Ownership" in response.content + + def test_summary_no_claim_button_when_owned(self, client, db_session): + """No Claim Ownership button when the file already has an owner.""" + file_rec = self._make_file(db_session, owner_id="bob@example.com") + with patch("app.config.settings.multi_user_enabled", True): + response = client.get(f"/files/{file_rec.id}") + assert response.status_code == 200 + assert b"Claim Ownership" not in response.content + + def test_summary_no_owner_row_in_single_user_mode(self, client, db_session): + """Owner row is hidden in single-user mode.""" + file_rec = self._make_file(db_session, owner_id=None) + with patch("app.config.settings.multi_user_enabled", False): + response = client.get(f"/files/{file_rec.id}") + assert response.status_code == 200 + # Claim button and Unowned label should not appear in single-user mode + assert b"Claim Ownership" not in response.content + + # ── /files/{id}/detail (file_view.html) ────────────────────────────── + + def test_detail_shows_owner_when_multi_user_enabled(self, client, db_session): + """Owner ID is rendered in file detail view when multi-user mode is on.""" + file_rec = self._make_file(db_session, owner_id="charlie@example.com") + with patch("app.config.settings.multi_user_enabled", True): + response = client.get(f"/files/{file_rec.id}/detail") + assert response.status_code == 200 + assert b"charlie@example.com" in response.content + + def test_detail_shows_claim_button_for_unowned_file(self, client, db_session): + """Claim Ownership button appears in file detail view for an unowned file.""" + file_rec = self._make_file(db_session, owner_id=None) + with patch("app.config.settings.multi_user_enabled", True): + response = client.get(f"/files/{file_rec.id}/detail") + assert response.status_code == 200 + assert b"Claim Ownership" in response.content + + # ── /files/{id}/annotations (file_annotations.html) ────────────────── + + def test_annotations_shows_owner_info(self, client, db_session): + """Owner info is rendered on the annotations page in multi-user mode.""" + file_rec = self._make_file(db_session, owner_id="dave@example.com") + with patch("app.config.settings.multi_user_enabled", True): + response = client.get(f"/files/{file_rec.id}/annotations") + assert response.status_code == 200 + assert b"dave@example.com" in response.content + + def test_annotations_shows_claim_button_for_unowned_file(self, client, db_session): + """Claim Ownership button appears on annotations page for unowned file.""" + file_rec = self._make_file(db_session, owner_id=None) + with patch("app.config.settings.multi_user_enabled", True): + response = client.get(f"/files/{file_rec.id}/annotations") + assert response.status_code == 200 + assert b"Claim Ownership" in response.content + + def test_annotations_no_claim_button_when_owned(self, client, db_session): + """No Claim Ownership button on annotations page when file has an owner.""" + file_rec = self._make_file(db_session, owner_id="eve@example.com") + with patch("app.config.settings.multi_user_enabled", True): + response = client.get(f"/files/{file_rec.id}/annotations") + assert response.status_code == 200 + assert b"Claim Ownership" not in response.content + + def test_display_name_used_when_profile_exists(self, client, db_session): + """UserProfile.display_name overrides raw user_id in the owner display.""" + from app.models import UserProfile + + file_rec = self._make_file(db_session, owner_id="frank@example.com") + profile = UserProfile(user_id="frank@example.com", display_name="Frank Lastname") + db_session.add(profile) + db_session.commit() + + with patch("app.config.settings.multi_user_enabled", True): + response = client.get(f"/files/{file_rec.id}") + assert response.status_code == 200 + assert b"Frank Lastname" in response.content
{{ _("devices.col_device") }}{{ _("devices.col_token_prefix") }}{{ _("devices.col_created") }}{{ _("devices.col_last_used") }}{{ _("devices.col_status") }}{{ _("common.actions") }}{{ _("devices.col_device") }}{{ _("devices.col_token_prefix") }}{{ _("devices.col_created") }}{{ _("devices.col_last_used") }}{{ _("devices.col_status") }}{{ _("common.actions") }}