diff --git a/app/api/dropbox.py b/app/api/dropbox.py index ea7600aa..f9bf10e5 100644 --- a/app/api/dropbox.py +++ b/app/api/dropbox.py @@ -7,11 +7,15 @@ import os from typing import Annotated, Optional import requests -from fastapi import APIRouter, Form, HTTPException, Request, status +from fastapi import APIRouter, Depends, Form, HTTPException, Request, status +from sqlalchemy.orm import Session 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 +from app.utils.settings_sync import notify_settings_updated # Set up logging logger = logging.getLogger(__name__) @@ -63,38 +67,52 @@ async def update_dropbox_settings( app_key: Annotated[Optional[str], Form()] = None, app_secret: Annotated[Optional[str], Form()] = None, folder_path: Annotated[Optional[str], Form()] = None, + db: Session = Depends(get_db), ): """ - Update Dropbox settings in memory + Update Dropbox settings in memory and persist to the database. """ try: - logger.info("Updating Dropbox settings in memory") + logger.info("Updating Dropbox settings in memory and database") - # Update settings in memory + 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 "wizard" + ) + + # Update settings in memory and persist to database if refresh_token: settings.dropbox_refresh_token = refresh_token - logger.info("Updated DROPBOX_REFRESH_TOKEN in memory") + save_setting_to_db(db, "dropbox_refresh_token", refresh_token, changed_by=changed_by) + logger.info("Updated DROPBOX_REFRESH_TOKEN in memory and database") if app_key: settings.dropbox_app_key = app_key - logger.info("Updated DROPBOX_APP_KEY in memory") + save_setting_to_db(db, "dropbox_app_key", app_key, changed_by=changed_by) + logger.info("Updated DROPBOX_APP_KEY in memory and database") if app_secret: settings.dropbox_app_secret = app_secret - logger.info("Updated DROPBOX_APP_SECRET in memory") + save_setting_to_db(db, "dropbox_app_secret", app_secret, changed_by=changed_by) + logger.info("Updated DROPBOX_APP_SECRET in memory and database") if folder_path: settings.dropbox_folder = folder_path - logger.info("Updated DROPBOX_FOLDER in memory") + save_setting_to_db(db, "dropbox_folder", folder_path, changed_by=changed_by) + logger.info("Updated DROPBOX_FOLDER in memory and database") - # Test token validity would be here, but we'll skip it for now + notify_settings_updated() - return {"status": "success", "message": "Dropbox settings have been updated in memory"} + return { + "status": "success", + "message": "Dropbox settings have been updated in memory and saved to database", + } except Exception as e: logger.exception(f"Unexpected error updating Dropbox settings: {str(e)}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update Dropbox settings: {str(e)}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update Dropbox settings: {str(e)}", ) @@ -109,7 +127,10 @@ async def test_dropbox_token(request: Request): if not settings.dropbox_refresh_token or not settings.dropbox_app_key or not settings.dropbox_app_secret: logger.warning("Dropbox credentials not fully configured") - return {"status": "error", "message": "Dropbox credentials are not fully configured"} + return { + "status": "error", + "message": "Dropbox credentials are not fully configured", + } # Check token validity by getting current account info headers = {"Authorization": f"Bearer {settings.dropbox_refresh_token}"} @@ -136,7 +157,11 @@ async def test_dropbox_token(request: Request): if refresh_response.status_code != 200: logger.error(f"Failed to refresh Dropbox token: {refresh_response.text}") - return {"status": "error", "message": "Refresh token has expired or is invalid", "needs_reauth": True} + return { + "status": "error", + "message": "Refresh token has expired or is invalid", + "needs_reauth": True, + } token_info = refresh_response.json() access_token = token_info.get("access_token") @@ -162,7 +187,10 @@ async def test_dropbox_token(request: Request): account_name = account_info.get("name", {}).get("display_name", "Unknown user") # Dropbox refresh tokens don't expire, but we should note that in our response - token_info = {"expires_in_human": "Never expires (perpetual token)", "is_perpetual": True} + token_info = { + "expires_in_human": "Never expires (perpetual token)", + "is_perpetual": True, + } logger.info(f"Successfully connected to Dropbox as {account_email}") @@ -187,65 +215,18 @@ async def save_dropbox_settings( app_key: Annotated[Optional[str], Form()] = None, app_secret: Annotated[Optional[str], Form()] = None, folder_path: Annotated[Optional[str], Form()] = None, + db: Session = Depends(get_db), ): """ - Save Dropbox settings to the .env file + Save Dropbox settings to database (primary) and .env file (best-effort). """ try: - # Get the path to the .env file - env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env") + 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 "wizard" + ) - if not os.path.exists(env_path): - logger.error(f".env file not found at {env_path}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not find .env file to update" - ) - - logger.info(f"Updating Dropbox settings in {env_path}") - - # Read the current .env file - with open(env_path, "r") as f: - env_lines = f.readlines() - - # Define settings to update - dropbox_settings = { - "DROPBOX_REFRESH_TOKEN": refresh_token, - } - - # Only update these if provided - 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 - - # 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 dropbox_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 dropbox_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") - - # Update the settings in memory + # Update settings in memory if refresh_token: settings.dropbox_refresh_token = refresh_token if app_key: @@ -255,14 +236,68 @@ async def save_dropbox_settings( if folder_path: settings.dropbox_folder = folder_path - logger.info("Successfully updated Dropbox settings") + # Persist to database (primary storage) + if refresh_token: + save_setting_to_db(db, "dropbox_refresh_token", refresh_token, changed_by=changed_by) + if app_key: + save_setting_to_db(db, "dropbox_app_key", app_key, changed_by=changed_by) + if app_secret: + save_setting_to_db(db, "dropbox_app_secret", app_secret, changed_by=changed_by) + if folder_path: + save_setting_to_db(db, "dropbox_folder", folder_path, changed_by=changed_by) + # Best-effort .env file write + try: + env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env") + 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}") + + 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}") + + notify_settings_updated() + + logger.info("Successfully saved Dropbox settings") return {"status": "success", "message": "Dropbox settings have been saved"} - except HTTPException: - raise except Exception as e: logger.exception(f"Unexpected error saving Dropbox settings: {str(e)}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to save Dropbox settings: {str(e)}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to save Dropbox settings: {str(e)}", ) diff --git a/app/api/google_drive.py b/app/api/google_drive.py index b626a470..f9eda756 100644 --- a/app/api/google_drive.py +++ b/app/api/google_drive.py @@ -7,11 +7,15 @@ import os from datetime import datetime from typing import Annotated, Optional -from fastapi import APIRouter, Form, HTTPException, Request, status +from fastapi import APIRouter, Depends, Form, HTTPException, Request, status +from sqlalchemy.orm import Session 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 +from app.utils.settings_sync import notify_settings_updated # Set up logging logger = logging.getLogger(__name__) @@ -64,38 +68,59 @@ async def update_google_drive_settings( client_secret: Annotated[Optional[str], Form()] = None, folder_id: Annotated[Optional[str], Form()] = None, use_oauth: Annotated[str, Form()] = "true", + db: Session = Depends(get_db), ): """ - Update Google Drive settings in memory + Update Google Drive settings in memory and persist to database """ try: - logger.info("Updating Google Drive settings in memory") + logger.info("Updating Google Drive settings in memory and database") + + 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 "wizard" + ) # Convert use_oauth string to boolean use_oauth_bool = use_oauth.lower() in ("true", "1", "yes", "y", "t") - # Update settings in memory + # Update settings in memory and persist to database if refresh_token: settings.google_drive_refresh_token = refresh_token - logger.info("Updated GOOGLE_DRIVE_REFRESH_TOKEN in memory") + save_setting_to_db(db, "google_drive_refresh_token", refresh_token, changed_by=changed_by) + logger.info("Updated GOOGLE_DRIVE_REFRESH_TOKEN in memory and database") if client_id: settings.google_drive_client_id = client_id - logger.info("Updated GOOGLE_DRIVE_CLIENT_ID in memory") + save_setting_to_db(db, "google_drive_client_id", client_id, changed_by=changed_by) + logger.info("Updated GOOGLE_DRIVE_CLIENT_ID in memory and database") if client_secret: settings.google_drive_client_secret = client_secret - logger.info("Updated GOOGLE_DRIVE_CLIENT_SECRET in memory") + save_setting_to_db(db, "google_drive_client_secret", client_secret, changed_by=changed_by) + logger.info("Updated GOOGLE_DRIVE_CLIENT_SECRET in memory and database") if folder_id: settings.google_drive_folder_id = folder_id - logger.info("Updated GOOGLE_DRIVE_FOLDER_ID in memory") + save_setting_to_db(db, "google_drive_folder_id", folder_id, changed_by=changed_by) + logger.info("Updated GOOGLE_DRIVE_FOLDER_ID in memory and database") # Set the OAuth flag settings.google_drive_use_oauth = use_oauth_bool - logger.info(f"Updated GOOGLE_DRIVE_USE_OAUTH in memory to {use_oauth_bool}") + save_setting_to_db( + db, + "google_drive_use_oauth", + str(use_oauth_bool).lower(), + changed_by=changed_by, + ) + logger.info(f"Updated GOOGLE_DRIVE_USE_OAUTH in memory and database to {use_oauth_bool}") - return {"status": "success", "message": "Google Drive settings have been updated in memory"} + notify_settings_updated() + + return { + "status": "success", + "message": "Google Drive settings have been updated in memory and database", + } except Exception as e: logger.exception(f"Unexpected error updating Google Drive settings: {str(e)}") @@ -125,7 +150,10 @@ async def test_google_drive_token(request: Request): and settings.google_drive_refresh_token ): logger.warning("Google Drive OAuth credentials not fully configured") - return {"status": "error", "message": "Google Drive OAuth credentials are not fully configured"} + return { + "status": "error", + "message": "Google Drive OAuth credentials are not fully configured", + } try: # Test OAuth connection @@ -188,7 +216,10 @@ async def test_google_drive_token(request: Request): # Test service account connection if not settings.google_drive_credentials_json: logger.warning("Google Drive service account credentials not configured") - return {"status": "error", "message": "Google Drive service account credentials are not configured"} + return { + "status": "error", + "message": "Google Drive service account credentials are not configured", + } try: service = get_google_drive_service() @@ -214,7 +245,10 @@ async def test_google_drive_token(request: Request): except Exception as e: error_msg = str(e) logger.error(f"Google Drive service account test failed: {error_msg}") - return {"status": "error", "message": f"Service account validation failed: {error_msg}"} + return { + "status": "error", + "message": f"Service account validation failed: {error_msg}", + } except Exception as e: logger.exception("Unexpected error testing Google Drive token") @@ -246,7 +280,10 @@ async def get_google_drive_token_info(request: Request): and settings.google_drive_refresh_token ): logger.warning("Google Drive OAuth credentials not fully configured") - return {"status": "error", "message": "Google Drive OAuth credentials are not fully configured"} + return { + "status": "error", + "message": "Google Drive OAuth credentials are not fully configured", + } try: # Get credentials and access token @@ -333,9 +370,10 @@ async def save_dropbox_settings( client_secret: Annotated[Optional[str], Form()] = None, folder_id: Annotated[Optional[str], Form()] = None, use_oauth: Annotated[str, Form()] = "true", + db: Session = Depends(get_db), ): """ - Save Google Drive settings to the .env file + Save Google Drive settings to the .env file (best-effort) and persist to database. """ try: # Get the path to the .env file @@ -344,6 +382,11 @@ async def save_dropbox_settings( # Convert use_oauth string to boolean use_oauth_bool = use_oauth.lower() in ("true", "1", "yes", "y", "t") + 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 "wizard" + ) + # Define settings to update drive_settings = {"GOOGLE_DRIVE_USE_OAUTH": str(use_oauth_bool).lower()} @@ -415,7 +458,25 @@ async def save_dropbox_settings( # Set OAuth flag settings.google_drive_use_oauth = use_oauth_bool - logger.info("Successfully updated Google Drive settings in memory") + # Persist to database + save_setting_to_db( + db, + "google_drive_use_oauth", + str(use_oauth_bool).lower(), + changed_by=changed_by, + ) + if refresh_token: + save_setting_to_db(db, "google_drive_refresh_token", refresh_token, changed_by=changed_by) + if client_id: + save_setting_to_db(db, "google_drive_client_id", client_id, changed_by=changed_by) + if client_secret: + save_setting_to_db(db, "google_drive_client_secret", client_secret, changed_by=changed_by) + if folder_id: + save_setting_to_db(db, "google_drive_folder_id", folder_id, changed_by=changed_by) + + notify_settings_updated() + + logger.info("Successfully updated Google Drive settings in memory and database") return { "status": "success", @@ -426,5 +487,6 @@ async def save_dropbox_settings( except Exception as e: logger.exception(f"Unexpected error saving Google Drive settings: {str(e)}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to save Google Drive settings: {str(e)}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to save Google Drive settings: {str(e)}", ) diff --git a/app/api/onedrive.py b/app/api/onedrive.py index 894a1a44..e9f8328d 100644 --- a/app/api/onedrive.py +++ b/app/api/onedrive.py @@ -8,11 +8,15 @@ from datetime import datetime, timedelta from typing import Annotated, Optional import requests -from fastapi import APIRouter, Form, HTTPException, Request, status +from fastapi import APIRouter, Depends, Form, HTTPException, Request, status +from sqlalchemy.orm import Session 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 +from app.utils.settings_sync import notify_settings_updated # Set up logging logger = logging.getLogger(__name__) @@ -50,7 +54,10 @@ async def exchange_onedrive_token( token_data = exchange_oauth_token(provider_name="OneDrive", token_url=token_url, payload=payload) # Return just what's needed by the frontend - return {"refresh_token": token_data["refresh_token"], "expires_in": token_data.get("expires_in", 3600)} + return { + "refresh_token": token_data["refresh_token"], + "expires_in": token_data.get("expires_in", 3600), + } @router.get("/onedrive/test-token") @@ -68,7 +75,10 @@ async def test_onedrive_token(request: Request): or not settings.onedrive_client_secret ): logger.warning("OneDrive credentials not fully configured") - return {"status": "error", "message": "OneDrive credentials are not fully configured"} + return { + "status": "error", + "message": "OneDrive credentials are not fully configured", + } # Refresh token to get a new access token and expiration info tenant_id = settings.onedrive_tenant_id or "common" @@ -86,7 +96,11 @@ async def test_onedrive_token(request: Request): if response.status_code != 200: logger.error(f"Failed to refresh OneDrive token: {response.text}") - return {"status": "error", "message": "Refresh token has expired or is invalid", "needs_reauth": True} + return { + "status": "error", + "message": "Refresh token has expired or is invalid", + "needs_reauth": True, + } token_data = response.json() access_token = token_data.get("access_token") @@ -128,6 +142,24 @@ async def test_onedrive_token(request: Request): except Exception as e: logger.warning(f"Failed to update refresh token in .env file: {e}") + # Persist the rotated refresh token to the database + try: + from app.database import SessionLocal + + _db = SessionLocal() + try: + save_setting_to_db( + _db, + "onedrive_refresh_token", + new_refresh_token, + changed_by="onedrive_token_rotation", + ) + notify_settings_updated() + finally: + _db.close() + except Exception as _e: + logger.warning(f"Failed to persist rotated OneDrive refresh token to database: {_e}") + # Test the access token by getting user information user_info_url = "https://graph.microsoft.com/v1.0/me" headers = {"Authorization": f"Bearer {access_token}"} @@ -203,65 +235,62 @@ async def save_onedrive_settings( client_secret: Annotated[Optional[str], Form()] = None, tenant_id: Annotated[str, Form()] = "common", folder_path: Annotated[Optional[str], Form()] = None, + db: Session = Depends(get_db), ): """ - Save OneDrive settings to the .env file + Saves to database (primary) and .env file (best-effort). """ try: - # Get the path to the .env file - env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env") + 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 "wizard" + ) - if not os.path.exists(env_path): - logger.error(f".env file not found at {env_path}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not find .env file to update" - ) + # Best-effort .env file write + try: + env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env") + 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 OneDrive settings in {env_path}") - logger.info(f"Updating OneDrive settings in {env_path}") + with open(env_path, "r") as f: + env_lines = f.readlines() - # Read the current .env file - with open(env_path, "r") as f: - env_lines = f.readlines() + 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 - # Define settings to update - onedrive_settings = { - "ONEDRIVE_REFRESH_TOKEN": refresh_token, - } + updated = set() + new_env_lines = [] + for line in env_lines: + stripped_line = line.rstrip() + is_updated = False + for key, value in onedrive_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) - # Only update these if provided - 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 + for key, value in onedrive_settings.items(): + if key not in updated: + new_env_lines.append(f"{key}={value}") - # 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 onedrive_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) + with open(env_path, "w") as f: + f.write("\n".join(new_env_lines) + "\n") - # Add any settings that weren't updated (they weren't in the file) - for key, value in onedrive_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 OneDrive settings in .env file") + except Exception as env_err: + logger.warning(f"Failed to write .env file (non-fatal): {env_err}") # Update the settings in memory if refresh_token: @@ -275,16 +304,28 @@ async def save_onedrive_settings( if folder_path: settings.onedrive_folder_path = folder_path - logger.info("Successfully updated OneDrive 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) + notify_settings_updated() + + logger.info("Successfully saved OneDrive settings") return {"status": "success", "message": "OneDrive settings have been saved"} - except HTTPException: - raise except Exception as e: logger.exception(f"Unexpected error saving OneDrive settings: {str(e)}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to save OneDrive settings: {str(e)}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to save OneDrive settings: {str(e)}", ) @@ -297,33 +338,46 @@ async def update_onedrive_settings( client_secret: Annotated[Optional[str], Form()] = None, tenant_id: Annotated[str, Form()] = "common", folder_path: Annotated[Optional[str], Form()] = None, + db: Session = Depends(get_db), ): """ - Update OneDrive settings in memory (without modifying .env file) + Update OneDrive settings in memory and persist to database """ try: - logger.info("Updating OneDrive settings in memory") + logger.info("Updating OneDrive settings in memory and database") - # Update settings in memory + 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 "wizard" + ) + + # Update settings in memory and persist to database if refresh_token: settings.onedrive_refresh_token = refresh_token - logger.info("Updated ONEDRIVE_REFRESH_TOKEN in memory") + save_setting_to_db(db, "onedrive_refresh_token", refresh_token, changed_by=changed_by) + logger.info("Updated ONEDRIVE_REFRESH_TOKEN in memory and database") if client_id: settings.onedrive_client_id = client_id - logger.info("Updated ONEDRIVE_CLIENT_ID in memory") + save_setting_to_db(db, "onedrive_client_id", client_id, changed_by=changed_by) + logger.info("Updated ONEDRIVE_CLIENT_ID in memory and database") if client_secret: settings.onedrive_client_secret = client_secret - logger.info("Updated ONEDRIVE_CLIENT_SECRET in memory") + save_setting_to_db(db, "onedrive_client_secret", client_secret, changed_by=changed_by) + logger.info("Updated ONEDRIVE_CLIENT_SECRET in memory and database") if tenant_id: settings.onedrive_tenant_id = tenant_id - logger.info("Updated ONEDRIVE_TENANT_ID in memory") + save_setting_to_db(db, "onedrive_tenant_id", tenant_id, changed_by=changed_by) + logger.info("Updated ONEDRIVE_TENANT_ID in memory and database") if folder_path: settings.onedrive_folder_path = folder_path - logger.info("Updated ONEDRIVE_FOLDER_PATH in memory") + save_setting_to_db(db, "onedrive_folder_path", folder_path, changed_by=changed_by) + logger.info("Updated ONEDRIVE_FOLDER_PATH in memory and database") + + notify_settings_updated() # Test the token to make sure it works try: @@ -333,14 +387,21 @@ async def update_onedrive_settings( logger.info("Successfully tested OneDrive token") except Exception as e: logger.error(f"Token test failed after updating settings: {str(e)}") - return {"status": "warning", "message": "Settings updated but token test failed: " + str(e)} + return { + "status": "warning", + "message": "Settings updated but token test failed: " + str(e), + } - return {"status": "success", "message": "OneDrive settings have been updated in memory"} + return { + "status": "success", + "message": "OneDrive settings have been updated in memory and database", + } except Exception as e: logger.exception(f"Unexpected error updating OneDrive settings: {str(e)}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update OneDrive settings: {str(e)}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update OneDrive settings: {str(e)}", ) diff --git a/app/api/settings.py b/app/api/settings.py index 0dad89b6..75c9e99a 100644 --- a/app/api/settings.py +++ b/app/api/settings.py @@ -16,11 +16,15 @@ from app.utils.settings_service import ( SETTING_METADATA, delete_setting_from_db, get_all_settings_from_db, + get_audit_log, + get_setting_history, get_setting_metadata, get_settings_by_category, + rollback_setting, save_setting_to_db, validate_setting_value, ) +from app.utils.settings_sync import notify_settings_updated logger = logging.getLogger(__name__) router = APIRouter(prefix="/settings", tags=["settings"]) @@ -79,7 +83,10 @@ async def get_settings(request: Request, db: DbSession, admin: AdminUser): for key in SETTING_METADATA.keys(): if hasattr(settings, key): value = getattr(settings, key) - current_settings[key] = {"value": value, "metadata": get_setting_metadata(key)} + current_settings[key] = { + "value": value, + "metadata": get_setting_metadata(key), + } # Get settings stored in database db_settings = get_all_settings_from_db(db) @@ -90,7 +97,10 @@ async def get_settings(request: Request, db: DbSession, admin: AdminUser): return SettingsListResponse(settings=current_settings, categories=categories, db_settings=db_settings) except Exception as e: logger.error(f"Error retrieving settings: {e}") - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve settings") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve settings", + ) @router.get("/{key}", response_model=SettingResponse) @@ -111,7 +121,8 @@ async def get_setting(key: str, request: Request, db: DbSession, admin: AdminUse except Exception as e: logger.error(f"Error retrieving setting {key}: {e}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to retrieve setting: {key}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to retrieve setting: {key}", ) @@ -135,13 +146,23 @@ async def update_setting( if not is_valid: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_message) + # Determine the username for the audit log + 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" + ) + # Save to database - success = save_setting_to_db(db, key, setting.value) + success = save_setting_to_db(db, key, setting.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" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to save setting to database", ) + # Notify workers that settings have changed + notify_settings_updated() + # Get metadata metadata = get_setting_metadata(key) restart_required = metadata.get("restart_required", False) @@ -158,7 +179,8 @@ async def update_setting( 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}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update setting: {key}", ) @@ -170,9 +192,19 @@ async def delete_setting(key: str, request: Request, db: DbSession, admin: Admin """ validate_setting_key(key) try: - success = delete_setting_from_db(db, key) + 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 = delete_setting_from_db(db, key, changed_by=changed_by) if not success: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Setting '{key}' not found in database") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Setting '{key}' not found in database", + ) + + notify_settings_updated() return { "success": True, @@ -183,7 +215,8 @@ async def delete_setting(key: str, request: Request, db: DbSession, admin: Admin except Exception as e: logger.error(f"Error deleting setting {key}: {e}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to delete setting: {key}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete setting: {key}", ) @@ -238,7 +271,10 @@ async def list_credentials(request: Request, db: DbSession, admin: AdminUser): } except Exception as e: logger.error(f"Error retrieving credential list: {e}") - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve credentials") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve credentials", + ) @router.post("/bulk-update") @@ -250,6 +286,11 @@ async def bulk_update_settings(updates: list[SettingUpdate], request: Request, d results = [] errors = [] + 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" + ) + for update in updates: try: # Validate the setting value @@ -260,7 +301,7 @@ async def bulk_update_settings(updates: list[SettingUpdate], request: Request, d continue # Save to database - success = save_setting_to_db(db, update.key, update.value) + success = save_setting_to_db(db, update.key, update.value, changed_by=changed_by) if success: results.append({"key": update.key, "value": update.value, "status": "success"}) else: @@ -269,6 +310,165 @@ async def bulk_update_settings(updates: list[SettingUpdate], request: Request, d logger.error(f"Error updating setting {update.key}: {e}") errors.append({"key": update.key, "error": str(e)}) + if results: + notify_settings_updated() + restart_required = any(get_setting_metadata(result["key"]).get("restart_required", False) for result in results) - return {"success": len(errors) == 0, "updated": results, "errors": errors, "restart_required": restart_required} + return { + "success": len(errors) == 0, + "updated": results, + "errors": errors, + "restart_required": restart_required, + } + + +@router.get("/audit-log") +async def list_audit_log( + request: Request, + db: DbSession, + admin: AdminUser, + limit: int = 100, + offset: int = 0, +): + """ + Retrieve the settings audit log (most recent first). + + Returns all configuration changes recorded in the audit log. + Sensitive values are masked in the response. + Admin only. + """ + try: + entries = get_audit_log(db, limit=limit, offset=offset) + return {"entries": entries, "limit": limit, "offset": offset} + except Exception as e: + logger.error(f"Error retrieving audit log: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve audit log", + ) + + +@router.get("/{key}/history") +async def get_key_history(key: str, request: Request, db: DbSession, admin: AdminUser): + """ + Get the change history for a specific setting key. + + Returns all audit log entries for that key, most recent first. + Admin only. + """ + validate_setting_key_format(key) + try: + entries = get_setting_history(db, key) + return {"key": key, "history": entries} + except Exception as e: + logger.error(f"Error retrieving history for {key}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to retrieve history for setting: {key}", + ) + + +@router.post("/{key}/rollback/{history_id}") +async def rollback_setting_to_history( + key: str, + history_id: int, + request: Request, + db: DbSession, + admin: AdminUser, +): + """ + Revert a setting to the value it held at a specific point in the audit log. + + The ``history_id`` is the ID of the :class:`~app.models.SettingsAuditLog` + entry whose ``new_value`` should be reinstated. If that entry recorded a + deletion (``new_value`` is ``None``), the setting is removed from the + database and reverts to its ENV/default value. + + A new audit log entry is written to record the rollback. + Admin only. + """ + validate_setting_key_format(key) + try: + 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 = rollback_setting(db, key, history_id, changed_by=changed_by) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"History entry {history_id} not found for setting '{key}'", + ) + + notify_settings_updated() + + return { + "success": True, + "message": f"Setting '{key}' rolled back to history entry {history_id}", + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Error rolling back setting {key} to history {history_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to roll back setting: {key}", + ) + + +@router.get("/export-env") +async def export_env_settings( + request: Request, + db: DbSession, + admin: AdminUser, + source: str = "db", +): + """ + Export current settings as a ``.env`` file. + + Query params: + - ``source=db`` (default) – only settings explicitly saved to the database. + - ``source=effective`` – full runtime configuration (DB > ENV > defaults) for + every key defined in SETTING_METADATA. + + Returns a downloadable plain-text file suitable for bootstrapping another + installation. All values — including sensitive ones — are included; only + admins can access this endpoint. + """ + from fastapi.responses import Response as FastAPIResponse + + from app.utils.settings_service import get_settings_for_export + + if source not in ("db", "effective"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="source must be 'db' or 'effective'", + ) + + try: + export_data = get_settings_for_export(db, source=source) + lines = [ + "# DocuElevate configuration export", + f"# Source: {source}", + "# Generated by DocuElevate Settings Export", + "# WARNING: This file contains sensitive values. Handle with care.", + "", + ] + for env_key, value in export_data.items(): + lines.append(f"{env_key}={value}") + lines.append("") # trailing newline + content = "\n".join(lines) + + return FastAPIResponse( + content=content, + media_type="text/plain", + headers={"Content-Disposition": f'attachment; filename="docuelevate-{source}.env"'}, + ) + except Exception as e: + logger.error(f"Error exporting settings: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to export settings", + ) diff --git a/app/celery_worker.py b/app/celery_worker.py index 1ed82467..d6bd83d4 100644 --- a/app/celery_worker.py +++ b/app/celery_worker.py @@ -35,6 +35,11 @@ from app.tasks.upload_to_sftp import upload_to_sftp # noqa: F401 from app.tasks.upload_to_webdav import upload_to_webdav # noqa: F401 from app.tasks.uptime_kuma_tasks import ping_uptime_kuma # noqa: F401 +# Register the settings reload signal handler so workers pick up config changes +from app.utils.settings_sync import register_settings_reload_signal + +register_settings_reload_signal() + celery.conf.task_routes = { "app.tasks.*": {"queue": "default"}, } diff --git a/app/models.py b/app/models.py index 848f71bf..78c09df9 100644 --- a/app/models.py +++ b/app/models.py @@ -102,3 +102,17 @@ class ApplicationSettings(Base): value = Column(String, nullable=True) # Setting value (stored as string, converted as needed) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class SettingsAuditLog(Base): + """Audit log for all configuration changes made via the settings UI.""" + + __tablename__ = "settings_audit_log" + + id = Column(Integer, primary_key=True, index=True) + key = Column(String, nullable=False, index=True) # Setting key that was changed + old_value = Column(String, nullable=True) # Previous value (None if first-time set) + new_value = Column(String, nullable=True) # New value (None if deleted) + changed_by = Column(String, nullable=False) # Username of the admin who made the change + changed_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) + action = Column(String, nullable=False) # "update" or "delete" diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index 5fc20ce7..b7344118 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -13,7 +13,7 @@ from typing import Any, Dict, List, Optional, Tuple from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session -from app.models import ApplicationSettings +from app.models import ApplicationSettings, SettingsAuditLog logger = logging.getLogger(__name__) @@ -888,16 +888,18 @@ def get_setting_from_db(db: Session, key: str) -> Optional[str]: return None -def save_setting_to_db(db: Session, key: str, value: Optional[str]) -> bool: +def save_setting_to_db(db: Session, key: str, value: Optional[str], changed_by: str = "system") -> bool: """ Save or update a setting in the database. Automatically encrypts sensitive values if encryption is enabled. + Records an entry in the settings audit log. Args: db: Database session key: Setting key value: Setting value (as string) + changed_by: Username of the admin performing the change (for audit log) Returns: True if successful, False otherwise @@ -917,13 +919,39 @@ def save_setting_to_db(db: Session, key: str, value: Optional[str]) -> bool: logger.warning(f"Storing sensitive setting {key} in plaintext (encryption unavailable)") setting = db.query(ApplicationSettings).filter(ApplicationSettings.key == key).first() + old_storage_value = setting.value if setting else None + if setting: setting.value = storage_value else: setting = ApplicationSettings(key=key, value=storage_value) db.add(setting) + + # Determine human-readable old value for audit log (decrypt if needed) + old_display_value = None + if old_storage_value is not None: + if metadata.get("sensitive", False): + try: + from app.utils.encryption import decrypt_value + + old_display_value = decrypt_value(old_storage_value) + except Exception: + old_display_value = old_storage_value + else: + old_display_value = old_storage_value + + # Write audit log entry + audit_entry = SettingsAuditLog( + key=key, + old_value=old_display_value, + new_value=value, + changed_by=changed_by, + action="update", + ) + db.add(audit_entry) + db.commit() - logger.info(f"Saved setting {key} to database") + logger.info(f"Saved setting {key} to database (changed_by={changed_by})") return True except SQLAlchemyError as e: logger.error(f"Error saving setting {key} to database: {e}") @@ -963,13 +991,16 @@ def get_all_settings_from_db(db: Session) -> Dict[str, str]: return {} -def delete_setting_from_db(db: Session, key: str) -> bool: +def delete_setting_from_db(db: Session, key: str, changed_by: str = "system") -> bool: """ Delete a setting from the database. + Records an entry in the settings audit log. + Args: db: Database session key: Setting key to delete + changed_by: Username of the admin performing the change (for audit log) Returns: True if successful, False otherwise @@ -977,9 +1008,33 @@ def delete_setting_from_db(db: Session, key: str) -> bool: try: setting = db.query(ApplicationSettings).filter(ApplicationSettings.key == key).first() if setting: + # Capture old value for audit log (decrypt if sensitive) + metadata = get_setting_metadata(key) + old_display_value = None + if setting.value is not None: + if metadata.get("sensitive", False): + try: + from app.utils.encryption import decrypt_value + + old_display_value = decrypt_value(setting.value) + except Exception: + old_display_value = setting.value + else: + old_display_value = setting.value + db.delete(setting) + + audit_entry = SettingsAuditLog( + key=key, + old_value=old_display_value, + new_value=None, + changed_by=changed_by, + action="delete", + ) + db.add(audit_entry) + db.commit() - logger.info(f"Deleted setting {key} from database") + logger.info(f"Deleted setting {key} from database (changed_by={changed_by})") return True return False except SQLAlchemyError as e: @@ -1061,3 +1116,162 @@ def validate_setting_value(key: str, value: str) -> Tuple[bool, Optional[str]]: return False, "session_secret must be at least 32 characters" return True, None + + +def get_audit_log(db: Session, limit: int = 100, offset: int = 0) -> List[Dict[str, Any]]: + """ + Retrieve the settings audit log, most recent first. + + Sensitive values are masked in the returned list so the log is safe to + display in the admin UI without leaking secrets. + + Args: + db: Database session + limit: Maximum number of entries to return + offset: Number of entries to skip (for pagination) + + Returns: + List of audit log entry dicts ordered by changed_at descending + """ + try: + entries = ( + db.query(SettingsAuditLog).order_by(SettingsAuditLog.changed_at.desc()).limit(limit).offset(offset).all() + ) + result = [] + for entry in entries: + meta = get_setting_metadata(entry.key) + is_sensitive = meta.get("sensitive", False) + result.append( + { + "id": entry.id, + "key": entry.key, + "old_value": ("[REDACTED]" if is_sensitive and entry.old_value else entry.old_value), + "new_value": ("[REDACTED]" if is_sensitive and entry.new_value else entry.new_value), + "changed_by": entry.changed_by, + "changed_at": (entry.changed_at.isoformat() if entry.changed_at else None), + "action": entry.action, + } + ) + return result + except SQLAlchemyError as e: + logger.error(f"Error retrieving audit log: {e}") + return [] + + +def get_setting_history(db: Session, key: str) -> List[Dict[str, Any]]: + """ + Retrieve the change history for a specific setting key, most recent first. + + Sensitive values are masked so the response is safe to surface in the UI. + + Args: + db: Database session + key: Setting key + + Returns: + List of audit log entry dicts for this key + """ + try: + entries = ( + db.query(SettingsAuditLog) + .filter(SettingsAuditLog.key == key) + .order_by(SettingsAuditLog.changed_at.desc()) + .all() + ) + meta = get_setting_metadata(key) + is_sensitive = meta.get("sensitive", False) + result = [] + for entry in entries: + result.append( + { + "id": entry.id, + "key": entry.key, + "old_value": ("[REDACTED]" if is_sensitive and entry.old_value else entry.old_value), + "new_value": ("[REDACTED]" if is_sensitive and entry.new_value else entry.new_value), + "changed_by": entry.changed_by, + "changed_at": (entry.changed_at.isoformat() if entry.changed_at else None), + "action": entry.action, + } + ) + return result + except SQLAlchemyError as e: + logger.error(f"Error retrieving history for setting {key}: {e}") + return [] + + +def rollback_setting(db: Session, key: str, history_id: int, changed_by: str = "system") -> bool: + """ + Revert a setting to the value recorded in a specific audit log entry. + + The value stored in the chosen history entry's ``new_value`` field is + re-applied as the current database value. If that value is ``None`` + (i.e. the entry recorded a deletion) the setting is removed from the + database entirely, reverting to ENV/defaults. + + A new audit log entry is written to record the rollback operation. + + Args: + db: Database session + key: Setting key to roll back + history_id: ID of the SettingsAuditLog entry whose ``new_value`` + should become the restored value + changed_by: Username performing the rollback (for audit log) + + Returns: + True if successful, False if the history entry was not found or an + error occurred + """ + try: + history_entry = ( + db.query(SettingsAuditLog).filter(SettingsAuditLog.id == history_id, SettingsAuditLog.key == key).first() + ) + if not history_entry: + logger.warning(f"Rollback failed: audit log entry {history_id} not found for key '{key}'") + return False + + target_value = history_entry.new_value + + if target_value is None: + # The history entry recorded a deletion – reinstate that by deleting the current db value + return delete_setting_from_db(db, key, changed_by=changed_by) + else: + return save_setting_to_db(db, key, target_value, changed_by=changed_by) + except SQLAlchemyError as e: + logger.error(f"Error rolling back setting {key} to history entry {history_id}: {e}") + db.rollback() + return False + + +def get_settings_for_export(db: Session, source: str = "db") -> Dict[str, str]: + """ + Collect settings for export as environment variables. + + Args: + db: Database session + source: ``"db"`` to export only database-persisted settings (default); + ``"effective"`` to export the full current runtime configuration + (DB overrides ENV overrides application defaults) for every key + listed in SETTING_METADATA. + + Returns: + Ordered dict mapping uppercase ENV variable names to their string values. + Sensitive values are included (the caller is responsible for access control). + """ + if source == "effective": + from app.config import settings as app_settings + + db_settings = get_all_settings_from_db(db) + result = {} + for key in sorted(SETTING_METADATA.keys()): + # DB wins, then live settings object (ENV/default) + if key in db_settings and db_settings[key] is not None: + value = db_settings[key] + else: + value = getattr(app_settings, key, None) + if value is not None: + result[key.upper()] = str(value) + return result + else: + # 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} diff --git a/app/utils/settings_sync.py b/app/utils/settings_sync.py new file mode 100644 index 00000000..8ffd03d1 --- /dev/null +++ b/app/utils/settings_sync.py @@ -0,0 +1,86 @@ +""" +Worker settings synchronisation helper. + +When an admin saves a configuration change through the UI, any running Celery +workers still hold the *old* values in their in-process ``settings`` singleton. +This module provides two complementary mechanisms to propagate the change: + +1. **Publish** (API side): :func:`notify_settings_updated` writes a monotonically + increasing timestamp to a Redis key. This is called immediately after every + successful ``save_setting_to_db`` / ``delete_setting_from_db`` operation. + +2. **Subscribe** (worker side): :func:`register_settings_reload_signal` installs + a Celery ``task_prerun`` signal handler. Before each task begins the handler + reads the Redis version key; if it has changed since the last reload it calls + :func:`~app.utils.config_loader.reload_settings_from_db` so the worker picks + up the new values *before* executing the task body. + +The Redis key used is ``docuelevate:settings_version``. Workers cache the last +seen version in a module-level variable to avoid redundant DB round-trips when +nothing has changed. +""" + +import logging +import time + +import redis +from celery.signals import task_prerun + +logger = logging.getLogger(__name__) + +#: Redis key that stores the current settings "version" (epoch timestamp string). +SETTINGS_VERSION_KEY = "docuelevate:settings_version" + +#: Module-level cache: the settings version seen by *this* process on its last reload. +_last_seen_version: str = "" + + +def notify_settings_updated() -> None: + """ + Publish a settings-updated signal by updating the Redis version key. + + Call this after every successful settings write so that all worker + processes know they need to reload their in-memory configuration. + + Errors are caught and logged rather than raised so that a Redis + connectivity issue does not prevent the primary save from succeeding. + """ + try: + from app.config import settings + + r = redis.from_url(settings.redis_url, socket_connect_timeout=2) + version = str(time.time()) + r.set(SETTINGS_VERSION_KEY, version) + logger.debug(f"Settings version bumped to {version}") + except Exception as exc: + logger.warning(f"Could not publish settings update to Redis: {exc}") + + +def register_settings_reload_signal() -> None: + """ + Install a Celery ``task_prerun`` signal handler for worker processes. + + This should be called once during Celery worker initialisation (e.g. from + ``celery_worker.py``). After registration, every task will check the + settings version key in Redis before it starts and reload configuration + from the database if a newer version is detected. + """ + + @task_prerun.connect(weak=False) + def _reload_if_stale(sender, **kwargs) -> None: # type: ignore[misc] + """Reload settings from DB if the Redis version key has changed.""" + global _last_seen_version + try: + from app.config import settings + from app.utils.config_loader import reload_settings_from_db + + r = redis.from_url(settings.redis_url, socket_connect_timeout=2) + current_version = (r.get(SETTINGS_VERSION_KEY) or b"").decode() + if current_version and current_version != _last_seen_version: + reload_settings_from_db(settings) + _last_seen_version = current_version + logger.info(f"Worker settings reloaded (version={current_version})") + except Exception as exc: + logger.debug(f"Settings version check skipped: {exc}") + + logger.info("Settings reload signal handler registered on task_prerun") diff --git a/app/views/settings.py b/app/views/settings.py index 7379d874..dd34ed03 100644 --- a/app/views/settings.py +++ b/app/views/settings.py @@ -103,7 +103,7 @@ async def settings_page(request: Request, db: Session = Depends(get_db)): settings_data[category].append( { "key": key, - "display_value": display_value if display_value is not None else "", + "display_value": (display_value if display_value is not None else ""), "metadata": metadata, "source": source, "source_label": source_label, @@ -112,11 +112,19 @@ async def settings_page(request: Request, db: Session = Depends(get_db)): ) return templates.TemplateResponse( - "settings.html", {"request": request, "settings_data": settings_data, "app_version": settings.version} + "settings.html", + { + "request": request, + "settings_data": settings_data, + "app_version": settings.version, + }, ) except Exception as e: logger.error(f"Error loading settings page: {e}") - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to load settings page") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to load settings page", + ) @router.get("/admin/credentials") @@ -181,4 +189,39 @@ async def credentials_page(request: Request, db: Session = Depends(get_db)): ) except Exception as e: logger.error(f"Error loading credentials page: {e}") - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to load credentials page") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to load credentials page", + ) + + +@router.get("/admin/settings/audit-log") +@require_login +@require_admin_access +async def audit_log_page(request: Request, db: Session = Depends(get_db)): + """ + Settings audit log page - admin only. + + Displays a chronological log of all configuration changes made via the + settings UI, including who made the change and what the old/new values + were. Sensitive values are masked. Provides rollback buttons to revert + any setting to a previous value. + """ + from app.utils.settings_service import get_audit_log + + try: + entries = get_audit_log(db, limit=200) + return templates.TemplateResponse( + "audit_log.html", + { + "request": request, + "entries": entries, + "app_version": settings.version, + }, + ) + except Exception as e: + logger.error(f"Error loading audit log page: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to load audit log page", + ) diff --git a/app/views/wizard.py b/app/views/wizard.py index 27696c8c..7d70d463 100644 --- a/app/views/wizard.py +++ b/app/views/wizard.py @@ -10,6 +10,7 @@ from fastapi.responses import RedirectResponse from sqlalchemy.orm import Session from app.utils.settings_service import save_setting_to_db +from app.utils.settings_sync import notify_settings_updated from app.utils.setup_wizard import get_wizard_steps from app.views.base import APIRouter, get_db, templates @@ -18,7 +19,7 @@ router = APIRouter() @router.get("/setup") -async def setup_wizard(request: Request, step: int = 1): +async def setup_wizard(request: Request, step: int = 1, db: Session = Depends(get_db)): """ Setup wizard for first-time configuration. @@ -41,6 +42,31 @@ async def setup_wizard(request: Request, step: int = 1): # Get step category (all settings in a step should have same category) step_category = current_settings[0].get("wizard_category", "Configuration") if current_settings else "Configuration" + # Enrich settings with current live values + from app.config import settings as app_settings + from app.utils.settings_service import get_setting_from_db + + enriched_settings = [] + for s in current_settings: + key = s["key"] + db_val = get_setting_from_db(db, key) + env_val = getattr(app_settings, key, None) + # Determine current_value and source + if db_val is not None: + current_value = db_val + value_source = "db" + elif env_val is not None and str(env_val).strip(): + current_value = str(env_val) + value_source = "env" + elif s.get("default") is not None: + current_value = s["default"] + value_source = "default" + else: + current_value = "" + value_source = "none" + enriched_settings.append({**s, "current_value": current_value, "value_source": value_source}) + current_settings = enriched_settings + return templates.TemplateResponse( "setup_wizard.html", { @@ -50,6 +76,7 @@ async def setup_wizard(request: Request, step: int = 1): "settings": current_settings, "step_category": step_category, "progress_percent": int((step / max_step) * 100), + "setup_skipped": bool(get_setting_from_db(db, "_setup_wizard_skipped")), }, ) @@ -87,6 +114,9 @@ async def setup_wizard_save(request: Request, step: int = Form(...), db: Session logger.info(f"Setup wizard step {step}: Saved {saved_count} settings") + if saved_count > 0: + notify_settings_updated() + # Determine next step max_step = max(wizard_steps.keys()) next_step = step + 1 @@ -122,3 +152,23 @@ async def setup_wizard_skip(request: Request): except Exception as e: logger.error(f"Error skipping setup wizard: {e}") return RedirectResponse(url="/", status_code=303) + + +@router.get("/setup/undo-skip") +async def setup_wizard_undo_skip(request: Request, db: Session = Depends(get_db)): + """ + Undo a previously skipped setup wizard. + + Removes the skip marker from the database so the wizard will be + presented again on next visit to the home page. Redirects to + step 1 of the wizard immediately. + """ + try: + from app.utils.settings_service import delete_setting_from_db + + delete_setting_from_db(db, "_setup_wizard_skipped", changed_by="wizard_undo_skip") + logger.info("Setup wizard skip marker removed; redirecting to wizard") + return RedirectResponse(url="/setup?step=1", status_code=303) + except Exception as e: + logger.error(f"Error undoing setup wizard skip: {e}") + return RedirectResponse(url="/settings", status_code=303) diff --git a/frontend/templates/audit_log.html b/frontend/templates/audit_log.html new file mode 100644 index 00000000..fa16bac6 --- /dev/null +++ b/frontend/templates/audit_log.html @@ -0,0 +1,157 @@ +{% extends "base.html" %} +{% block title %}Settings Audit Log - DocuElevate{% endblock %} + +{% block content %} +
+ +
+
+

