c7d3ec57c3
Commitd2217531(google-labs-jules SSRF fix) catastrophically deleted 11,500+ lines across 100+ files while fixing an unrelated IMAP issue. Restored from d2217531^ (pre-bad-commit state): Deleted files (fully restored): - app/api/{automation,classification_rules,comments,sharing}.py - app/middleware/upload_rate_limit.py - app/tasks/{automation_tasks,classify_document}.py - app/utils/{automation_hooks,classification_rules}.py - docs/AppleAppStoreCompliance.md - frontend/input.css, package.json, package-lock.json, tailwind.config.js - frontend/static/js/{annotations,claim,comments,sharing}.js - frontend/templates/{admin_connections,file_annotations,file_summary}.html - tests/{test_api_files_comprehensive,test_auth_extended,test_sharing, test_comments,test_connections,test_imap_profiles,test_api_sessions, test_automation,test_classification_rules,test_api_advanced_filters, test_api_classification_rules,test_upload_rate_limit,test_api_dropbox, test_classify_document,test_comments_ui,test_upload_to_icloud, test_api_onedrive_comprehensive,test_frontend_build,test_sentry, test_diagnostic,test_database,test_views_dropbox,test_local_auth}.py Truncated files (content restored): - app/{auth,config,main,models,celery_worker,database}.py - app/api/{__init__,api_tokens,diagnostic,dropbox,files,google_drive, integrations,local_auth,mobile,onedrive,pipelines,qr_auth, settings,url_upload}.py - app/middleware/upload_rate_limit.py - app/tasks/upload_to_nextcloud.py - app/utils/{allowed_types,settings_service,settings_sync,user_scope,webhook}.py - app/views/{base,dropbox,files,google_drive,onedrive,settings}.py - docs/{API,AuthenticationSetup,ConfigurationGuide,DatabaseConfiguration, DeploymentGuide,DropboxSetup,GoogleDriveSetup,KubernetesDeployment, MobileApp,OneDriveSetup,ProductionReadiness,SentrySetup, SocialLoginSetup,UserGuide}.md - frontend/static/{js/upload.js,styles.css} - frontend/templates/{api_tokens,base,devices,dropbox,dropbox_callback, file_view,files,google_drive,onedrive,onedrive_callback, signup}.html - frontend/translations/en.json - migrations/env.py - tests/{conftest,test_api_integrations,test_api_mobile,test_api_settings, test_api_tokens,test_audit_logs,test_duplicates,test_imap_tasks, test_setup_wizard,test_views_files_comprehensive}.py Security fixes kept from post-d2217531 commits: - app/utils/network.py: DNS SSRF fail-secure fix (06b0fced) - app/utils/file_operations.py: path traversal fix (1018ea17) - tests/test_imap_tasks.py: re-applied 4 is_private_ip mock patches Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/51133dd8-9bec-41ab-aa10-3de753634187
479 lines
19 KiB
Python
479 lines
19 KiB
Python
"""Tests for app/api/settings.py module."""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from app.api.settings import require_admin
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestRequireAdmin:
|
|
"""Tests for require_admin dependency."""
|
|
|
|
def test_raises_403_when_no_user(self):
|
|
"""Test that 403 is raised when no user in session."""
|
|
mock_request = MagicMock()
|
|
mock_request.session = {}
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
require_admin(mock_request)
|
|
assert exc_info.value.status_code == 403
|
|
|
|
def test_raises_403_when_not_admin(self):
|
|
"""Test that 403 is raised for non-admin user."""
|
|
mock_request = MagicMock()
|
|
mock_request.session = {"user": {"id": "1", "is_admin": False}}
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
require_admin(mock_request)
|
|
assert exc_info.value.status_code == 403
|
|
|
|
def test_returns_user_when_admin(self):
|
|
"""Test that admin user is returned."""
|
|
mock_request = MagicMock()
|
|
user = {"id": "admin", "is_admin": True}
|
|
mock_request.session = {"user": user}
|
|
|
|
result = require_admin(mock_request)
|
|
assert result == user
|
|
|
|
def test_raises_403_with_correct_detail_message(self):
|
|
"""Test that 403 includes correct detail message."""
|
|
mock_request = MagicMock()
|
|
mock_request.session = {}
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
require_admin(mock_request)
|
|
assert exc_info.value.detail == "Admin access required"
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestSettingsEndpoints:
|
|
"""Integration tests for settings API endpoints."""
|
|
|
|
def test_get_settings_requires_admin(self, client):
|
|
"""Test GET /settings requires admin access."""
|
|
response = client.get("/api/settings/")
|
|
assert response.status_code in [302, 401, 403]
|
|
|
|
def test_get_single_setting_requires_admin(self, client):
|
|
"""Test GET /settings/{key} requires admin access."""
|
|
response = client.get("/api/settings/workdir")
|
|
assert response.status_code in [302, 401, 403]
|
|
|
|
def test_update_setting_requires_admin(self, client):
|
|
"""Test POST /settings/{key} requires admin access."""
|
|
response = client.post("/api/settings/test_key", json={"key": "test_key", "value": "test_value"})
|
|
assert response.status_code in [302, 401, 403]
|
|
|
|
def test_delete_setting_requires_admin(self, client):
|
|
"""Test DELETE /settings/{key} requires admin access."""
|
|
response = client.delete("/api/settings/test_key")
|
|
assert response.status_code in [302, 401, 403]
|
|
|
|
def test_bulk_update_requires_admin(self, client):
|
|
"""Test POST /settings/bulk-update requires admin access."""
|
|
response = client.post("/api/settings/bulk-update", json=[{"key": "test_key", "value": "test_value"}])
|
|
assert response.status_code in [302, 401, 403]
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestSettingsEndpointsWithAuth:
|
|
"""Integration tests for settings endpoints with authentication."""
|
|
|
|
@patch("app.api.settings.get_all_settings_from_db")
|
|
@patch("app.api.settings.get_settings_by_category")
|
|
@patch("app.api.settings.get_setting_metadata")
|
|
def test_get_all_settings_success(self, mock_metadata, mock_categories, mock_db_settings, client, db_session):
|
|
"""Test GET /settings returns all settings."""
|
|
from app.api.settings import require_admin
|
|
from app.main import app as fastapi_app
|
|
|
|
# Mock the settings data
|
|
mock_db_settings.return_value = {"test_key": "test_value"}
|
|
mock_categories.return_value = {"General": ["workdir", "debug"]}
|
|
mock_metadata.return_value = {"type": "str", "description": "Test setting"}
|
|
|
|
# Override the require_admin dependency to return a mock admin user
|
|
def override_require_admin():
|
|
return {"id": "admin", "is_admin": True}
|
|
|
|
fastapi_app.dependency_overrides[require_admin] = override_require_admin
|
|
|
|
try:
|
|
# Make the request with the mocked admin dependency
|
|
response = client.get("/api/settings/")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "settings" in data
|
|
assert "categories" in data
|
|
assert "db_settings" in data
|
|
finally:
|
|
# Clean up the override
|
|
fastapi_app.dependency_overrides.pop(require_admin, None)
|
|
|
|
@patch("app.api.settings.get_setting_metadata")
|
|
def test_get_single_setting_returns_metadata(self, mock_metadata, client):
|
|
"""Test GET /settings/{key} returns setting with metadata."""
|
|
mock_metadata.return_value = {"type": "str", "description": "Working directory"}
|
|
|
|
# Without admin auth, should be 403
|
|
response = client.get("/api/settings/workdir")
|
|
assert response.status_code in [302, 401, 403]
|
|
|
|
@patch("app.api.settings.validate_setting_value")
|
|
@patch("app.api.settings.save_setting_to_db")
|
|
@patch("app.api.settings.get_setting_metadata")
|
|
def test_update_setting_validates_value(self, mock_metadata, mock_save, mock_validate, client):
|
|
"""Test POST /settings/{key} validates setting value."""
|
|
mock_validate.return_value = (False, "Invalid value")
|
|
mock_metadata.return_value = {"restart_required": False}
|
|
|
|
# Without admin auth, should be 403
|
|
response = client.post("/api/settings/test_key", json={"key": "test_key", "value": "invalid"})
|
|
assert response.status_code in [302, 401, 403]
|
|
|
|
@patch("app.api.settings.delete_setting_from_db")
|
|
def test_delete_setting_handles_not_found(self, mock_delete, client):
|
|
"""Test DELETE /settings/{key} handles not found."""
|
|
mock_delete.return_value = False
|
|
|
|
# Without admin auth, should be 403
|
|
response = client.delete("/api/settings/nonexistent_key")
|
|
assert response.status_code in [302, 401, 403]
|
|
|
|
@patch("app.api.settings.validate_setting_value")
|
|
@patch("app.api.settings.save_setting_to_db")
|
|
@patch("app.api.settings.get_setting_metadata")
|
|
def test_bulk_update_processes_multiple_settings(self, mock_metadata, mock_save, mock_validate, client):
|
|
"""Test POST /settings/bulk-update processes multiple settings."""
|
|
mock_validate.return_value = (True, None)
|
|
mock_save.return_value = True
|
|
mock_metadata.return_value = {"restart_required": False}
|
|
|
|
updates = [{"key": "setting1", "value": "value1"}, {"key": "setting2", "value": "value2"}]
|
|
|
|
# Without admin auth, should be 403
|
|
response = client.post("/api/settings/bulk-update", json=updates)
|
|
assert response.status_code in [302, 401, 403]
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSettingModels:
|
|
"""Tests for Pydantic models."""
|
|
|
|
def test_setting_update_model(self):
|
|
"""Test SettingUpdate model."""
|
|
from app.api.settings import SettingUpdate
|
|
|
|
update = SettingUpdate(key="test_key", value="test_value")
|
|
assert update.key == "test_key"
|
|
assert update.value == "test_value"
|
|
|
|
def test_setting_update_model_with_none_value(self):
|
|
"""Test SettingUpdate model with None value."""
|
|
from app.api.settings import SettingUpdate
|
|
|
|
update = SettingUpdate(key="test_key", value=None)
|
|
assert update.key == "test_key"
|
|
assert update.value is None
|
|
|
|
def test_setting_value_update_model(self):
|
|
"""Test SettingValueUpdate model (PUT body — no key required)."""
|
|
from app.api.settings import SettingValueUpdate
|
|
|
|
body = SettingValueUpdate(value="test_value")
|
|
assert body.value == "test_value"
|
|
|
|
def test_setting_value_update_model_with_none_value(self):
|
|
"""Test SettingValueUpdate model accepts None value."""
|
|
from app.api.settings import SettingValueUpdate
|
|
|
|
body = SettingValueUpdate(value=None)
|
|
assert body.value is None
|
|
|
|
def test_setting_value_update_model_defaults_to_none(self):
|
|
"""Test SettingValueUpdate model value defaults to None when omitted."""
|
|
from app.api.settings import SettingValueUpdate
|
|
|
|
body = SettingValueUpdate()
|
|
assert body.value is None
|
|
|
|
def test_setting_response_model(self):
|
|
"""Test SettingResponse model."""
|
|
from app.api.settings import SettingResponse
|
|
|
|
response = SettingResponse(
|
|
key="test_key", value="test_value", metadata={"type": "str", "description": "Test setting"}
|
|
)
|
|
assert response.key == "test_key"
|
|
assert response.value == "test_value"
|
|
assert response.metadata["type"] == "str"
|
|
|
|
def test_settings_list_response_model(self):
|
|
"""Test SettingsListResponse model."""
|
|
from app.api.settings import SettingsListResponse
|
|
|
|
response = SettingsListResponse(
|
|
settings={"test_key": {"value": "test_value", "metadata": {}}},
|
|
categories={"General": ["test_key"]},
|
|
db_settings={"test_key": "test_value"},
|
|
)
|
|
assert "test_key" in response.settings
|
|
assert "General" in response.categories
|
|
assert "test_key" in response.db_settings
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestPutSettingEndpoint:
|
|
"""Tests for PUT /api/settings/{key} endpoint."""
|
|
|
|
def test_put_setting_requires_admin(self, client):
|
|
"""Test PUT /settings/{key} requires admin access."""
|
|
response = client.put("/api/settings/social_auth_dropbox_enabled", json={"value": "true"})
|
|
assert response.status_code in [302, 401, 403]
|
|
|
|
@patch("app.api.settings.notify_settings_updated")
|
|
@patch("app.api.settings.get_setting_metadata")
|
|
@patch("app.api.settings.validate_setting_value")
|
|
@patch("app.api.settings.save_setting_to_db")
|
|
def test_put_setting_saves_value(self, mock_save, mock_validate, mock_metadata, mock_notify, client):
|
|
"""Test PUT /settings/{key} saves the value when authenticated as admin."""
|
|
from app.api.settings import require_admin
|
|
from app.main import app as fastapi_app
|
|
|
|
mock_validate.return_value = (True, None)
|
|
mock_save.return_value = True
|
|
mock_metadata.return_value = {"restart_required": True}
|
|
|
|
def override_require_admin():
|
|
return {"id": "admin", "is_admin": True, "preferred_username": "admin"}
|
|
|
|
fastapi_app.dependency_overrides[require_admin] = override_require_admin
|
|
try:
|
|
response = client.put(
|
|
"/api/settings/social_auth_dropbox_enabled",
|
|
json={"value": "true"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["success"] is True
|
|
assert data["key"] == "social_auth_dropbox_enabled"
|
|
assert data["value"] == "true"
|
|
assert data["restart_required"] is True
|
|
finally:
|
|
fastapi_app.dependency_overrides.pop(require_admin, None)
|
|
|
|
@patch("app.api.settings.notify_settings_updated")
|
|
@patch("app.api.settings.get_setting_metadata")
|
|
@patch("app.api.settings.validate_setting_value")
|
|
@patch("app.api.settings.save_setting_to_db")
|
|
def test_put_setting_body_without_key_field_is_accepted(
|
|
self, mock_save, mock_validate, mock_metadata, mock_notify, client
|
|
):
|
|
"""Test PUT /settings/{key} body need not contain a key field."""
|
|
from app.api.settings import require_admin
|
|
from app.main import app as fastapi_app
|
|
|
|
mock_validate.return_value = (True, None)
|
|
mock_save.return_value = True
|
|
mock_metadata.return_value = {"restart_required": False}
|
|
|
|
def override_require_admin():
|
|
return {"id": "admin", "is_admin": True, "preferred_username": "admin"}
|
|
|
|
fastapi_app.dependency_overrides[require_admin] = override_require_admin
|
|
try:
|
|
# Body only contains "value" — no "key" field (mirrors admin_connections.html behaviour)
|
|
response = client.put(
|
|
"/api/settings/social_auth_dropbox_use_global_credentials",
|
|
json={"value": "false"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["success"] is True
|
|
finally:
|
|
fastapi_app.dependency_overrides.pop(require_admin, None)
|
|
|
|
@patch("app.api.settings.validate_setting_value")
|
|
@patch("app.api.settings.get_setting_metadata")
|
|
def test_put_setting_returns_400_on_invalid_value(self, mock_metadata, mock_validate, client):
|
|
"""Test PUT /settings/{key} returns 400 for invalid values."""
|
|
from app.api.settings import require_admin
|
|
from app.main import app as fastapi_app
|
|
|
|
mock_validate.return_value = (False, "Invalid boolean value")
|
|
mock_metadata.return_value = {"restart_required": False}
|
|
|
|
def override_require_admin():
|
|
return {"id": "admin", "is_admin": True}
|
|
|
|
fastapi_app.dependency_overrides[require_admin] = override_require_admin
|
|
try:
|
|
response = client.put(
|
|
"/api/settings/social_auth_dropbox_enabled",
|
|
json={"value": "not_a_bool"},
|
|
)
|
|
assert response.status_code == 400
|
|
finally:
|
|
fastapi_app.dependency_overrides.pop(require_admin, None)
|
|
|
|
"""Tests for the list_credentials function (GET /api/settings/credentials)."""
|
|
|
|
@patch("app.api.settings.get_all_settings_from_db")
|
|
def test_list_credentials_returns_sensitive_keys_only(self, mock_db_settings):
|
|
"""Test that list_credentials only includes keys marked sensitive in SETTING_METADATA."""
|
|
import asyncio
|
|
|
|
from app.api.settings import list_credentials
|
|
from app.utils.settings_service import SETTING_METADATA
|
|
|
|
mock_db_settings.return_value = {}
|
|
mock_request = MagicMock()
|
|
mock_db = MagicMock()
|
|
mock_admin = {"is_admin": True}
|
|
|
|
with patch("app.api.settings.settings") as mock_settings:
|
|
for key in SETTING_METADATA:
|
|
setattr(mock_settings, key, None)
|
|
|
|
result = asyncio.run(list_credentials(mock_request, mock_db, mock_admin))
|
|
|
|
returned_keys = {c["key"] for c in result["credentials"]}
|
|
sensitive_keys = {k for k, v in SETTING_METADATA.items() if v.get("sensitive")}
|
|
assert returned_keys == sensitive_keys
|
|
|
|
@patch("app.api.settings.get_all_settings_from_db")
|
|
def test_list_credentials_source_db_when_in_database(self, mock_db_settings):
|
|
"""Test that credentials stored in the database report source='db'."""
|
|
import asyncio
|
|
|
|
from app.api.settings import list_credentials
|
|
|
|
mock_db_settings.return_value = {"openai_api_key": "sk-db-key"}
|
|
mock_request = MagicMock()
|
|
mock_db = MagicMock()
|
|
mock_admin = {"is_admin": True}
|
|
|
|
with patch("app.api.settings.settings") as mock_settings:
|
|
mock_settings.openai_api_key = "sk-env-key"
|
|
|
|
result = asyncio.run(list_credentials(mock_request, mock_db, mock_admin))
|
|
|
|
openai_entry = next(c for c in result["credentials"] if c["key"] == "openai_api_key")
|
|
assert openai_entry["source"] == "db"
|
|
assert openai_entry["configured"] is True
|
|
|
|
@patch("app.api.settings.get_all_settings_from_db")
|
|
def test_list_credentials_source_env_when_only_in_env(self, mock_db_settings):
|
|
"""Test that credentials only in env report source='env'."""
|
|
import asyncio
|
|
|
|
from app.api.settings import list_credentials
|
|
|
|
mock_db_settings.return_value = {}
|
|
mock_request = MagicMock()
|
|
mock_db = MagicMock()
|
|
mock_admin = {"is_admin": True}
|
|
|
|
with patch("app.api.settings.settings") as mock_settings:
|
|
mock_settings.openai_api_key = "sk-env-key"
|
|
|
|
result = asyncio.run(list_credentials(mock_request, mock_db, mock_admin))
|
|
|
|
openai_entry = next(c for c in result["credentials"] if c["key"] == "openai_api_key")
|
|
assert openai_entry["source"] == "env"
|
|
assert openai_entry["configured"] is True
|
|
|
|
@patch("app.api.settings.get_all_settings_from_db")
|
|
def test_list_credentials_unconfigured_when_no_value(self, mock_db_settings):
|
|
"""Test that credentials with no value are marked as unconfigured."""
|
|
import asyncio
|
|
|
|
from app.api.settings import list_credentials
|
|
|
|
mock_db_settings.return_value = {}
|
|
mock_request = MagicMock()
|
|
mock_db = MagicMock()
|
|
mock_admin = {"is_admin": True}
|
|
|
|
with patch("app.api.settings.settings") as mock_settings:
|
|
mock_settings.openai_api_key = None
|
|
|
|
result = asyncio.run(list_credentials(mock_request, mock_db, mock_admin))
|
|
|
|
openai_entry = next(c for c in result["credentials"] if c["key"] == "openai_api_key")
|
|
assert openai_entry["configured"] is False
|
|
assert openai_entry["source"] is None
|
|
|
|
@patch("app.api.settings.get_all_settings_from_db")
|
|
def test_list_credentials_counts_are_accurate(self, mock_db_settings):
|
|
"""Test that configured_count and unconfigured_count are correct."""
|
|
import asyncio
|
|
|
|
from app.api.settings import list_credentials
|
|
|
|
mock_db_settings.return_value = {"openai_api_key": "sk-db-key"}
|
|
mock_request = MagicMock()
|
|
mock_db = MagicMock()
|
|
mock_admin = {"is_admin": True}
|
|
|
|
with patch("app.api.settings.settings") as mock_settings:
|
|
# Most keys will be None, one will be set via db_settings mock
|
|
mock_settings.openai_api_key = "sk-key"
|
|
|
|
result = asyncio.run(list_credentials(mock_request, mock_db, mock_admin))
|
|
|
|
assert result["total"] == len(result["credentials"])
|
|
assert result["configured_count"] + result["unconfigured_count"] == result["total"]
|
|
|
|
@patch("app.api.settings.get_all_settings_from_db")
|
|
def test_list_credentials_raises_500_on_exception(self, mock_db_settings):
|
|
"""Test that list_credentials raises HTTP 500 on unexpected errors."""
|
|
import asyncio
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from app.api.settings import list_credentials
|
|
|
|
mock_db_settings.side_effect = Exception("DB failure")
|
|
mock_request = MagicMock()
|
|
mock_db = MagicMock()
|
|
mock_admin = {"is_admin": True}
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(list_credentials(mock_request, mock_db, mock_admin))
|
|
assert exc_info.value.status_code == 500
|
|
|
|
def test_list_credentials_endpoint_requires_admin(self, client):
|
|
"""Test that GET /api/settings/credentials requires admin access."""
|
|
response = client.get("/api/settings/credentials")
|
|
assert response.status_code in [302, 401, 403]
|
|
|
|
@patch("app.api.settings.get_all_settings_from_db")
|
|
def test_list_credentials_response_has_required_fields(self, mock_db_settings):
|
|
"""Test that each credential entry has the required fields."""
|
|
import asyncio
|
|
|
|
from app.api.settings import list_credentials
|
|
|
|
mock_db_settings.return_value = {}
|
|
mock_request = MagicMock()
|
|
mock_db = MagicMock()
|
|
mock_admin = {"is_admin": True}
|
|
|
|
with patch("app.api.settings.settings") as mock_settings:
|
|
mock_settings.openai_api_key = None
|
|
|
|
result = asyncio.run(list_credentials(mock_request, mock_db, mock_admin))
|
|
|
|
for cred in result["credentials"]:
|
|
assert "key" in cred
|
|
assert "category" in cred
|
|
assert "description" in cred
|
|
assert "configured" in cred
|
|
assert "source" in cred
|
|
assert "restart_required" in cred
|