Settings Audit Log

+

+ Chronological record of all configuration changes made via the settings UI. + Sensitive values are masked. Use the rollback button to revert any setting to a prior value. +

+
+ + Back to Settings + +
+ + +
+ +
+ + {% if entries %} +
+ + + + + + + + + + + + + + {% for entry in entries %} + + + + + + + + + + {% endfor %} + +
WhenChanged BySetting KeyActionOld ValueNew ValueRollback
{{ entry.changed_at }}{{ entry.changed_by }}{{ entry.key }} + {% if entry.action == 'delete' %} + delete + {% elif entry.action == 'rollback' %} + rollback + {% else %} + update + {% endif %} + + {% if entry.old_value %} + {{ entry.old_value }} + {% else %} + + {% endif %} + + {% if entry.new_value %} + {{ entry.new_value }} + {% else %} + — (deleted) + {% endif %} + + +
+
+ {% else %} +
+ +

No configuration changes recorded yet.

+

Changes you make on the Settings page will appear here.

+
+ {% endif %} +
+ + +{% endblock %} diff --git a/frontend/templates/settings.html b/frontend/templates/settings.html index 61898dab..36afbd72 100644 --- a/frontend/templates/settings.html +++ b/frontend/templates/settings.html @@ -13,10 +13,44 @@
-

Application Settings

-

- This is a convenience feature to view and edit application settings through the web interface. -

+
+
+

Application Settings

+

+ This is a convenience feature to view and edit application settings through the web interface. +

+
+ +
{% endif %}
+ + +
+ +
{% endfor %} @@ -185,6 +235,7 @@ function settingsApp() { originalData: {}, showPassword: {}, saving: false, + savingKey: null, showAlert: false, alertType: 'success', alertTitle: '', @@ -230,6 +281,38 @@ function settingsApp() { this.showAlert = false; }, + async saveSetting(key) { + this.savingKey = key; + this.hideAlert(); + + try { + const value = this.formData[key]; + const response = await fetch(`/api/settings/${key}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key, value }), + }); + + const result = await response.json(); + + if (response.ok && result.success) { + this.originalData[key] = value; + let message = `Setting '${key}' saved successfully.`; + if (result.restart_required) { + message += ' Please restart the application for this change to take effect.'; + } + this.showSuccessAlert('Setting Saved', message); + } else { + this.showErrorAlert('Save Failed', result.detail || 'Unknown error'); + } + } catch (error) { + console.error('Error saving setting:', error); + this.showErrorAlert('Error', 'Failed to save setting. Please try again.'); + } finally { + this.savingKey = null; + } + }, + async saveSettings() { this.saving = true; this.hideAlert(); diff --git a/frontend/templates/setup_wizard.html b/frontend/templates/setup_wizard.html index ed284dda..08d63037 100644 --- a/frontend/templates/setup_wizard.html +++ b/frontend/templates/setup_wizard.html @@ -134,13 +134,21 @@ type="{% if setting.sensitive %}password{% else %}text{% endif %}" id="{{ setting.key }}" name="{{ setting.key }}" - value="{{ setting.default if setting.default else '' }}" + value="{{ setting.current_value if setting.current_value else '' }}" class="wizard-input w-full px-4 py-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent" placeholder="{{ setting.description }}" {% if setting.default is none or setting.key in ['admin_password', 'openai_api_key', 'azure_ai_key', 'azure_endpoint'] %}required{% endif %} /> {% endif %} + {% if setting.value_source == 'db' %} + DB + {% elif setting.value_source == 'env' %} + ENV + {% elif setting.value_source == 'default' %} + DEFAULT + {% endif %} + {% if setting.key == 'admin_password' %}

@@ -159,7 +167,12 @@

- {% if current_step == 1 %} + {% if setup_skipped %} + + + Setup was previously skipped. + + {% elif current_step == 1 %} Skip setup (advanced users) @@ -200,6 +213,12 @@

Fields marked with * are required.

+ {% if setup_skipped %} +

+ + You previously skipped the setup wizard. Click here to re-run it. +

+ {% endif %}
diff --git a/tests/test_api_dropbox.py b/tests/test_api_dropbox.py index b664a9e8..f3d3a7a1 100644 --- a/tests/test_api_dropbox.py +++ b/tests/test_api_dropbox.py @@ -107,7 +107,8 @@ class TestUpdateDropboxSettings: """Test that exceptions return 500 error.""" # Make setting the attribute raise an exception type(mock_settings).dropbox_refresh_token = property( - lambda self: "", lambda self, v: (_ for _ in ()).throw(RuntimeError("forced")) + lambda self: "", + lambda self, v: (_ for _ in ()).throw(RuntimeError("forced")), ) response = client.post( @@ -264,15 +265,16 @@ class TestSaveDropboxSettings: @patch("app.api.dropbox.settings") def test_save_settings_env_not_found(self, mock_settings, client): - """Test error when .env file is not found.""" - # The endpoint constructs the env path using __file__ + """Test that missing .env file is non-fatal — DB write still succeeds.""" with patch("os.path.exists", return_value=False): response = client.post( "/api/dropbox/save-settings", data={"refresh_token": "test-token"}, ) - assert response.status_code == 500 + # .env write is best-effort; endpoint should still succeed via DB write + assert response.status_code == 200 + assert response.json()["status"] == "success" @patch("app.api.dropbox.settings") def test_save_settings_success(self, mock_settings, client, tmp_path): @@ -389,7 +391,7 @@ class TestSaveDropboxSettings: @patch("app.api.dropbox.settings") def test_save_settings_io_error(self, mock_settings, client, tmp_path): - """Test handling of I/O errors when saving settings.""" + """Test that I/O errors on .env write are non-fatal — DB write still succeeds.""" mock_settings.dropbox_refresh_token = "" # Create a temporary .env file @@ -407,6 +409,6 @@ class TestSaveDropboxSettings: data={"refresh_token": "new-token"}, ) - assert response.status_code == 500 - data = response.json() - assert "Failed to save Dropbox settings" in data["detail"] + # .env write is best-effort; endpoint should still succeed via DB write + assert response.status_code == 200 + assert response.json()["status"] == "success" diff --git a/tests/test_api_onedrive_comprehensive.py b/tests/test_api_onedrive_comprehensive.py index 1da7d30d..1019031b 100644 --- a/tests/test_api_onedrive_comprehensive.py +++ b/tests/test_api_onedrive_comprehensive.py @@ -119,13 +119,19 @@ class TestTestOneDriveToken: # Mock token refresh response mock_post_response = Mock() mock_post_response.status_code = 200 - mock_post_response.json.return_value = {"access_token": "test_access_token", "expires_in": 3600} + mock_post_response.json.return_value = { + "access_token": "test_access_token", + "expires_in": 3600, + } mock_post.return_value = mock_post_response # Mock user info response mock_get_response = Mock() mock_get_response.status_code = 200 - mock_get_response.json.return_value = {"displayName": "Test User", "userPrincipalName": "test@example.com"} + mock_get_response.json.return_value = { + "displayName": "Test User", + "userPrincipalName": "test@example.com", + } mock_get.return_value = mock_get_response response = client.get("/api/onedrive/test-token") @@ -198,7 +204,10 @@ class TestTestOneDriveToken: # Mock user info mock_get_response = Mock() mock_get_response.status_code = 200 - mock_get_response.json.return_value = {"displayName": "Test User", "userPrincipalName": "test@example.com"} + mock_get_response.json.return_value = { + "displayName": "Test User", + "userPrincipalName": "test@example.com", + } mock_get.return_value = mock_get_response with patch("os.path.exists", return_value=False): @@ -209,12 +218,23 @@ class TestTestOneDriveToken: @patch("requests.post") @patch("requests.get") - @patch("builtins.open", new_callable=mock_open, read_data="ONEDRIVE_REFRESH_TOKEN=old_token\n") + @patch( + "builtins.open", + new_callable=mock_open, + read_data="ONEDRIVE_REFRESH_TOKEN=old_token\n", + ) @patch("os.path.exists") @patch("os.path.dirname") @patch("app.config.settings") def test_test_token_updates_env_file( - self, mock_settings, mock_dirname, mock_exists, mock_file, mock_get, mock_post, client: TestClient + self, + mock_settings, + mock_dirname, + mock_exists, + mock_file, + mock_get, + mock_post, + client: TestClient, ): """Test that new refresh token is saved to .env file.""" mock_settings.onedrive_refresh_token = "old_token" @@ -238,7 +258,10 @@ class TestTestOneDriveToken: # Mock user info mock_get_response = Mock() mock_get_response.status_code = 200 - mock_get_response.json.return_value = {"displayName": "Test User", "userPrincipalName": "test@example.com"} + mock_get_response.json.return_value = { + "displayName": "Test User", + "userPrincipalName": "test@example.com", + } mock_get.return_value = mock_get_response response = client.get("/api/onedrive/test-token") @@ -258,7 +281,10 @@ class TestTestOneDriveToken: # Mock successful refresh mock_post_response = Mock() mock_post_response.status_code = 200 - mock_post_response.json.return_value = {"access_token": "test_access_token", "expires_in": 3600} + mock_post_response.json.return_value = { + "access_token": "test_access_token", + "expires_in": 3600, + } mock_post.return_value = mock_post_response # Mock failed user info @@ -343,17 +369,24 @@ class TestSaveOneDriveSettings: @patch("os.path.exists") @patch("os.path.dirname") def test_save_settings_env_file_not_found(self, mock_dirname, mock_exists, client: TestClient): - """Test save when .env file doesn't exist.""" + """Test that missing .env file is non-fatal — DB write still succeeds.""" mock_exists.return_value = False mock_dirname.return_value = "/app" - response = client.post("/api/onedrive/save-settings", data={"refresh_token": "token", "tenant_id": "common"}) + response = client.post( + "/api/onedrive/save-settings", + data={"refresh_token": "token", "tenant_id": "common"}, + ) - assert response.status_code == 500 - data = response.json() - assert "could not find .env file" in data["detail"].lower() + # .env write is best-effort; endpoint should still succeed via DB write + assert response.status_code == 200 + assert response.json()["status"] == "success" - @patch("builtins.open", new_callable=mock_open, read_data="ONEDRIVE_REFRESH_TOKEN=old_token\n") + @patch( + "builtins.open", + new_callable=mock_open, + read_data="ONEDRIVE_REFRESH_TOKEN=old_token\n", + ) @patch("os.path.exists") @patch("os.path.dirname") @patch("app.config.settings") @@ -365,12 +398,17 @@ class TestSaveOneDriveSettings: mock_dirname.return_value = "/app" response = client.post( - "/api/onedrive/save-settings", data={"refresh_token": "updated_token", "tenant_id": "common"} + "/api/onedrive/save-settings", + data={"refresh_token": "updated_token", "tenant_id": "common"}, ) assert response.status_code == 200 - @patch("builtins.open", new_callable=mock_open, read_data="# ONEDRIVE_CLIENT_ID=commented\n") + @patch( + "builtins.open", + new_callable=mock_open, + read_data="# ONEDRIVE_CLIENT_ID=commented\n", + ) @patch("os.path.exists") @patch("os.path.dirname") @patch("app.config.settings") @@ -383,7 +421,11 @@ class TestSaveOneDriveSettings: response = client.post( "/api/onedrive/save-settings", - data={"refresh_token": "token", "client_id": "new_client_id", "tenant_id": "common"}, + data={ + "refresh_token": "token", + "client_id": "new_client_id", + "tenant_id": "common", + }, ) assert response.status_code == 200 @@ -401,7 +443,11 @@ class TestSaveOneDriveSettings: response = client.post( "/api/onedrive/save-settings", - data={"refresh_token": "new_token", "folder_path": "/New/Path", "tenant_id": "common"}, + data={ + "refresh_token": "new_token", + "folder_path": "/New/Path", + "tenant_id": "common", + }, ) assert response.status_code == 200 @@ -415,12 +461,17 @@ class TestSaveOneDriveSettings: @patch("os.path.exists") @patch("os.path.dirname") def test_save_settings_exception_handling(self, mock_dirname, mock_exists, client: TestClient): - """Test exception handling in save settings.""" + """Test that exceptions in .env write are non-fatal — DB write still succeeds.""" mock_exists.side_effect = Exception("Unexpected error") - response = client.post("/api/onedrive/save-settings", data={"refresh_token": "token", "tenant_id": "common"}) + response = client.post( + "/api/onedrive/save-settings", + data={"refresh_token": "token", "tenant_id": "common"}, + ) - assert response.status_code == 500 + # .env write exception is caught; endpoint succeeds via DB write + assert response.status_code == 200 + assert response.json()["status"] == "success" @pytest.mark.unit @@ -455,7 +506,8 @@ class TestUpdateOneDriveSettings: mock_get_token.return_value = "test_token" response = client.post( - "/api/onedrive/update-settings", data={"refresh_token": "new_token", "tenant_id": "common"} + "/api/onedrive/update-settings", + data={"refresh_token": "new_token", "tenant_id": "common"}, ) assert response.status_code == 200 @@ -467,7 +519,8 @@ class TestUpdateOneDriveSettings: mock_get_token.side_effect = Exception("Token invalid") response = client.post( - "/api/onedrive/update-settings", data={"refresh_token": "bad_token", "tenant_id": "common"} + "/api/onedrive/update-settings", + data={"refresh_token": "bad_token", "tenant_id": "common"}, ) assert response.status_code == 200 @@ -486,9 +539,13 @@ class TestUpdateOneDriveSettings: """Test exception handling in update settings.""" mock_settings.onedrive_refresh_token = None - with patch("app.tasks.upload_to_onedrive.get_onedrive_token", side_effect=Exception("Fatal error")): + with patch( + "app.tasks.upload_to_onedrive.get_onedrive_token", + side_effect=Exception("Fatal error"), + ): response = client.post( - "/api/onedrive/update-settings", data={"refresh_token": "token", "tenant_id": "common"} + "/api/onedrive/update-settings", + data={"refresh_token": "token", "tenant_id": "common"}, ) # Should still update settings even if test fails @@ -578,7 +635,10 @@ class TestOneDriveIntegration: with patch("app.tasks.upload_to_onedrive.get_onedrive_token"): response = client.post( "/api/onedrive/update-settings", - data={"refresh_token": token_data["refresh_token"], "tenant_id": "common"}, + data={ + "refresh_token": token_data["refresh_token"], + "tenant_id": "common", + }, ) assert response.status_code == 200 @@ -606,7 +666,10 @@ class TestOneDriveIntegration: mock_get_response = Mock() mock_get_response.status_code = 200 - mock_get_response.json.return_value = {"displayName": "Test User", "userPrincipalName": "test@example.com"} + mock_get_response.json.return_value = { + "displayName": "Test User", + "userPrincipalName": "test@example.com", + } mock_get.return_value = mock_get_response with patch("os.path.exists", return_value=False): diff --git a/tests/test_settings_audit_log.py b/tests/test_settings_audit_log.py new file mode 100644 index 00000000..79021423 --- /dev/null +++ b/tests/test_settings_audit_log.py @@ -0,0 +1,414 @@ +"""Tests for the settings audit log, rollback, per-option save, and worker sync features.""" + +import asyncio +from unittest.mock import MagicMock, patch + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.database import Base +from app.models import SettingsAuditLog + +# --------------------------------------------------------------------------- +# Shared DB fixture +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def db_session(): + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + Session = sessionmaker(autocommit=False, autoflush=False, bind=engine) + session = Session() + try: + yield session + finally: + session.close() + Base.metadata.drop_all(bind=engine) + + +# =========================================================================== +# A) Audit log written on save +# =========================================================================== + + +@pytest.mark.unit +class TestAuditLogOnSave: + """Audit log entries are created when settings are saved or deleted.""" + + def test_save_creates_audit_entry(self, db_session): + from app.utils.settings_service import save_setting_to_db + + result = save_setting_to_db(db_session, "workdir", "/new/path", changed_by="alice") + + assert result is True + entry = db_session.query(SettingsAuditLog).filter_by(key="workdir").first() + assert entry is not None + assert entry.action == "update" + assert entry.new_value == "/new/path" + assert entry.changed_by == "alice" + assert entry.old_value is None # was not previously set + + def test_update_records_old_value(self, db_session): + from app.utils.settings_service import save_setting_to_db + + # Set initial value + save_setting_to_db(db_session, "workdir", "/old/path", changed_by="admin") + # Update + save_setting_to_db(db_session, "workdir", "/new/path", changed_by="bob") + + entries = db_session.query(SettingsAuditLog).filter_by(key="workdir").all() + assert len(entries) == 2 + # Second entry should have old_value from first write + update_entry = entries[1] + assert update_entry.old_value == "/old/path" + assert update_entry.new_value == "/new/path" + + def test_delete_creates_audit_entry(self, db_session): + from app.utils.settings_service import delete_setting_from_db, save_setting_to_db + + save_setting_to_db(db_session, "workdir", "/some/path", changed_by="admin") + result = delete_setting_from_db(db_session, "workdir", changed_by="carol") + + assert result is True + delete_entry = db_session.query(SettingsAuditLog).filter_by(key="workdir", action="delete").first() + assert delete_entry is not None + assert delete_entry.old_value == "/some/path" + assert delete_entry.new_value is None + assert delete_entry.changed_by == "carol" + + def test_delete_nonexistent_returns_false_no_entry(self, db_session): + from app.utils.settings_service import delete_setting_from_db + + result = delete_setting_from_db(db_session, "nonexistent_key", changed_by="admin") + + assert result is False + assert db_session.query(SettingsAuditLog).count() == 0 + + def test_default_changed_by_is_system(self, db_session): + from app.utils.settings_service import save_setting_to_db + + save_setting_to_db(db_session, "workdir", "/tmp") + + entry = db_session.query(SettingsAuditLog).first() + assert entry.changed_by == "system" + + +# =========================================================================== +# C) Audit log retrieval +# =========================================================================== + + +@pytest.mark.unit +class TestGetAuditLog: + """get_audit_log returns entries, masks sensitive values.""" + + def test_returns_all_entries_most_recent_first(self, db_session): + from app.utils.settings_service import get_audit_log, save_setting_to_db + + save_setting_to_db(db_session, "workdir", "/first", changed_by="u1") + save_setting_to_db(db_session, "workdir", "/second", changed_by="u2") + + log = get_audit_log(db_session, limit=100) + + assert len(log) == 2 + # Most recent first + assert log[0]["new_value"] == "/second" + assert log[1]["new_value"] == "/first" + + def test_sensitive_values_are_masked(self, db_session): + from app.utils.settings_service import get_audit_log, save_setting_to_db + + save_setting_to_db(db_session, "openai_api_key", "sk-secret123", changed_by="admin") + + log = get_audit_log(db_session) + + entry = next(e for e in log if e["key"] == "openai_api_key") + assert entry["new_value"] == "[REDACTED]" + + def test_required_fields_present(self, db_session): + from app.utils.settings_service import get_audit_log, save_setting_to_db + + save_setting_to_db(db_session, "workdir", "/path", changed_by="alice") + + log = get_audit_log(db_session) + + assert len(log) == 1 + entry = log[0] + for field in ( + "id", + "key", + "old_value", + "new_value", + "changed_by", + "changed_at", + "action", + ): + assert field in entry + + def test_limit_and_offset(self, db_session): + from app.utils.settings_service import get_audit_log, save_setting_to_db + + for i in range(5): + save_setting_to_db(db_session, "workdir", f"/path{i}", changed_by="admin") + + first_page = get_audit_log(db_session, limit=3, offset=0) + second_page = get_audit_log(db_session, limit=3, offset=3) + + assert len(first_page) == 3 + assert len(second_page) == 2 + + +# =========================================================================== +# C) Per-key history +# =========================================================================== + + +@pytest.mark.unit +class TestGetSettingHistory: + """get_setting_history returns only entries for the requested key.""" + + def test_returns_only_matching_key(self, db_session): + from app.utils.settings_service import get_setting_history, save_setting_to_db + + save_setting_to_db(db_session, "workdir", "/wdir", changed_by="admin") + save_setting_to_db(db_session, "debug", "true", changed_by="admin") + + history = get_setting_history(db_session, "workdir") + + assert len(history) == 1 + assert history[0]["key"] == "workdir" + + def test_returns_empty_list_for_unknown_key(self, db_session): + from app.utils.settings_service import get_setting_history + + history = get_setting_history(db_session, "totally_unknown_key") + + assert history == [] + + +# =========================================================================== +# D) Rollback +# =========================================================================== + + +@pytest.mark.unit +class TestRollbackSetting: + """rollback_setting reinstates the value from a given audit log entry.""" + + def test_rollback_to_previous_value(self, db_session): + from app.utils.settings_service import get_setting_from_db, rollback_setting, save_setting_to_db + + save_setting_to_db(db_session, "workdir", "/v1", changed_by="admin") # entry id 1 + save_setting_to_db(db_session, "workdir", "/v2", changed_by="admin") # entry id 2 + + first_entry = db_session.query(SettingsAuditLog).filter_by(key="workdir").first() + # first entry has new_value="/v1" + success = rollback_setting(db_session, "workdir", first_entry.id, changed_by="rollbacker") + + assert success is True + current = get_setting_from_db(db_session, "workdir") + assert current == "/v1" + + def test_rollback_creates_new_audit_entry(self, db_session): + from app.utils.settings_service import rollback_setting, save_setting_to_db + + save_setting_to_db(db_session, "workdir", "/v1", changed_by="admin") + entry = db_session.query(SettingsAuditLog).filter_by(key="workdir").first() + + initial_count = db_session.query(SettingsAuditLog).count() + rollback_setting(db_session, "workdir", entry.id, changed_by="rollbacker") + + assert db_session.query(SettingsAuditLog).count() == initial_count + 1 + + def test_rollback_wrong_history_id_returns_false(self, db_session): + from app.utils.settings_service import rollback_setting, save_setting_to_db + + save_setting_to_db(db_session, "workdir", "/v1", changed_by="admin") + + result = rollback_setting(db_session, "workdir", 9999, changed_by="admin") + + assert result is False + + def test_rollback_wrong_key_returns_false(self, db_session): + from app.utils.settings_service import rollback_setting, save_setting_to_db + + save_setting_to_db(db_session, "workdir", "/v1", changed_by="admin") + entry = db_session.query(SettingsAuditLog).filter_by(key="workdir").first() + + # Pass wrong key for the history ID + result = rollback_setting(db_session, "debug", entry.id, changed_by="admin") + + assert result is False + + +# =========================================================================== +# B) Worker sync – settings_sync module +# =========================================================================== + + +@pytest.mark.unit +class TestNotifySettingsUpdated: + """notify_settings_updated publishes the settings version key to Redis.""" + + def test_sets_redis_key(self): + from app.utils.settings_sync import SETTINGS_VERSION_KEY, notify_settings_updated + + mock_redis = MagicMock() + mock_redis_instance = MagicMock() + mock_redis.return_value = mock_redis_instance + + with patch("app.utils.settings_sync.redis") as mock_redis_module: + mock_redis_module.from_url.return_value = mock_redis_instance + notify_settings_updated() + + mock_redis_instance.set.assert_called_once() + call_args = mock_redis_instance.set.call_args[0] + assert call_args[0] == SETTINGS_VERSION_KEY + + def test_does_not_raise_on_redis_failure(self): + """notify_settings_updated must not propagate Redis errors.""" + from app.utils.settings_sync import notify_settings_updated + + with patch("app.utils.settings_sync.redis") as mock_redis_module: + mock_redis_module.from_url.side_effect = Exception("Redis down") + # Should not raise + notify_settings_updated() + + +@pytest.mark.unit +class TestRegisterSettingsReloadSignal: + """register_settings_reload_signal installs a task_prerun handler.""" + + def test_registers_without_error(self): + from app.utils.settings_sync import register_settings_reload_signal + + with patch("app.utils.settings_sync.task_prerun") as mock_signal: + mock_signal.connect = MagicMock() + # Call it – the decorator calls task_prerun.connect(weak=False) + register_settings_reload_signal() + # If no exception is raised the registration succeeded + + +# =========================================================================== +# API endpoint – audit log +# =========================================================================== + + +@pytest.mark.integration +class TestAuditLogEndpoint: + """GET /api/settings/audit-log requires admin access.""" + + def test_requires_admin(self, client): + response = client.get("/api/settings/audit-log") + assert response.status_code in [302, 401, 403] + + @patch("app.api.settings.get_audit_log") + def test_returns_entries_for_admin(self, mock_get_log): + from app.api.settings import list_audit_log + + mock_get_log.return_value = [ + { + "id": 1, + "key": "workdir", + "old_value": None, + "new_value": "/tmp", + "changed_by": "admin", + "changed_at": "2024-01-01T00:00:00", + "action": "update", + } + ] + + mock_request = MagicMock() + mock_db = MagicMock() + mock_admin = {"is_admin": True} + + result = asyncio.run(list_audit_log(mock_request, mock_db, mock_admin)) + + assert "entries" in result + assert len(result["entries"]) == 1 + assert result["entries"][0]["key"] == "workdir" + + +@pytest.mark.integration +class TestHistoryEndpoint: + """GET /api/settings/{key}/history requires admin access.""" + + def test_requires_admin(self, client): + response = client.get("/api/settings/workdir/history") + assert response.status_code in [302, 401, 403] + + @patch("app.api.settings.get_setting_history") + def test_returns_history_for_admin(self, mock_get_history): + from app.api.settings import get_key_history + + mock_get_history.return_value = [ + { + "id": 1, + "key": "workdir", + "old_value": None, + "new_value": "/tmp", + "changed_by": "admin", + "changed_at": "2024-01-01T00:00:00", + "action": "update", + } + ] + + mock_request = MagicMock() + mock_db = MagicMock() + mock_admin = {"is_admin": True} + + result = asyncio.run(get_key_history("workdir", mock_request, mock_db, mock_admin)) + + assert result["key"] == "workdir" + assert len(result["history"]) == 1 + + +@pytest.mark.integration +class TestRollbackEndpoint: + """POST /api/settings/{key}/rollback/{history_id} requires admin access.""" + + def test_requires_admin(self, client): + response = client.post("/api/settings/workdir/rollback/1") + assert response.status_code in [302, 401, 403] + + @patch("app.api.settings.notify_settings_updated") + @patch("app.api.settings.rollback_setting") + def test_rollback_success(self, mock_rollback, mock_notify): + from app.api.settings import rollback_setting_to_history + + mock_rollback.return_value = True + mock_request = MagicMock() + mock_request.session = {"user": {"preferred_username": "admin"}} + mock_db = MagicMock() + mock_admin = {"is_admin": True} + + result = asyncio.run(rollback_setting_to_history("workdir", 1, mock_request, mock_db, mock_admin)) + + assert result["success"] is True + mock_notify.assert_called_once() + + @patch("app.api.settings.rollback_setting") + def test_rollback_not_found_raises_404(self, mock_rollback): + import asyncio + + from fastapi import HTTPException + + from app.api.settings import rollback_setting_to_history + + mock_rollback.return_value = False + mock_request = MagicMock() + mock_request.session = {"user": {"preferred_username": "admin"}} + mock_db = MagicMock() + mock_admin = {"is_admin": True} + + with pytest.raises(HTTPException) as exc_info: + asyncio.run(rollback_setting_to_history("workdir", 9999, mock_request, mock_db, mock_admin)) + + assert exc_info.value.status_code == 404 diff --git a/tests/test_wizard_db_persist.py b/tests/test_wizard_db_persist.py new file mode 100644 index 00000000..2f51f09e --- /dev/null +++ b/tests/test_wizard_db_persist.py @@ -0,0 +1,485 @@ +""" +Tests for wizard DB persistence, settings export, and related functionality. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from app.utils.settings_service import get_setting_from_db, save_setting_to_db + +# --------------------------------------------------------------------------- +# TestSetupWizardDbPersist +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestSetupWizardDbPersist: + """Unit tests for setup_wizard_save POST handler DB persistence.""" + + @patch("app.views.wizard.notify_settings_updated") + @patch("app.views.wizard.save_setting_to_db") + def test_settings_saved_to_db(self, mock_save, mock_notify, client): + """Test that settings are saved to DB via save_setting_to_db.""" + mock_save.return_value = True + + response = client.post( + "/setup", + data={"step": "1", "database_url": "sqlite:///test.db"}, + follow_redirects=False, + ) + + assert response.status_code == 303 + mock_save.assert_called() + + @patch("app.views.wizard.notify_settings_updated") + @patch("app.views.wizard.save_setting_to_db") + def test_notify_called_when_settings_saved(self, mock_save, mock_notify, client): + """Test that notify_settings_updated is called when settings are saved.""" + mock_save.return_value = True + + client.post( + "/setup", + data={"step": "1", "database_url": "sqlite:///test.db"}, + follow_redirects=False, + ) + + mock_notify.assert_called_once() + + @patch("app.views.wizard.notify_settings_updated") + @patch("app.views.wizard.save_setting_to_db") + def test_notify_not_called_when_no_settings_saved(self, mock_save, mock_notify, client): + """Test that notify_settings_updated is NOT called when saved_count == 0.""" + mock_save.return_value = False + + client.post( + "/setup", + data={"step": "1"}, # no values provided + follow_redirects=False, + ) + + mock_notify.assert_not_called() + + @patch("app.views.wizard.notify_settings_updated") + @patch("app.views.wizard.secrets.token_hex") + @patch("app.views.wizard.save_setting_to_db") + def test_auto_generate_session_secret(self, mock_save, mock_token, mock_notify, client): + """Test that session_secret auto-generate path produces a real token.""" + mock_save.return_value = True + mock_token.return_value = "deadbeef" * 8 + + client.post( + "/setup", + data={"step": "2", "session_secret": "auto-generate"}, + follow_redirects=False, + ) + + mock_token.assert_called_once() + # Ensure save was called with the generated token, not 'auto-generate' + for call_args in mock_save.call_args_list: + args = call_args[0] + if len(args) >= 2 and args[1] == "session_secret": + assert args[2] != "auto-generate" + + +# --------------------------------------------------------------------------- +# TestSetupWizardUndoSkip +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestSetupWizardUndoSkip: + """Tests for /setup/undo-skip route.""" + + def test_undo_skip_removes_marker(self, client, db_session): + """Test that undo-skip removes the _setup_wizard_skipped marker from DB.""" + # First, put the marker in DB + save_setting_to_db(db_session, "_setup_wizard_skipped", "true") + assert get_setting_from_db(db_session, "_setup_wizard_skipped") == "true" + + # Undo skip via the route + response = client.get("/setup/undo-skip", follow_redirects=False) + + # Should redirect + assert response.status_code in (303, 200) + + def test_undo_skip_redirects_to_wizard(self, client): + """Test that undo-skip redirects to /setup?step=1.""" + response = client.get("/setup/undo-skip", follow_redirects=False) + # The redirect should go to /setup?step=1 or /settings on error + assert response.status_code in (303, 302) + location = response.headers.get("location", "") + assert "/setup" in location or "/settings" in location + + @patch("app.utils.settings_service.delete_setting_from_db") + def test_undo_skip_calls_delete(self, mock_delete, client): + """Test that undo-skip calls delete_setting_from_db.""" + mock_delete.return_value = True + # Just ensure the route exists and does not 404 + response = client.get("/setup/undo-skip", follow_redirects=False) + assert response.status_code != 404 + + +# --------------------------------------------------------------------------- +# TestDropboxSaveSettingsDbPersist +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestDropboxSaveSettingsDbPersist: + """Unit tests for save_dropbox_settings DB persistence.""" + + @patch("app.api.dropbox.settings") + @patch("app.api.dropbox.notify_settings_updated") + @patch("app.api.dropbox.save_setting_to_db") + def test_db_written_even_when_env_missing(self, mock_save, mock_notify, mock_settings, client): + """Test that DB is written even when .env doesn't exist (no exception).""" + mock_save.return_value = True + + with patch("os.path.exists", return_value=False): + response = client.post( + "/api/dropbox/save-settings", + data={"refresh_token": "test-refresh-token"}, + follow_redirects=False, + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + mock_save.assert_called() + + @patch("app.api.dropbox.settings") + @patch("app.api.dropbox.notify_settings_updated") + @patch("app.api.dropbox.save_setting_to_db") + def test_notify_settings_updated_called(self, mock_save, mock_notify, mock_settings, client): + """Test that notify_settings_updated is called.""" + mock_save.return_value = True + + with patch("os.path.exists", return_value=False): + client.post( + "/api/dropbox/save-settings", + data={"refresh_token": "test-refresh-token"}, + follow_redirects=False, + ) + + mock_notify.assert_called_once() + + @patch("app.api.dropbox.settings") + @patch("app.api.dropbox.notify_settings_updated") + @patch("app.api.dropbox.save_setting_to_db") + def test_all_provided_values_persisted(self, mock_save, mock_notify, mock_settings, client): + """Test that all provided values are persisted to DB.""" + mock_save.return_value = True + + with patch("os.path.exists", return_value=False): + client.post( + "/api/dropbox/save-settings", + data={ + "refresh_token": "tok", + "app_key": "key", + "app_secret": "secret", + "folder_path": "/uploads", + }, + follow_redirects=False, + ) + + keys_saved = [call[0][1] for call in mock_save.call_args_list] + assert "dropbox_refresh_token" in keys_saved + assert "dropbox_app_key" in keys_saved + assert "dropbox_app_secret" in keys_saved + assert "dropbox_folder" in keys_saved + + +# --------------------------------------------------------------------------- +# TestGoogleDriveUpdateSettingsDbPersist +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGoogleDriveUpdateSettingsDbPersist: + """Unit tests for update_google_drive_settings DB persistence.""" + + @patch("app.api.google_drive.settings") + @patch("app.api.google_drive.notify_settings_updated") + @patch("app.api.google_drive.save_setting_to_db") + def test_db_written_for_each_provided_field(self, mock_save, mock_notify, mock_settings, client): + """Test that DB is written for each provided field.""" + mock_save.return_value = True + + response = client.post( + "/api/google-drive/update-settings", + data={ + "refresh_token": "gdrive-refresh", + "client_id": "client-id", + "client_secret": "client-secret", + "folder_id": "folder-123", + "use_oauth": "true", + }, + follow_redirects=False, + ) + + assert response.status_code == 200 + keys_saved = [call[0][1] for call in mock_save.call_args_list] + assert "google_drive_refresh_token" in keys_saved + assert "google_drive_client_id" in keys_saved + assert "google_drive_client_secret" in keys_saved + assert "google_drive_folder_id" in keys_saved + assert "google_drive_use_oauth" in keys_saved + + @patch("app.api.google_drive.settings") + @patch("app.api.google_drive.notify_settings_updated") + @patch("app.api.google_drive.save_setting_to_db") + def test_use_oauth_saved_as_lowercase_string(self, mock_save, mock_notify, mock_settings, client): + """Test that use_oauth is saved as 'true' or 'false' string.""" + mock_save.return_value = True + + client.post( + "/api/google-drive/update-settings", + data={"refresh_token": "tok", "use_oauth": "true"}, + follow_redirects=False, + ) + + use_oauth_calls = [call for call in mock_save.call_args_list if call[0][1] == "google_drive_use_oauth"] + assert len(use_oauth_calls) == 1 + assert use_oauth_calls[0][0][2] in ("true", "false") + + @patch("app.api.google_drive.settings") + @patch("app.api.google_drive.notify_settings_updated") + @patch("app.api.google_drive.save_setting_to_db") + def test_notify_called(self, mock_save, mock_notify, mock_settings, client): + """Test that notify_settings_updated is called.""" + mock_save.return_value = True + + client.post( + "/api/google-drive/update-settings", + data={"refresh_token": "tok"}, + follow_redirects=False, + ) + + mock_notify.assert_called_once() + + +# --------------------------------------------------------------------------- +# TestOneDriveSaveSettingsDbPersist +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestOneDriveSaveSettingsDbPersist: + """Unit tests for save_onedrive_settings DB persistence.""" + + @patch("app.api.onedrive.settings") + @patch("app.api.onedrive.notify_settings_updated") + @patch("app.api.onedrive.save_setting_to_db") + def test_db_written_even_without_env_file(self, mock_save, mock_notify, mock_settings, client): + """Test that DB is written even when .env file does not exist.""" + mock_save.return_value = True + + with patch("os.path.exists", return_value=False): + response = client.post( + "/api/onedrive/save-settings", + data={"refresh_token": "od-refresh", "tenant_id": "common"}, + follow_redirects=False, + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + mock_save.assert_called() + + @patch("app.api.onedrive.settings") + @patch("app.api.onedrive.notify_settings_updated") + @patch("app.api.onedrive.save_setting_to_db") + def test_all_fields_persisted(self, mock_save, mock_notify, mock_settings, client): + """Test that all provided fields are persisted to DB.""" + mock_save.return_value = True + + with patch("os.path.exists", return_value=False): + client.post( + "/api/onedrive/save-settings", + data={ + "refresh_token": "tok", + "client_id": "cid", + "client_secret": "csec", + "tenant_id": "my-tenant", + "folder_path": "/docs", + }, + follow_redirects=False, + ) + + keys_saved = [call[0][1] for call in mock_save.call_args_list] + assert "onedrive_refresh_token" in keys_saved + assert "onedrive_client_id" in keys_saved + assert "onedrive_client_secret" in keys_saved + assert "onedrive_tenant_id" in keys_saved + assert "onedrive_folder_path" in keys_saved + + @patch("app.api.onedrive.settings") + @patch("app.api.onedrive.notify_settings_updated") + @patch("app.api.onedrive.save_setting_to_db") + def test_notify_called(self, mock_save, mock_notify, mock_settings, client): + """Test that notify_settings_updated is called.""" + mock_save.return_value = True + + with patch("os.path.exists", return_value=False): + client.post( + "/api/onedrive/save-settings", + data={"refresh_token": "tok"}, + follow_redirects=False, + ) + + mock_notify.assert_called_once() + + +# --------------------------------------------------------------------------- +# TestGetSettingsForExport +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetSettingsForExport: + """Unit tests for the get_settings_for_export service function.""" + + def test_source_db_returns_only_db_settings(self, db_session): + """Test that source=db returns only DB-persisted settings.""" + from app.utils.settings_service import get_settings_for_export + + save_setting_to_db(db_session, "workdir", "/tmp/test", changed_by="test") + result = get_settings_for_export(db_session, source="db") + + assert "WORKDIR" in result + assert result["WORKDIR"] == "/tmp/test" + + def test_source_effective_includes_metadata_keys(self, db_session): + """Test that source=effective includes keys from SETTING_METADATA.""" + from app.utils.settings_service import get_settings_for_export + + result = get_settings_for_export(db_session, source="effective") + + # The effective export should include keys from SETTING_METADATA that have values + # At a minimum check it returns a dict + assert isinstance(result, dict) + # Keys should be uppercase + for k in result: + assert k == k.upper() + + def test_keys_are_uppercase(self, db_session): + """Test that all keys are returned in uppercase.""" + from app.utils.settings_service import get_settings_for_export + + save_setting_to_db(db_session, "workdir", "/tmp", changed_by="test") + result = get_settings_for_export(db_session, source="db") + + for k in result: + assert k == k.upper(), f"Key {k!r} is not uppercase" + + def test_none_values_excluded(self, db_session): + """Test that None values are excluded from the export.""" + from app.utils.settings_service import get_settings_for_export + + result = get_settings_for_export(db_session, source="db") + + for v in result.values(): + assert v is not None + + def test_db_only_excludes_env_only_values(self, db_session): + """Test that source=db does NOT include ENV-only values (only DB rows).""" + from app.utils.settings_service import get_settings_for_export + + # Ensure no settings in DB + result = get_settings_for_export(db_session, source="db") + # DB is empty so result should be empty + assert len(result) == 0 + + +# --------------------------------------------------------------------------- +# TestExportEnvEndpoint +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestExportEnvEndpoint: + """Unit tests for export_env_settings endpoint function.""" + + def test_requires_admin(self, client): + """Test that the endpoint requires admin access (no session).""" + response = client.get("/api/settings/export-env") + assert response.status_code in (302, 401, 403) + + def test_returns_text_plain(self, db_session): + """Test that the endpoint returns text/plain response.""" + import asyncio + + from app.api.settings import export_env_settings + + mock_request = MagicMock() + mock_admin = {"id": "admin", "is_admin": True} + + result = asyncio.run(export_env_settings(mock_request, db_session, mock_admin, source="db")) + assert result.media_type == "text/plain" + + def test_content_disposition_header(self, db_session): + """Test that the response includes a content-disposition attachment header.""" + import asyncio + + from app.api.settings import export_env_settings + + mock_request = MagicMock() + mock_admin = {"id": "admin", "is_admin": True} + + result = asyncio.run(export_env_settings(mock_request, db_session, mock_admin, source="db")) + cd = result.headers.get("content-disposition", "") + assert "attachment" in cd + assert ".env" in cd + + def test_invalid_source_returns_400(self, db_session): + """Test that an invalid source parameter raises HTTPException 400.""" + import asyncio + + from fastapi import HTTPException + + from app.api.settings import export_env_settings + + mock_request = MagicMock() + mock_admin = {"id": "admin", "is_admin": True} + + with pytest.raises(HTTPException) as exc_info: + asyncio.run(export_env_settings(mock_request, db_session, mock_admin, source="invalid")) + assert exc_info.value.status_code == 400 + + def test_default_source_is_db(self, db_session): + """Test that default source is db (filename contains 'db').""" + import asyncio + + from app.api.settings import export_env_settings + + mock_request = MagicMock() + mock_admin = {"id": "admin", "is_admin": True} + + result = asyncio.run(export_env_settings(mock_request, db_session, mock_admin)) + cd = result.headers.get("content-disposition", "") + assert "db" in cd + + def test_effective_source_returns_response(self, db_session): + """Test that source=effective returns a valid response.""" + import asyncio + + from app.api.settings import export_env_settings + + mock_request = MagicMock() + mock_admin = {"id": "admin", "is_admin": True} + + result = asyncio.run(export_env_settings(mock_request, db_session, mock_admin, source="effective")) + assert result.media_type == "text/plain" + + def test_output_contains_docuelevate_header(self, db_session): + """Test that the export output contains a DocuElevate header comment.""" + import asyncio + + from app.api.settings import export_env_settings + + mock_request = MagicMock() + mock_admin = {"id": "admin", "is_admin": True} + + result = asyncio.run(export_env_settings(mock_request, db_session, mock_admin, source="db")) + assert b"DocuElevate" in result.body