Merge branch 'main' into code-health/simplify-ui-upload-12009112042086709661

This commit is contained in:
Christian Krakau-Louis
2026-03-16 12:14:11 +01:00
committed by GitHub
50 changed files with 2792 additions and 415 deletions
+18
View File
@@ -7,6 +7,24 @@ GOTENBERG_URL=http://gotenberg:3000
ALLOW_FILE_DELETE=true # Allow deletion of file records ALLOW_FILE_DELETE=true # Allow deletion of file records
COMPLIANCE_ENABLED=true # Enable compliance templates dashboard (GDPR, HIPAA, SOC 2) COMPLIANCE_ENABLED=true # Enable compliance templates dashboard (GDPR, HIPAA, SOC 2)
# **Logging**
# LOG_LEVEL controls the Python root-logger level.
# Accepted values: DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO).
# When DEBUG=true and LOG_LEVEL is not set, the level is automatically lowered to DEBUG.
# LOG_LEVEL=INFO
# DEBUG=false
# Log output format: "text" (human-readable, default) or "json" (structured JSON lines).
# Use "json" when shipping logs to Grafana Loki, Splunk, ELK, Datadog, or any SIEM.
# LOG_FORMAT=text
# Forward application logs to a syslog receiver (in addition to stdout).
# Useful for traditional (non-container) deployments and centralised SIEM ingestion.
# LOG_SYSLOG_ENABLED=false
# LOG_SYSLOG_HOST=localhost
# LOG_SYSLOG_PORT=514
# LOG_SYSLOG_PROTOCOL=udp # udp | tcp
# **UI / Appearance** # **UI / Appearance**
# Default colour scheme: system (follow OS), light, or dark # Default colour scheme: system (follow OS), light, or dark
# Individual users can always override with the navbar dark-mode toggle. # Individual users can always override with the navbar dark-mode toggle.
+1 -1
View File
@@ -1 +1 @@
2026-03-15T21:39:26Z 2026-03-16T11:12:55Z
+242
View File
@@ -10,6 +10,248 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- version list --> <!-- version list -->
## v0.147.2 (2026-03-16)
### Code Style
- Apply ruff auto-fix
([`46c4031`](https://github.com/christianlouis/DocuElevate/commit/46c403127641c1b5c729ff69ab5505efa5c9b54d))
### Documentation
- **changelog**: Update changelog [skip ci]
([`66fdb11`](https://github.com/christianlouis/DocuElevate/commit/66fdb11e39bc63f5a1d2b652649fd39d2a7e7469))
- **changelog**: Update changelog [skip ci]
([`0f31216`](https://github.com/christianlouis/DocuElevate/commit/0f312160bcb2e46e29c90dd055c4ebc9aaf01ad4))
- **changelog**: Update changelog [skip ci]
([`5734df2`](https://github.com/christianlouis/DocuElevate/commit/5734df2d5046158a23b987fdc4235d3f3f6b4042))
## Unreleased
### Code Style
- Apply ruff auto-fix
([`46c4031`](https://github.com/christianlouis/DocuElevate/commit/46c403127641c1b5c729ff69ab5505efa5c9b54d))
### Documentation
- **changelog**: Update changelog [skip ci]
([`0f31216`](https://github.com/christianlouis/DocuElevate/commit/0f312160bcb2e46e29c90dd055c4ebc9aaf01ad4))
- **changelog**: Update changelog [skip ci]
([`5734df2`](https://github.com/christianlouis/DocuElevate/commit/5734df2d5046158a23b987fdc4235d3f3f6b4042))
## Unreleased
### Code Style
- Apply ruff auto-fix
([`46c4031`](https://github.com/christianlouis/DocuElevate/commit/46c403127641c1b5c729ff69ab5505efa5c9b54d))
### Documentation
- **changelog**: Update changelog [skip ci]
([`5734df2`](https://github.com/christianlouis/DocuElevate/commit/5734df2d5046158a23b987fdc4235d3f3f6b4042))
## Unreleased
## v0.147.1 (2026-03-16)
### Bug Fixes
- Resolve test failures and mypy errors on main
([`c7ff177`](https://github.com/christianlouis/DocuElevate/commit/c7ff177e179b60a4b6adba7f5296522e6da4391f))
### Documentation
- **changelog**: Update changelog [skip ci]
([`dd7c8f0`](https://github.com/christianlouis/DocuElevate/commit/dd7c8f0342f00b80e83394d146e6bc51a3688687))
- **changelog**: Update changelog [skip ci]
([`4d4706e`](https://github.com/christianlouis/DocuElevate/commit/4d4706e078d661682326262000e70d7e56fb6d23))
## Unreleased
### Documentation
- **changelog**: Update changelog [skip ci]
([`4d4706e`](https://github.com/christianlouis/DocuElevate/commit/4d4706e078d661682326262000e70d7e56fb6d23))
## Unreleased
## v0.147.0 (2026-03-16)
### Code Style
- Apply ruff auto-fix
([`80a0ddc`](https://github.com/christianlouis/DocuElevate/commit/80a0ddcbfcd89c156d6cce65bb6275bdf35c061e))
### Documentation
- **changelog**: Update changelog [skip ci]
([`5e986cb`](https://github.com/christianlouis/DocuElevate/commit/5e986cb6d66c313753c7ce9cf69137c36b354ae8))
- **changelog**: Update changelog [skip ci]
([`8f8e11c`](https://github.com/christianlouis/DocuElevate/commit/8f8e11cffe5a6aaae47c9111bc6a95cb88ef6cd8))
### Features
- **config**: Add JSON structured logging and syslog forwarding for application logs
([`6da1e6f`](https://github.com/christianlouis/DocuElevate/commit/6da1e6fd816fea1dc0fa37c9ad42ce8cafed8c73))
- **config**: Add LOG_LEVEL setting and configure root logging at startup
([`18c49c6`](https://github.com/christianlouis/DocuElevate/commit/18c49c6b2d214fddefa2f954d57b0358fb4b5f72))
### Refactoring
- **main**: Move JSON formatter imports to module level per code review
([`df1fa51`](https://github.com/christianlouis/DocuElevate/commit/df1fa51800fb62862271fce4e0fff691f62c3333))
### Testing
- Add 500 error test for saved search deletion
([`ae9ed6e`](https://github.com/christianlouis/DocuElevate/commit/ae9ed6e9a703f8a80880d0b1cb421de3cf024cd3))
## Unreleased
### Code Style
- Apply ruff auto-fix
([`80a0ddc`](https://github.com/christianlouis/DocuElevate/commit/80a0ddcbfcd89c156d6cce65bb6275bdf35c061e))
### Documentation
- **changelog**: Update changelog [skip ci]
([`8f8e11c`](https://github.com/christianlouis/DocuElevate/commit/8f8e11cffe5a6aaae47c9111bc6a95cb88ef6cd8))
### Testing
- Add 500 error test for saved search deletion
([`ae9ed6e`](https://github.com/christianlouis/DocuElevate/commit/ae9ed6e9a703f8a80880d0b1cb421de3cf024cd3))
## Unreleased
### Code Style
- Apply ruff auto-fix
([`80a0ddc`](https://github.com/christianlouis/DocuElevate/commit/80a0ddcbfcd89c156d6cce65bb6275bdf35c061e))
### Testing
- Add 500 error test for saved search deletion
([`ae9ed6e`](https://github.com/christianlouis/DocuElevate/commit/ae9ed6e9a703f8a80880d0b1cb421de3cf024cd3))
## v0.146.0 (2026-03-16)
### Code Style
- Apply ruff auto-fix
([`522cefa`](https://github.com/christianlouis/DocuElevate/commit/522cefad935508fed32e194984726b0a10c99654))
- Apply ruff auto-fix
([`c76e513`](https://github.com/christianlouis/DocuElevate/commit/c76e51391b14eccbbe74f34ffadc89a477b58a64))
- Apply ruff auto-fix
([`cc2a07b`](https://github.com/christianlouis/DocuElevate/commit/cc2a07b090883f3cf2affea8a5e254a4163ce966))
- Apply ruff auto-fix
([`8bb6457`](https://github.com/christianlouis/DocuElevate/commit/8bb6457c65c177a13fd15de7a7b55e24eb39477e))
- Apply ruff auto-fix
([`275a5ad`](https://github.com/christianlouis/DocuElevate/commit/275a5ad6fa887ce3aa05d50787bd5da435735f26))
- Apply ruff auto-fix
([`ac35c5e`](https://github.com/christianlouis/DocuElevate/commit/ac35c5e6fa84f2b38cd333de75f202f13bf4c461))
- Apply ruff auto-fix
([`705c801`](https://github.com/christianlouis/DocuElevate/commit/705c801158394e7d6466f82485f8d872860653bb))
- Apply ruff auto-fix
([`0425d46`](https://github.com/christianlouis/DocuElevate/commit/0425d46c4440191cd410434ba64c1bc9cdb53cbb))
- Apply ruff auto-fix
([`b4118f6`](https://github.com/christianlouis/DocuElevate/commit/b4118f61622a35461af2c220d8a948ecb795e65b))
- Format app/database.py to fix CI failure
([`fbd4f83`](https://github.com/christianlouis/DocuElevate/commit/fbd4f837301deb208c7303553bca8facbae85ba6))
### Features
- Extract embedded PDF metadata using pypdf in upload_to_email
([`726e4df`](https://github.com/christianlouis/DocuElevate/commit/726e4dfdc47507dd08047a27f02c137ed3e7ecaf))
- Extract embedded PDF metadata using pypdf in upload_to_email
([`df64aec`](https://github.com/christianlouis/DocuElevate/commit/df64aece2c19215cf762b2ac62d476888cd21527))
### Performance Improvements
- Optimize dropbox token refresh by replacing blocking requests with httpx
([`84c6e1c`](https://github.com/christianlouis/DocuElevate/commit/84c6e1c5dd1a427c7f015e7461df59b889e66154))
- **api**: Optimize reorder_plans to prevent N+1 queries
([`d8372c6`](https://github.com/christianlouis/DocuElevate/commit/d8372c6fb83b09ce8d61bc8a870c921372a9d27a))
- **duplicates**: Fix N+1 query in group listing
([`e4e3ac4`](https://github.com/christianlouis/DocuElevate/commit/e4e3ac40771bdd8459cc0876d9d49be441df51a4))
### Testing
- Add missing error tests for updating saved searches
([`d18c05c`](https://github.com/christianlouis/DocuElevate/commit/d18c05c36dc4324dd24e20e5344bca9c038bbf73))
- Improve coverage for notify_settings_updated error handling
([`d8906ae`](https://github.com/christianlouis/DocuElevate/commit/d8906aece0045aed9dcd0f0cf1891970c7cb00e8))
## v0.145.3 (2026-03-16)
### Bug Fixes
- **tests**: Resolve ruff import sorting issue in benchmark test
([`fa9b037`](https://github.com/christianlouis/DocuElevate/commit/fa9b037d5a2a67f9115a1bddf0f98ba9020cefd6))
### Code Style
- Apply ruff auto-fix
([`c1657a0`](https://github.com/christianlouis/DocuElevate/commit/c1657a01a77ca6b914bc21a20a42aeb924de8254))
- Apply ruff auto-fix
([`6f5f4d9`](https://github.com/christianlouis/DocuElevate/commit/6f5f4d9d4948208f65fc5572c86a08642efe54b6))
- Apply ruff auto-fix
([`2cfbea2`](https://github.com/christianlouis/DocuElevate/commit/2cfbea29a9211abfc13b74dea0a841fe5a2546b9))
### Documentation
- **changelog**: Update changelog [skip ci]
([`fa36ec6`](https://github.com/christianlouis/DocuElevate/commit/fa36ec69876b919a33a7471bb837c2a6b69c2a50))
### Performance Improvements
- **api**: Fix n+1 query issue in user notification preferences update
([`fe20e02`](https://github.com/christianlouis/DocuElevate/commit/fe20e02f78c3c6236b07f50edb66599b8a9c7ed2))
## Unreleased
### Code Style
- Apply ruff auto-fix
([`2cfbea2`](https://github.com/christianlouis/DocuElevate/commit/2cfbea29a9211abfc13b74dea0a841fe5a2546b9))
## v0.145.2 (2026-03-15) ## v0.145.2 (2026-03-15)
### Bug Fixes ### Bug Fixes
+1 -1
View File
@@ -1 +1 @@
237af31 6bb695b
+6 -6
View File
@@ -1,10 +1,10 @@
DocuElevate Build Information DocuElevate Build Information
============================== ==============================
Version: 0.145.2 Version: 0.147.2
Build Date: 2026-03-15T21:39:26Z Build Date: 2026-03-16T11:12:55Z
Git Commit: 237af31f5fe598cfc3c08f2bbba79b3d0925787e Git Commit: 6bb695b2ae239a35090fc6ab3c36cfe804b44a8d
Git Short SHA: 237af31 Git Short SHA: 6bb695b
Git Branch: main Git Branch: main
Commit Date: 2026-03-15T22:39:04+01:00 Commit Date: 2026-03-16T12:12:32+01:00
Build Timestamp: 2026-03-15T21:39:26Z Build Timestamp: 2026-03-16T11:12:55Z
============================== ==============================
+1 -1
View File
@@ -1 +1 @@
0.145.2 0.147.2
+6 -4
View File
@@ -20,14 +20,16 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
DbSession = Annotated[Session, Depends(get_db)] # Module-level dependency singleton to satisfy Ruff B008 while maintaining default values for manual calls (e.g. in decorators).
_db_dep = Depends(get_db)
DbSession = Annotated[Session, _db_dep]
@router.get("/audit-logs") @router.get("/audit-logs")
@require_login @require_login
async def list_audit_logs( async def list_audit_logs(
request: Request, request: Request,
db: DbSession, db: DbSession = _db_dep,
action: Annotated[str | None, Query(description="Filter by action (exact match)")] = None, action: Annotated[str | None, Query(description="Filter by action (exact match)")] = None,
user: Annotated[str | None, Query(description="Filter by username")] = None, user: Annotated[str | None, Query(description="Filter by username")] = None,
resource_type: Annotated[str | None, Query(description="Filter by resource type")] = None, resource_type: Annotated[str | None, Query(description="Filter by resource type")] = None,
@@ -73,7 +75,7 @@ async def list_audit_logs(
@require_login @require_login
async def list_distinct_actions( async def list_distinct_actions(
request: Request, request: Request,
db: DbSession, db: DbSession = _db_dep,
) -> list[str]: ) -> list[str]:
"""Return the distinct action values present in the audit log.""" """Return the distinct action values present in the audit log."""
from app.models import AuditLog from app.models import AuditLog
@@ -86,7 +88,7 @@ async def list_distinct_actions(
@require_login @require_login
async def list_distinct_users( async def list_distinct_users(
request: Request, request: Request,
db: DbSession, db: DbSession = _db_dep,
) -> list[str]: ) -> list[str]:
"""Return the distinct user values present in the audit log.""" """Return the distinct user values present in the audit log."""
from app.models import AuditLog from app.models import AuditLog
+49 -46
View File
@@ -6,7 +6,7 @@ import logging
import os import os
from typing import Annotated, Optional from typing import Annotated, Optional
import requests import httpx
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -132,57 +132,60 @@ async def test_dropbox_token(request: Request):
"message": "Dropbox credentials are not fully configured", "message": "Dropbox credentials are not fully configured",
} }
# Check token validity by getting current account info async with httpx.AsyncClient() as client:
headers = {"Authorization": f"Bearer {settings.dropbox_refresh_token}"} # Check token validity by getting current account info
response = requests.post( headers = {"Authorization": f"Bearer {settings.dropbox_refresh_token}"}
"https://api.dropboxapi.com/2/users/get_current_account", response = await client.post(
headers=headers,
timeout=settings.http_request_timeout,
)
# If token is invalid, try refreshing it
if response.status_code == 401:
logger.info("Dropbox access token invalid or expired, trying to refresh")
# Get a new access token using the refresh token
refresh_url = "https://api.dropbox.com/oauth2/token"
refresh_data = {
"grant_type": "refresh_token",
"refresh_token": settings.dropbox_refresh_token,
"client_id": settings.dropbox_app_key,
"client_secret": settings.dropbox_app_secret,
}
refresh_response = requests.post(refresh_url, data=refresh_data, timeout=settings.http_request_timeout)
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,
}
token_info = refresh_response.json()
access_token = token_info.get("access_token")
# Try again with the new access token
headers = {"Authorization": f"Bearer {access_token}"}
response = requests.post(
"https://api.dropboxapi.com/2/users/get_current_account", "https://api.dropboxapi.com/2/users/get_current_account",
headers=headers, headers=headers,
timeout=settings.http_request_timeout, timeout=settings.http_request_timeout,
) )
if response.status_code != 200: # If token is invalid, try refreshing it
logger.error(f"Dropbox token test failed: {response.status_code} {response.text}") if response.status_code == 401:
return { logger.info("Dropbox access token invalid or expired, trying to refresh")
"status": "error",
"message": f"Token validation failed with status {response.status_code}: {response.text}",
}
# Get account info # Get a new access token using the refresh token
account_info = response.json() refresh_url = "https://api.dropbox.com/oauth2/token"
refresh_data = {
"grant_type": "refresh_token",
"refresh_token": settings.dropbox_refresh_token,
"client_id": settings.dropbox_app_key,
"client_secret": settings.dropbox_app_secret,
}
refresh_response = await client.post(
refresh_url, data=refresh_data, timeout=settings.http_request_timeout
)
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,
}
token_info = refresh_response.json()
access_token = token_info.get("access_token")
# Try again with the new access token
headers = {"Authorization": f"Bearer {access_token}"}
response = await client.post(
"https://api.dropboxapi.com/2/users/get_current_account",
headers=headers,
timeout=settings.http_request_timeout,
)
if response.status_code != 200:
logger.error(f"Dropbox token test failed: {response.status_code} {response.text}")
return {
"status": "error",
"message": f"Token validation failed with status {response.status_code}: {response.text}",
}
# Get account info
account_info = response.json()
account_email = account_info.get("email", "Unknown account") account_email = account_info.get("email", "Unknown account")
account_name = account_info.get("name", {}).get("display_name", "Unknown user") account_name = account_info.get("name", {}).get("display_name", "Unknown user")
+28 -23
View File
@@ -73,33 +73,38 @@ def list_duplicate_groups(
groups = [] groups = []
total_duplicate_files = 0 total_duplicate_files = 0
for filehash in dup_hashes: if dup_hashes:
# Find the original (non-duplicate) record with this hash # Fetch all matching files (both original and duplicates) in a single batch query
original = ( all_records = (
db.query(FileRecord) db.query(FileRecord).filter(FileRecord.filehash.in_(dup_hashes)).order_by(FileRecord.id.asc()).all()
.filter(FileRecord.filehash == filehash, FileRecord.is_duplicate.is_(False))
.order_by(FileRecord.id.asc())
.first()
) )
# Find all duplicate records for this hash # Group records by hash
duplicates = ( originals_by_hash = {}
db.query(FileRecord) duplicates_by_hash = {h: [] for h in dup_hashes}
.filter(FileRecord.filehash == filehash, FileRecord.is_duplicate.is_(True))
.order_by(FileRecord.id.asc())
.all()
)
total_duplicate_files += len(duplicates) for record in all_records:
h = record.filehash
if not record.is_duplicate:
# Store only the first original record per hash, matching the old .first() behaviour
if h not in originals_by_hash:
originals_by_hash[h] = record
else:
duplicates_by_hash[h].append(record)
total_duplicate_files += 1
groups.append( for filehash in dup_hashes:
{ original = originals_by_hash.get(filehash)
"filehash": filehash, duplicates = duplicates_by_hash.get(filehash, [])
"original": _file_record_to_dict(original) if original else None,
"duplicates": [_file_record_to_dict(d) for d in duplicates], groups.append(
"duplicate_count": len(duplicates), {
} "filehash": filehash,
) "original": _file_record_to_dict(original) if original else None,
"duplicates": [_file_record_to_dict(d) for d in duplicates],
"duplicate_count": len(duplicates),
}
)
total_pages = (total_groups + per_page - 1) // per_page if total_groups > 0 else 1 total_pages = (total_groups + per_page - 1) // per_page if total_groups > 0 else 1
+26 -1
View File
@@ -11,6 +11,7 @@ import zipfile
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Annotated, List, Optional from typing import Annotated, List, Optional
import aiofiles
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy import asc, desc from sqlalchemy import asc, desc
@@ -1335,7 +1336,31 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
# Read file in chunks to avoid loading the entire body into memory at once, # Read file in chunks to avoid loading the entire body into memory at once,
# enforcing the size limit during the read so memory usage stays bounded. # enforcing the size limit during the read so memory usage stays bounded.
written_size = await _save_upload_file_chunks(file, target_path, max_size) try:
written_size = 0
async with aiofiles.open(target_path, "wb") as f:
chunk_size = 65536 # 64 KB chunks
while True:
chunk = await file.read(chunk_size)
if not chunk:
break
written_size += len(chunk)
if written_size > max_size:
# Exceeded limit mid-stream; clean up and reject
await f.close()
os.remove(target_path)
raise HTTPException(
status_code=413,
detail=f"File too large: exceeded {max_size} bytes during upload. "
f"See SECURITY_AUDIT.md for configuration details.",
)
await f.write(chunk)
except HTTPException:
raise
except Exception as e:
if os.path.exists(target_path):
os.remove(target_path)
raise HTTPException(status_code=500, detail=f"Failed to save file: {e}")
# Log the mapping between original and safe filename # Log the mapping between original and safe filename
logger.info(f"Saved uploaded file '{safe_filename}' as '{target_filename}'") logger.info(f"Saved uploaded file '{safe_filename}' as '{target_filename}'")
+1 -1
View File
@@ -363,7 +363,7 @@ def format_time_remaining(time_delta):
@router.post("/google-drive/save-settings") @router.post("/google-drive/save-settings")
@require_login @require_login
async def save_dropbox_settings( async def save_google_drive_settings(
request: Request, request: Request,
refresh_token: Annotated[str, Form(...)], refresh_token: Annotated[str, Form(...)],
client_id: Annotated[Optional[str], Form()] = None, client_id: Annotated[Optional[str], Form()] = None,
+9 -10
View File
@@ -452,17 +452,16 @@ async def update_preferences(
) )
try: try:
# Pre-fetch existing preferences for this user to avoid N+1 queries
existing_prefs = (
db.query(UserNotificationPreference).filter(UserNotificationPreference.owner_id == owner_id).all()
)
# Build a fast lookup dictionary keyed by (event_type, channel_type, target_id)
prefs_dict = {(pref.event_type, pref.channel_type, pref.target_id): pref for pref in existing_prefs}
for item in body.preferences: for item in body.preferences:
existing = ( existing = prefs_dict.get((item.event_type, item.channel_type, item.target_id))
db.query(UserNotificationPreference)
.filter(
UserNotificationPreference.owner_id == owner_id,
UserNotificationPreference.event_type == item.event_type,
UserNotificationPreference.channel_type == item.channel_type,
UserNotificationPreference.target_id == item.target_id,
)
.first()
)
if existing: if existing:
existing.is_enabled = item.is_enabled existing.is_enabled = item.is_enabled
else: else:
+19 -93
View File
@@ -3,7 +3,6 @@ OneDrive API endpoints
""" """
import logging import logging
import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Annotated, Optional from typing import Annotated, Optional
@@ -14,6 +13,7 @@ from sqlalchemy.orm import Session
from app.auth import require_login from app.auth import require_login
from app.config import settings from app.config import settings
from app.database import get_db from app.database import get_db
from app.utils.env_utils import update_env_file
from app.utils.oauth_helper import exchange_oauth_token from app.utils.oauth_helper import exchange_oauth_token
from app.utils.settings_service import save_setting_to_db from app.utils.settings_service import save_setting_to_db
from app.utils.settings_sync import notify_settings_updated from app.utils.settings_sync import notify_settings_updated
@@ -115,32 +115,7 @@ async def test_onedrive_token(request: Request):
settings.onedrive_refresh_token = new_refresh_token settings.onedrive_refresh_token = new_refresh_token
# Also try to update .env file if it exists # Also try to update .env file if it exists
try: update_env_file({"ONEDRIVE_REFRESH_TOKEN": new_refresh_token})
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
if os.path.exists(env_path):
with open(env_path, "r") as f:
env_lines = f.readlines()
updated_lines = []
updated = False
for line in env_lines:
if line.startswith("ONEDRIVE_REFRESH_TOKEN="):
updated_lines.append(f"ONEDRIVE_REFRESH_TOKEN={new_refresh_token}\n")
updated = True
else:
updated_lines.append(line)
if not updated:
updated_lines.append(f"ONEDRIVE_REFRESH_TOKEN={new_refresh_token}\n")
with open(env_path, "w") as f:
f.writelines(updated_lines)
logger.info("Updated refresh token in .env file")
except Exception as e:
logger.warning(f"Failed to update refresh token in .env file: {e}")
# Persist the rotated refresh token to the database # Persist the rotated refresh token to the database
try: try:
@@ -246,75 +221,26 @@ async def save_onedrive_settings(
user.get("preferred_username") or user.get("username") or user.get("email") or user.get("id") or "wizard" user.get("preferred_username") or user.get("username") or user.get("email") or user.get("id") or "wizard"
) )
# Best-effort .env file write # Build settings dictionary mapped to database/memory keys
try: onedrive_settings = {
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env") "onedrive_refresh_token": refresh_token,
if not os.path.exists(env_path): "onedrive_client_id": client_id,
logger.warning(f".env file not found at {env_path}, skipping file write") "onedrive_client_secret": client_secret,
else: "onedrive_tenant_id": tenant_id,
logger.info(f"Updating OneDrive settings in {env_path}") "onedrive_folder_path": folder_path,
}
with open(env_path, "r") as f: # Filter out None values
env_lines = f.readlines() onedrive_settings = {k: v for k, v in onedrive_settings.items() if v is not None}
onedrive_settings = {"ONEDRIVE_REFRESH_TOKEN": refresh_token} # Best-effort .env file write using the new utility
if client_id: env_settings = {k.upper(): v for k, v in onedrive_settings.items()}
onedrive_settings["ONEDRIVE_CLIENT_ID"] = client_id update_env_file(env_settings)
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
updated = set() # Update in-memory settings and persist to database dynamically
new_env_lines = [] for key, value in onedrive_settings.items():
for line in env_lines: setattr(settings, key, value)
stripped_line = line.rstrip() save_setting_to_db(db, key, value, changed_by=changed_by)
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)
for key, value in onedrive_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 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:
settings.onedrive_refresh_token = refresh_token
if client_id:
settings.onedrive_client_id = client_id
if client_secret:
settings.onedrive_client_secret = client_secret
if tenant_id:
settings.onedrive_tenant_id = tenant_id
if folder_path:
settings.onedrive_folder_path = folder_path
# 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() notify_settings_updated()
+11 -2
View File
@@ -196,11 +196,20 @@ def seed_plans(db: DbSession, _admin: AdminUser) -> dict[str, Any]:
def reorder_plans(body: ReorderBody, db: DbSession, _admin: AdminUser) -> dict[str, Any]: def reorder_plans(body: ReorderBody, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Update sort_order for each plan_id in *body.order* (position = index in list).""" """Update sort_order for each plan_id in *body.order* (position = index in list)."""
updated = 0 updated = 0
for sort_order, plan_id in enumerate(body.order):
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first() # Fetch all requested plans in a single query to avoid N+1
plan_ids = body.order
plans = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id.in_(plan_ids)).all()
# Build a map for fast O(1) lookup
plan_map = {p.plan_id: p for p in plans}
for sort_order, plan_id in enumerate(plan_ids):
plan = plan_map.get(plan_id)
if plan: if plan:
plan.sort_order = sort_order plan.sort_order = sort_order
updated += 1 updated += 1
try: try:
db.commit() db.commit()
except Exception: except Exception:
+7 -5
View File
@@ -313,16 +313,18 @@ async def list_shared_links(
active_only: bool = Query(False, description="When true, only return active (non-revoked) links"), active_only: bool = Query(False, description="When true, only return active (non-revoked) links"),
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""List all shared links created by the authenticated user.""" """List all shared links created by the authenticated user."""
q = db.query(SharedLink).filter(SharedLink.owner_id == owner_id) q = (
db.query(SharedLink, FileRecord.original_filename)
.outerjoin(FileRecord, SharedLink.file_id == FileRecord.id)
.filter(SharedLink.owner_id == owner_id)
)
if active_only: if active_only:
q = q.filter(SharedLink.is_active.is_(True)) q = q.filter(SharedLink.is_active.is_(True))
links = q.order_by(SharedLink.created_at.desc()).all() links_with_filenames = q.order_by(SharedLink.created_at.desc()).all()
base_url = str(request.base_url).rstrip("/") base_url = str(request.base_url).rstrip("/")
result = [] result = []
for link in links: for link, filename in links_with_filenames:
file_record = db.query(FileRecord).filter(FileRecord.id == link.file_id).first()
filename = file_record.original_filename if file_record else None
result.append(_link_to_dict(link, base_url, filename)) result.append(_link_to_dict(link, base_url, filename))
return result return result
+63 -56
View File
@@ -9,7 +9,8 @@ import urllib.parse
import uuid import uuid
from typing import Optional from typing import Optional
import requests import aiofiles
import httpx
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, HttpUrl, field_validator from pydantic import BaseModel, HttpUrl, field_validator
@@ -153,67 +154,73 @@ async def process_url(request: Request, url_request: URLUploadRequest):
logger.info(f"Downloading file from URL: {url}") logger.info(f"Downloading file from URL: {url}")
# Use configured timeout to prevent hanging # Use configured timeout to prevent hanging
response = requests.get( async with httpx.AsyncClient(
url,
timeout=settings.http_request_timeout, timeout=settings.http_request_timeout,
stream=True, # Stream to handle large files follow_redirects=True,
allow_redirects=True, # Follow redirects
headers={ headers={
"User-Agent": "DocuElevate/1.0", # Identify ourselves "User-Agent": "DocuElevate/1.0", # Identify ourselves
}, },
) ) as client:
response.raise_for_status() async with client.stream("GET", url) as response:
response.raise_for_status()
# Validate content type # Validate content type
content_type = response.headers.get("Content-Type", "") content_type = response.headers.get("Content-Type", "")
if not validate_file_type(content_type, safe_filename): if not validate_file_type(content_type, safe_filename):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Unsupported file type: {content_type}. " detail=f"Unsupported file type: {content_type}. "
"Supported types: PDF, Office documents, images, plain text", "Supported types: PDF, Office documents, images, plain text",
) )
# Check content length before downloading # Check content length before downloading
content_length = response.headers.get("Content-Length") content_length = response.headers.get("Content-Length")
if content_length: if content_length:
file_size = int(content_length) file_size = int(content_length)
max_size = settings.max_upload_size max_size = settings.max_upload_size
if file_size > max_size: if file_size > max_size:
raise HTTPException(
status_code=413,
detail=f"File too large: {file_size} bytes (max {max_size} bytes)",
)
# Generate unique filename
unique_id = str(uuid.uuid4())
if "." in safe_filename:
file_extension = safe_filename.rsplit(".", 1)[1]
target_filename = f"{unique_id}.{file_extension}"
else:
target_filename = unique_id
target_path = os.path.join(settings.workdir, target_filename)
# Download file in chunks to handle large files
downloaded_size = 0
max_size = settings.max_upload_size
with open(target_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
downloaded_size += len(chunk)
# Check size during download
if downloaded_size > max_size:
# Remove partial file
f.close()
os.remove(target_path)
raise HTTPException( raise HTTPException(
status_code=413, status_code=413,
detail=f"File too large: exceeded {max_size} bytes during download", detail=f"File too large: {file_size} bytes (max {max_size} bytes)",
) )
# Generate unique filename
unique_id = str(uuid.uuid4())
# Check for extension using original_filename to avoid any CodeQL issues
# with safe_filename which is derived from the URL directly.
if "." in original_filename:
_, ext = os.path.splitext(original_filename)
# Strip out the leading dot and any non-alphanumeric chars
clean_ext = "".join(c for c in ext if c.isalnum())
if not clean_ext:
clean_ext = "bin"
target_filename = f"{unique_id}.{clean_ext}"
else:
target_filename = unique_id
target_path = os.path.join(settings.workdir, target_filename)
# Download file in chunks to handle large files
downloaded_size = 0
max_size = settings.max_upload_size
async with aiofiles.open(target_path, "wb") as f:
async for chunk in response.aiter_bytes(chunk_size=8192):
if chunk:
await f.write(chunk)
downloaded_size += len(chunk)
# Check size during download
if downloaded_size > max_size:
# Remove partial file
await f.close()
os.remove(target_path)
raise HTTPException(
status_code=413,
detail=f"File too large: exceeded {max_size} bytes during download",
)
logger.info(f"Downloaded file from URL '{url}' as '{target_filename}' ({downloaded_size} bytes)") logger.info(f"Downloaded file from URL '{url}' as '{target_filename}' ({downloaded_size} bytes)")
# Enqueue for processing # Enqueue for processing
@@ -227,19 +234,19 @@ async def process_url(request: Request, url_request: URLUploadRequest):
"size": downloaded_size, "size": downloaded_size,
} }
except requests.exceptions.Timeout: except httpx.TimeoutException:
logger.error(f"Timeout while downloading file from URL: {url}") logger.error(f"Timeout while downloading file from URL: {url}")
raise HTTPException(status_code=408, detail="Request timeout: server took too long to respond") raise HTTPException(status_code=408, detail="Request timeout: server took too long to respond")
except requests.exceptions.ConnectionError as e: except httpx.ConnectError as e:
logger.error(f"Connection error while downloading file from URL: {url} - {str(e)}") logger.error(f"Connection error while downloading file from URL: {url} - {str(e)}")
raise HTTPException(status_code=502, detail=f"Failed to connect to URL: {str(e)}") raise HTTPException(status_code=502, detail=f"Failed to connect to URL: {str(e)}")
except requests.exceptions.HTTPError as e: except httpx.HTTPStatusError as e:
logger.error(f"HTTP error while downloading file from URL: {url} - {str(e)}") logger.error(f"HTTP error while downloading file from URL: {url} - {str(e)}")
raise HTTPException(status_code=e.response.status_code, detail=f"HTTP error: {str(e)}") raise HTTPException(status_code=e.response.status_code, detail=f"HTTP error: {str(e)}")
except requests.exceptions.RequestException as e: except httpx.RequestError as e:
logger.error(f"Error downloading file from URL: {url} - {str(e)}") logger.error(f"Error downloading file from URL: {url} - {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to download file: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to download file: {str(e)}")
+81 -2
View File
@@ -125,8 +125,17 @@ def get_current_user(request: Request):
# Check for Bearer token auth first (API tokens) # Check for Bearer token auth first (API tokens)
api_user = getattr(request.state, "api_token_user", None) api_user = getattr(request.state, "api_token_user", None)
if isinstance(api_user, dict): if isinstance(api_user, dict):
logger.debug("[AUTH] get_current_user: resolved from API token (user_id=%s)", api_user.get("id"))
return api_user return api_user
return request.session.get("user") session_user = request.session.get("user")
if session_user:
logger.debug(
"[AUTH] get_current_user: resolved from session (user=%s)",
session_user.get("preferred_username") or session_user.get("email") or session_user.get("id"),
)
else:
logger.debug("[AUTH] get_current_user: no user in session or API token")
return session_user
def _resolve_bearer_user(request: Request, db: Session) -> dict | None: def _resolve_bearer_user(request: Request, db: Session) -> dict | None:
@@ -141,10 +150,12 @@ def _resolve_bearer_user(request: Request, db: Session) -> dict | None:
""" """
auth_header = request.headers.get("authorization", "") auth_header = request.headers.get("authorization", "")
if not isinstance(auth_header, str) or not auth_header.startswith("Bearer "): if not isinstance(auth_header, str) or not auth_header.startswith("Bearer "):
logger.debug("[AUTH] _resolve_bearer_user: no Bearer token in Authorization header")
return None return None
raw_token = auth_header[7:] raw_token = auth_header[7:]
if not raw_token or not isinstance(raw_token, str): if not raw_token or not isinstance(raw_token, str):
logger.debug("[AUTH] _resolve_bearer_user: empty or invalid token after 'Bearer ' prefix")
return None return None
from app.api.api_tokens import hash_token from app.api.api_tokens import hash_token
@@ -153,8 +164,15 @@ def _resolve_bearer_user(request: Request, db: Session) -> dict | None:
token_hash = hash_token(raw_token) token_hash = hash_token(raw_token)
db_token = db.query(ApiToken).filter(ApiToken.token_hash == token_hash, ApiToken.is_active.is_(True)).first() db_token = db.query(ApiToken).filter(ApiToken.token_hash == token_hash, ApiToken.is_active.is_(True)).first()
if db_token is None: if db_token is None:
logger.debug("[AUTH] _resolve_bearer_user: no active API token matched the provided hash")
return None return None
logger.debug(
"[AUTH] _resolve_bearer_user: matched API token id=%s owner=%s",
db_token.id,
db_token.owner_id,
)
# Update usage tracking # Update usage tracking
try: try:
db_token.last_used_at = datetime.now(timezone.utc) db_token.last_used_at = datetime.now(timezone.utc)
@@ -205,16 +223,18 @@ def require_login(func):
@wraps(func) @wraps(func)
async def wrapper(request: Request, *args, **kwargs): async def wrapper(request: Request, *args, **kwargs):
url_path = urlparse(str(request.url)).path
# Check session auth first # Check session auth first
if request.session.get("user"): if request.session.get("user"):
logger.debug("[AUTH] require_login: session auth OK for %s", url_path)
if inspect.iscoroutinefunction(func): if inspect.iscoroutinefunction(func):
return await func(*args, request=request, **kwargs) return await func(*args, request=request, **kwargs)
else: else:
return func(*args, request=request, **kwargs) return func(*args, request=request, **kwargs)
# Fall back to Bearer token auth for API endpoints # Fall back to Bearer token auth for API endpoints
url_path = urlparse(str(request.url)).path
if url_path.startswith("/api/"): if url_path.startswith("/api/"):
logger.debug("[AUTH] require_login: no session, trying Bearer token for %s", url_path)
try: try:
from app.database import SessionLocal from app.database import SessionLocal
@@ -228,17 +248,22 @@ def require_login(func):
if api_user: if api_user:
request.state.api_token_user = api_user request.state.api_token_user = api_user
logger.debug(
"[AUTH] require_login: Bearer token auth OK for %s (user=%s)", url_path, api_user.get("id")
)
if inspect.iscoroutinefunction(func): if inspect.iscoroutinefunction(func):
return await func(*args, request=request, **kwargs) return await func(*args, request=request, **kwargs)
else: else:
return func(*args, request=request, **kwargs) return func(*args, request=request, **kwargs)
logger.debug("[AUTH] require_login: no valid auth for API endpoint %s — returning 401", url_path)
return JSONResponse( return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
content={"error": "Not authenticated"}, content={"error": "Not authenticated"},
) )
# Non-API endpoint with no session — redirect to login # Non-API endpoint with no session — redirect to login
logger.debug("[AUTH] require_login: no session for %s — redirecting to /login", url_path)
request.session["redirect_after_login"] = str(request.url) request.session["redirect_after_login"] = str(request.url)
return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND) return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
@@ -307,9 +332,15 @@ async def login(request: Request):
async def oauth_login(request: Request): async def oauth_login(request: Request):
"""Handle OAuth login flow""" """Handle OAuth login flow"""
if not OAUTH_CONFIGURED: if not OAUTH_CONFIGURED:
logger.debug("[AUTH] oauth_login: OAuth not configured — redirecting to /login")
return RedirectResponse(url="/login?error=OAuth+not+configured", status_code=status.HTTP_302_FOUND) return RedirectResponse(url="/login?error=OAuth+not+configured", status_code=status.HTTP_302_FOUND)
redirect_uri = request.url_for("oauth_callback") redirect_uri = request.url_for("oauth_callback")
logger.debug(
"[AUTH] oauth_login: initiating Authentik OAuth redirect_uri=%s session_keys=%s",
redirect_uri,
list(request.session.keys()),
)
return await oauth.authentik.authorize_redirect(request, redirect_uri) return await oauth.authentik.authorize_redirect(request, redirect_uri)
@@ -324,13 +355,23 @@ async def social_login(request: Request, provider: str):
A redirect to the provider's authorization page, or back to /login on error. A redirect to the provider's authorization page, or back to /login on error.
""" """
if provider not in SOCIAL_PROVIDERS: if provider not in SOCIAL_PROVIDERS:
logger.debug(
"[AUTH] social_login: unknown provider=%r (registered=%s)", provider, list(SOCIAL_PROVIDERS.keys())
)
return RedirectResponse(url="/login?error=Unknown+social+provider", status_code=status.HTTP_302_FOUND) return RedirectResponse(url="/login?error=Unknown+social+provider", status_code=status.HTTP_302_FOUND)
redirect_uri = request.url_for("social_callback", provider=provider) redirect_uri = request.url_for("social_callback", provider=provider)
oauth_client = getattr(oauth, provider, None) oauth_client = getattr(oauth, provider, None)
if oauth_client is None: if oauth_client is None:
logger.debug("[AUTH] social_login: provider=%r registered but OAuth client not configured", provider)
return RedirectResponse(url="/login?error=Provider+not+configured", status_code=status.HTTP_302_FOUND) return RedirectResponse(url="/login?error=Provider+not+configured", status_code=status.HTTP_302_FOUND)
logger.debug(
"[AUTH] social_login: initiating %s OAuth, redirect_uri=%s session_keys=%s",
provider,
redirect_uri,
list(request.session.keys()),
)
return await oauth_client.authorize_redirect(request, redirect_uri) return await oauth_client.authorize_redirect(request, redirect_uri)
@@ -389,27 +430,39 @@ async def social_callback(request: Request, provider: str, db: Session = Depends
A redirect to the user's original destination or the upload page. A redirect to the user's original destination or the upload page.
""" """
if provider not in SOCIAL_PROVIDERS: if provider not in SOCIAL_PROVIDERS:
logger.debug("[AUTH] social_callback: unknown provider=%r", provider)
return RedirectResponse(url="/login?error=Unknown+social+provider", status_code=status.HTTP_302_FOUND) return RedirectResponse(url="/login?error=Unknown+social+provider", status_code=status.HTTP_302_FOUND)
oauth_client = getattr(oauth, provider, None) oauth_client = getattr(oauth, provider, None)
if oauth_client is None: if oauth_client is None:
logger.debug("[AUTH] social_callback: provider=%r not configured", provider)
return RedirectResponse(url="/login?error=Provider+not+configured", status_code=status.HTTP_302_FOUND) return RedirectResponse(url="/login?error=Provider+not+configured", status_code=status.HTTP_302_FOUND)
try: try:
logger.debug("[AUTH] social_callback: exchanging auth code for provider=%s", provider)
token = await oauth_client.authorize_access_token(request) token = await oauth_client.authorize_access_token(request)
# Try standard OIDC userinfo first, fall back to token-embedded userinfo # Try standard OIDC userinfo first, fall back to token-embedded userinfo
raw_userinfo = token.get("userinfo") raw_userinfo = token.get("userinfo")
if not raw_userinfo: if not raw_userinfo:
logger.debug("[AUTH] social_callback: no userinfo in token, fetching from userinfo endpoint")
try: try:
resp = await oauth_client.userinfo(token=token) resp = await oauth_client.userinfo(token=token)
raw_userinfo = resp if isinstance(resp, dict) else resp.json() if hasattr(resp, "json") else {} raw_userinfo = resp if isinstance(resp, dict) else resp.json() if hasattr(resp, "json") else {}
except Exception: except Exception:
logger.debug("[AUTH] social_callback: userinfo endpoint failed, using empty dict", exc_info=True)
raw_userinfo = {} raw_userinfo = {}
user_data = _normalize_social_userinfo(provider, token, raw_userinfo) user_data = _normalize_social_userinfo(provider, token, raw_userinfo)
logger.debug(
"[AUTH] social_callback: normalized user_data email=%s sub=%s provider=%s",
user_data.get("email"),
user_data.get("sub"),
provider,
)
if not user_data.get("email"): if not user_data.get("email"):
logger.debug("[AUTH] social_callback: no email in user_data — aborting")
return RedirectResponse( return RedirectResponse(
url="/login?error=Could+not+retrieve+email+from+provider", url="/login?error=Could+not+retrieve+email+from+provider",
status_code=status.HTTP_302_FOUND, status_code=status.HTTP_302_FOUND,
@@ -442,21 +495,29 @@ async def social_callback(request: Request, provider: str, db: Session = Depends
) )
# Mobile app flow: issue an inline API token and redirect back to the app. # Mobile app flow: issue an inline API token and redirect back to the app.
logger.debug(
"[MOBILE] social_callback: checking for mobile redirect (session has mobile_redirect_uri=%s)",
"mobile_redirect_uri" in request.session,
)
mobile_resp = _create_mobile_redirect(request, db) mobile_resp = _create_mobile_redirect(request, db)
if mobile_resp: if mobile_resp:
logger.info("[MOBILE] social_callback: returning mobile redirect response for provider=%s", provider)
return mobile_resp return mobile_resp
if user_id: if user_id:
profile = db.query(_UserProfile).filter(_UserProfile.user_id == user_id).first() profile = db.query(_UserProfile).filter(_UserProfile.user_id == user_id).first()
if profile and not profile.onboarding_completed: if profile and not profile.onboarding_completed:
logger.debug("[AUTH] social_callback: user=%s needs onboarding, redirecting", user_id)
post_onboarding = request.session.pop("redirect_after_login", "/upload") post_onboarding = request.session.pop("redirect_after_login", "/upload")
request.session["post_onboarding_redirect"] = post_onboarding request.session["post_onboarding_redirect"] = post_onboarding
return RedirectResponse(url="/onboarding", status_code=status.HTTP_302_FOUND) return RedirectResponse(url="/onboarding", status_code=status.HTTP_302_FOUND)
redirect_url = request.session.pop("redirect_after_login", "/upload") redirect_url = request.session.pop("redirect_after_login", "/upload")
logger.debug("[AUTH] social_callback: login complete, redirecting to %s", redirect_url)
return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND) return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
except Exception as e: except Exception as e:
logger.warning("[SECURITY] SOCIAL_LOGIN_FAILURE provider=%s error=%s", provider, type(e).__name__) logger.warning("[SECURITY] SOCIAL_LOGIN_FAILURE provider=%s error=%s", provider, type(e).__name__)
logger.debug("[AUTH] social_callback: full exception for provider=%s", provider, exc_info=True)
return RedirectResponse( return RedirectResponse(
url="/login?error=Social+login+failed.+Please+try+again.", status_code=status.HTTP_302_FOUND url="/login?error=Social+login+failed.+Please+try+again.", status_code=status.HTTP_302_FOUND
) )
@@ -561,15 +622,23 @@ def _ensure_user_profile(db: Session, user_data: dict, is_admin: bool = False) -
async def oauth_callback(request: Request, db: Session = Depends(get_db)): async def oauth_callback(request: Request, db: Session = Depends(get_db)):
"""Handle OAuth callback from provider""" """Handle OAuth callback from provider"""
try: try:
logger.debug("[AUTH] oauth_callback: exchanging authorization code for token")
token = await oauth.authentik.authorize_access_token(request) token = await oauth.authentik.authorize_access_token(request)
userinfo = token.get("userinfo") userinfo = token.get("userinfo")
if not userinfo: if not userinfo:
logger.debug("[AUTH] oauth_callback: no userinfo in token response — aborting")
return RedirectResponse( return RedirectResponse(
url="/login?error=Failed+to+retrieve+user+information", status_code=status.HTTP_302_FOUND url="/login?error=Failed+to+retrieve+user+information", status_code=status.HTTP_302_FOUND
) )
# Store user info in session # Store user info in session
user_data = dict(userinfo) user_data = dict(userinfo)
logger.debug(
"[AUTH] oauth_callback: received userinfo email=%s sub=%s groups=%s",
user_data.get("email"),
user_data.get("sub"),
user_data.get("groups", []),
)
# Add Gravatar picture if no picture is provided # Add Gravatar picture if no picture is provided
if not user_data.get("picture") and user_data.get("email"): if not user_data.get("picture") and user_data.get("email"):
@@ -584,6 +653,12 @@ async def oauth_callback(request: Request, db: Session = Depends(get_db)):
groups = user_data.get("groups", []) groups = user_data.get("groups", [])
admin_group = (settings.admin_group_name or "admin").strip().lower() admin_group = (settings.admin_group_name or "admin").strip().lower()
is_admin = admin_group in [group.lower() for group in groups] is_admin = admin_group in [group.lower() for group in groups]
logger.debug(
"[AUTH] oauth_callback: admin group check — looking for %r in %s → is_admin=%s",
admin_group,
[g.lower() for g in groups],
is_admin,
)
# Set is_admin flag (defaults to False for OAuth users unless they're in admin group) # Set is_admin flag (defaults to False for OAuth users unless they're in admin group)
user_data["is_admin"] = is_admin user_data["is_admin"] = is_admin
@@ -623,15 +698,18 @@ async def oauth_callback(request: Request, db: Session = Depends(get_db)):
if user_id: if user_id:
profile = db.query(_UserProfile).filter(_UserProfile.user_id == user_id).first() profile = db.query(_UserProfile).filter(_UserProfile.user_id == user_id).first()
if profile and not profile.onboarding_completed: if profile and not profile.onboarding_completed:
logger.debug("[AUTH] oauth_callback: user=%s needs onboarding, redirecting", user_id)
post_onboarding = request.session.pop("redirect_after_login", "/upload") post_onboarding = request.session.pop("redirect_after_login", "/upload")
request.session["post_onboarding_redirect"] = post_onboarding request.session["post_onboarding_redirect"] = post_onboarding
return RedirectResponse(url="/onboarding", status_code=status.HTTP_302_FOUND) return RedirectResponse(url="/onboarding", status_code=status.HTTP_302_FOUND)
# Redirect to original destination or default # Redirect to original destination or default
redirect_url = request.session.pop("redirect_after_login", "/upload") redirect_url = request.session.pop("redirect_after_login", "/upload")
logger.debug("[AUTH] oauth_callback: login complete, redirecting to %s", redirect_url)
return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND) return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
except Exception as e: except Exception as e:
logger.warning(f"[SECURITY] OAUTH_LOGIN_FAILURE error={type(e).__name__}") logger.warning(f"[SECURITY] OAUTH_LOGIN_FAILURE error={type(e).__name__}")
logger.debug("[AUTH] oauth_callback: full exception details", exc_info=True)
return RedirectResponse(url=f"/login?error=Authentication+failed:+{str(e)}", status_code=status.HTTP_302_FOUND) return RedirectResponse(url=f"/login?error=Authentication+failed:+{str(e)}", status_code=status.HTTP_302_FOUND)
@@ -931,6 +1009,7 @@ async def logout(request: Request, db: Session = Depends(get_db)):
username = "unknown" username = "unknown"
if isinstance(user, dict): if isinstance(user, dict):
username = user.get("preferred_username") or user.get("email") or "unknown" username = user.get("preferred_username") or user.get("email") or "unknown"
logger.debug("[AUTH] logout: clearing session for user=%s client_ip=%s", username, get_client_ip(request))
logger.info(f"[SECURITY] LOGOUT user={username}") logger.info(f"[SECURITY] LOGOUT user={username}")
try: try:
from app.utils.audit_service import record_event from app.utils.audit_service import record_event
+45
View File
@@ -48,6 +48,51 @@ class Settings(BaseSettings):
workdir: str workdir: str
debug: bool = False # Default to False debug: bool = False # Default to False
# Logging level for the application. Accepts standard Python level names:
# DEBUG, INFO, WARNING, ERROR, CRITICAL. When *debug* is True and
# *log_level* has not been explicitly set, the effective level is forced to
# DEBUG so that all ``logger.debug()`` calls produce output.
log_level: str = Field(
default="INFO",
description=(
"Python logging level for the application root logger. "
"Accepts: DEBUG, INFO, WARNING, ERROR, CRITICAL. "
"When DEBUG=True and LOG_LEVEL is not explicitly set, "
"the effective level is automatically lowered to DEBUG."
),
)
# Log output format. ``text`` is the human-readable default.
# ``json`` emits one JSON object per line, ideal for log collectors
# (Promtail, Fluentd, Filebeat, Datadog agent) and SIEM ingestion.
log_format: str = Field(
default="text",
description=(
"Log output format: 'text' (human-readable, default) or "
"'json' (structured JSON lines for SIEM / log aggregation)."
),
)
# Optional syslog forwarding for application logs (not just audit events).
# When enabled, a Python SysLogHandler is added to the root logger so that
# every log message is also sent to the configured syslog receiver.
log_syslog_enabled: bool = Field(
default=False,
description="Forward application logs to a syslog receiver in addition to stdout.",
)
log_syslog_host: str = Field(
default="localhost",
description="Hostname or IP of the syslog receiver for application logs.",
)
log_syslog_port: int = Field(
default=514,
description="Port of the syslog receiver for application logs.",
)
log_syslog_protocol: str = Field(
default="udp",
description="Protocol for syslog transport: 'udp' or 'tcp'.",
)
# Making Dropbox optional # Making Dropbox optional
dropbox_enabled: bool = Field( dropbox_enabled: bool = Field(
default=True, default=True,
+1
View File
@@ -271,6 +271,7 @@ def _ensure_indexes(engine: Any, inspector: Any) -> None:
if table not in columns_by_table: if table not in columns_by_table:
columns_by_table[table] = {col["name"] for col in inspector.get_columns(table)} columns_by_table[table] = {col["name"] for col in inspector.get_columns(table)}
if column in columns_by_table[table]: if column in columns_by_table[table]:
# SECURITY: Quoted identifiers to prevent SQL injection during index creation
quoted_idx = preparer.quote(idx_name) quoted_idx = preparer.quote(idx_name)
quoted_table = preparer.quote(table) quoted_table = preparer.quote(table)
quoted_col = preparer.quote(column) quoted_col = preparer.quote(column)
+111
View File
@@ -1,8 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json as _json_mod
import logging import logging
import os import os
import pathlib import pathlib
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime as _dt
from datetime import timezone as _tz
from fastapi import FastAPI, HTTPException, Request, status from fastapi import FastAPI, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -36,6 +39,114 @@ from app.views import router as frontend_router
# Explicitly include the files router # Explicitly include the files router
from app.views.files import router as files_router from app.views.files import router as files_router
# ---------------------------------------------------------------------------
# Configure Python root logging level early so that *all* loggers (including
# those already created via ``logging.getLogger(__name__)`` in other modules)
# respect the configured level.
#
# Standard behaviour (matches Django, Flask, 12-factor conventions):
# • ``LOG_LEVEL`` env var takes precedence when explicitly set.
# • When ``DEBUG=True`` and ``LOG_LEVEL`` is **not** set, the effective
# level is automatically lowered to ``DEBUG``.
# • Default (neither flag set): ``INFO``.
#
# ``LOG_FORMAT=json`` enables structured JSON lines on stdout, suitable for
# Promtail, Fluentd, Filebeat, Datadog, Splunk UF, or any log collector.
#
# ``LOG_SYSLOG_ENABLED=true`` adds a Python SysLogHandler so that every log
# message is also forwarded to the configured syslog receiver — useful for
# traditional (non-container) deployments and centralised SIEM ingestion.
#
# Noisy third-party loggers (httpx, httpcore, authlib, etc.) are pinned to
# WARNING when the app-level is DEBUG to keep output useful.
# ---------------------------------------------------------------------------
_explicit_log_level = os.environ.get("LOG_LEVEL")
if settings.debug and _explicit_log_level is None:
_effective_level = "DEBUG"
else:
_effective_level = settings.log_level.upper()
_effective_level_int = getattr(logging, _effective_level, logging.INFO)
class _JsonFormatter(logging.Formatter):
"""Emit one JSON object per log line for machine consumption.
Fields emitted: ``timestamp``, ``level``, ``logger``, ``message``,
``module``, ``funcName``, ``lineno``, and — when present — ``exc_info``.
Compatible with Grafana Loki, Splunk, ELK, Datadog, and most SIEM tools.
"""
def format(self, record: logging.LogRecord) -> str:
log_entry: dict = {
"timestamp": _dt.fromtimestamp(record.created, tz=_tz.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"module": record.module,
"funcName": record.funcName,
"lineno": record.lineno,
}
if record.exc_info and record.exc_info[1] is not None:
log_entry["exc_info"] = self.formatException(record.exc_info)
return _json_mod.dumps(log_entry, default=str)
# Choose formatter based on LOG_FORMAT setting
if settings.log_format.lower() == "json":
_handler = logging.StreamHandler()
_handler.setFormatter(_JsonFormatter())
logging.root.handlers = [_handler]
logging.root.setLevel(_effective_level_int)
else:
logging.basicConfig(
level=_effective_level_int,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
force=True,
)
# Optional: forward application logs to a syslog receiver
if settings.log_syslog_enabled:
import logging.handlers as _lh
import socket as _socket
_proto = settings.log_syslog_protocol.lower()
_socktype = _socket.SOCK_STREAM if _proto == "tcp" else _socket.SOCK_DGRAM
_syslog_handler = _lh.SysLogHandler(
address=(settings.log_syslog_host, settings.log_syslog_port),
socktype=_socktype,
)
_syslog_handler.setLevel(_effective_level_int)
# Use the same formatter as stdout (text or JSON)
if settings.log_format.lower() == "json":
_syslog_handler.setFormatter(_JsonFormatter())
else:
_syslog_handler.setFormatter(logging.Formatter("%(name)s - %(levelname)s - %(message)s"))
logging.root.addHandler(_syslog_handler)
# Keep noisy third-party loggers quiet at DEBUG level
if _effective_level_int <= logging.DEBUG:
for _noisy in (
"httpx",
"httpcore",
"authlib",
"urllib3",
"hpack",
"multipart",
"watchfiles",
):
logging.getLogger(_noisy).setLevel(logging.WARNING)
_startup_logger = logging.getLogger(__name__)
_startup_logger.info(
"Root logging level set to %s (debug=%s, format=%s, syslog=%s)",
_effective_level,
settings.debug,
settings.log_format,
settings.log_syslog_enabled,
)
# Load configuration from .env for the session key # Load configuration from .env for the session key
config = Config(".env") config = Config(".env")
# Use settings.session_secret which has proper validation # Use settings.session_secret which has proper validation
+17 -2
View File
@@ -11,6 +11,7 @@ from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
import pypdf
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
from app.celery_app import celery from app.celery_app import celery
@@ -80,8 +81,22 @@ def extract_metadata_from_file(file_path):
except Exception as e: except Exception as e:
logger.warning(f"Failed to load metadata from JSON file: {str(e)}") logger.warning(f"Failed to load metadata from JSON file: {str(e)}")
# TODO: For PDF files, try to extract embedded metadata using PyPDF2 # Try to extract embedded metadata from PDF
# This would require additional dependencies, so for now we'll just check for external JSON if file_path.lower().endswith(".pdf") and os.path.exists(file_path):
try:
with open(file_path, "rb") as f:
pdf_reader = pypdf.PdfReader(f)
pdf_metadata = pdf_reader.metadata
if pdf_metadata:
# Convert metadata to a standard dictionary
for key, value in pdf_metadata.items():
# Remove the leading slash from PDF metadata keys (e.g., '/Title' -> 'Title')
clean_key = key[1:] if key.startswith("/") else key
metadata[clean_key] = str(value)
logger.info(f"Extracted embedded metadata from PDF: {file_path}")
except Exception as e:
logger.warning(f"Failed to extract metadata from PDF {file_path}: {str(e)}")
return metadata return metadata
+3 -3
View File
@@ -55,12 +55,12 @@ def upload_with_rclone(self, file_path: str, destination: str):
try: try:
# Ensure the remote path exists (create folders if needed) # Ensure the remote path exists (create folders if needed)
mkdir_cmd = ["rclone", "mkdir", "--config", rclone_config_path, destination] mkdir_cmd = ["rclone", "mkdir", "--config", rclone_config_path, "--", destination]
subprocess.run(mkdir_cmd, check=True, capture_output=True) # noqa: S603 subprocess.run(mkdir_cmd, check=True, capture_output=True) # noqa: S603
# Construct the upload command # Construct the upload command
upload_cmd = ["rclone", "copy", "--config", rclone_config_path, file_path, destination, "--progress"] upload_cmd = ["rclone", "copy", "--config", rclone_config_path, "--progress", "--", file_path, destination]
log_task_progress(task_id, "rclone_upload", "in_progress", f"Executing rclone copy to {destination}") log_task_progress(task_id, "rclone_upload", "in_progress", f"Executing rclone copy to {destination}")
@@ -71,7 +71,7 @@ def upload_with_rclone(self, file_path: str, destination: str):
if result.returncode == 0: if result.returncode == 0:
# Try to get a public link if possible # Try to get a public link if possible
try: try:
link_cmd = ["rclone", "link", "--config", rclone_config_path, f"{destination}/{filename}"] link_cmd = ["rclone", "link", "--config", rclone_config_path, "--", f"{destination}/{filename}"]
link_result = subprocess.run(link_cmd, capture_output=True, text=True, check=False) # noqa: S603 link_result = subprocess.run(link_cmd, capture_output=True, text=True, check=False) # noqa: S603
public_url = link_result.stdout.strip() if link_result.returncode == 0 else None public_url = link_result.stdout.strip() if link_result.returncode == 0 else None
except (subprocess.SubprocessError, OSError) as e: except (subprocess.SubprocessError, OSError) as e:
+4
View File
@@ -12,6 +12,7 @@ The utility:
""" """
import logging import logging
import re
from typing import Any from typing import Any
from sqlalchemy import MetaData, create_engine, inspect, text from sqlalchemy import MetaData, create_engine, inspect, text
@@ -84,6 +85,9 @@ def preview_migration(source_url: str) -> dict[str, Any]:
total = 0 total = 0
with src_engine.connect() as conn: with src_engine.connect() as conn:
for table_name in tables: for table_name in tables:
if not re.match(r"^[a-zA-Z0-9_]+$", table_name):
logger.warning(f"Skipping table with invalid name format: {table_name}")
continue
# table_name is safe — sourced from inspect().get_table_names(), not user input # table_name is safe — sourced from inspect().get_table_names(), not user input
quoted_table = conn.dialect.identifier_preparer.quote(table_name) quoted_table = conn.dialect.identifier_preparer.quote(table_name)
row = conn.execute(text(f"SELECT COUNT(*) FROM {quoted_table}")).fetchone() # noqa: S608 row = conn.execute(text(f"SELECT COUNT(*) FROM {quoted_table}")).fetchone() # noqa: S608
+55
View File
@@ -0,0 +1,55 @@
import logging
import os
from typing import Dict
logger = logging.getLogger(__name__)
def update_env_file(settings_to_update: Dict[str, str]) -> bool:
"""
Updates the .env file with the given settings (best-effort).
Creates or modifies existing keys.
Args:
settings_to_update: A dictionary mapping uppercase env var names to their new string values.
Returns:
True if the file was successfully updated, False otherwise.
"""
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")
return False
logger.info(f"Updating settings in {env_path}")
with open(env_path, "r") as f:
env_lines = f.readlines()
updated = set()
new_env_lines = []
for line in env_lines:
stripped_line = line.rstrip()
is_updated = False
for key, value in settings_to_update.items():
if stripped_line.startswith(f"{key}=") or stripped_line.startswith(f"# {key}="):
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 settings_to_update.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 settings in .env file")
return True
except Exception as env_err:
logger.warning(f"Failed to write .env file (non-fatal): {env_err}")
return False
+57
View File
@@ -2628,6 +2628,63 @@ SETTING_METADATA = {
"required": False, "required": False,
"restart_required": False, "restart_required": False,
}, },
# Logging
"log_level": {
"category": "Observability",
"description": (
"Python logging level for the application root logger. "
"Accepts: DEBUG, INFO, WARNING, ERROR, CRITICAL. "
"When DEBUG=True and LOG_LEVEL is not explicitly set, "
"the effective level is automatically lowered to DEBUG."
),
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
"log_format": {
"category": "Observability",
"description": (
"Log output format: 'text' (human-readable, default) or "
"'json' (structured JSON lines for SIEM / log aggregation)."
),
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
"log_syslog_enabled": {
"category": "Observability",
"description": "Forward application logs to a syslog receiver in addition to stdout.",
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": True,
},
"log_syslog_host": {
"category": "Observability",
"description": "Hostname or IP of the syslog receiver for application logs.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
"log_syslog_port": {
"category": "Observability",
"description": "Port of the syslog receiver for application logs.",
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": True,
},
"log_syslog_protocol": {
"category": "Observability",
"description": "Protocol for syslog transport: 'udp' or 'tcp'.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
# Observability Sentry # Observability Sentry
"sentry_dsn": { "sentry_dsn": {
"category": "Observability", "category": "Observability",
+101
View File
@@ -0,0 +1,101 @@
import time
import os
import sys
import asyncio
# Mock settings before app imports to bypass validation
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
os.environ["REDIS_URL"] = "redis://localhost:6379"
os.environ["OPENAI_API_KEY"] = "mock_key"
os.environ["WORKDIR"] = "/tmp/workdir"
os.environ["AZURE_AI_KEY"] = "mock"
os.environ["AZURE_REGION"] = "mock"
os.environ["AZURE_ENDPOINT"] = "http://mock"
os.environ["GOTENBERG_URL"] = "http://mock"
os.environ["SESSION_SECRET"] = "mock_secret_mock_secret_mock_secret_mock_secret"
# Ensure app package is accessible
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.database import Base
from app.models import FileRecord
from app.api.duplicates import list_duplicate_groups
# Mocking Request object
class MockRequest:
def __init__(self):
self.session = {"user": {"username": "testuser"}}
self.state = type('State', (), {'user': {"username": "testuser"}})()
class MockURL:
def include_query_params(self, **kwargs):
return f"http://testserver/api/duplicates?page={kwargs.get('page')}"
url = MockURL()
def setup_db():
engine = create_engine('sqlite:///:memory:')
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
db = Session()
return db
def populate_data(db, num_groups, duplicates_per_group):
for i in range(num_groups):
filehash = f"hash_{i}"
# Original
original = FileRecord(
filehash=filehash,
local_filename=f"orig_{i}.txt",
file_size=100,
is_duplicate=False
)
db.add(original)
# Duplicates
for j in range(duplicates_per_group):
dup = FileRecord(
filehash=filehash,
local_filename=f"dup_{i}_{j}.txt",
file_size=100,
is_duplicate=True
)
db.add(dup)
db.commit()
async def run_benchmark(db):
request = MockRequest()
start_time = time.time()
# Run the function we want to benchmark
result = list_duplicate_groups(request=request, db=db, page=1, per_page=500)
if asyncio.iscoroutine(result):
result = await result
end_time = time.time()
return end_time - start_time, result
async def main():
db = setup_db()
# 500 groups, each with 20 duplicates = 10500 records total
print("Populating data...")
populate_data(db, 500, 20)
print("Data populated. Running baseline benchmark...")
# Warmup
result = list_duplicate_groups(request=MockRequest(), db=db, page=1, per_page=500)
if asyncio.iscoroutine(result):
await result
# Benchmark
total_time = 0
iterations = 10
for _ in range(iterations):
time_taken, _ = await run_benchmark(db)
total_time += time_taken
avg_time = total_time / iterations
print(f"Average time over {iterations} iterations: {avg_time:.4f} seconds")
if __name__ == "__main__":
asyncio.run(main())
+50
View File
@@ -0,0 +1,50 @@
import json
import time
import pytest
from app.database import get_db
from app.models import UserNotificationTarget, UserNotificationPreference
from app.main import app
from tests.test_notifications_api import _make_client, _OWNER, _cleanup
import statistics
def run_benchmark(notif_engine, notif_session, client, items_count, iterations=5):
# Setup
target = UserNotificationTarget(
owner_id=_OWNER,
channel_type="webhook",
name="My Webhook",
config=json.dumps({"url": "https://x.com"}),
)
notif_session.add(target)
notif_session.commit()
notif_session.refresh(target)
# Generate big payload
preferences = []
for i in range(items_count):
preferences.append({
"event_type": f"event.type.{i}",
"channel_type": "webhook",
"is_enabled": True,
"target_id": target.id,
})
payload = {"preferences": preferences}
# Warm up
client.put("/api/user-notifications/preferences", json=payload)
times = []
for _ in range(iterations):
# Alter the values a bit so it's a real update
for p in payload["preferences"]:
p["is_enabled"] = not p["is_enabled"]
start = time.time()
resp = client.put("/api/user-notifications/preferences", json=payload)
end = time.time()
assert resp.status_code == 200
times.append(end - start)
return statistics.mean(times)
+49
View File
@@ -0,0 +1,49 @@
import asyncio
import time
from unittest.mock import Mock, patch
from app.api.url_upload import process_url, URLUploadRequest
from app.config import settings
async def main():
# Mock request and URLUploadRequest
request = Mock()
url_request = URLUploadRequest(url="https://example.com/file.pdf")
# Generate a large chunk
large_chunk = b"A" * 8192
num_chunks = 10000 # 8192 * 10000 = ~80MB
mock_response = Mock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf"}
mock_response.iter_content = Mock(return_value=[large_chunk] * num_chunks)
# For async client later
class AsyncMockResponse:
def __init__(self):
self.status_code = 200
self.headers = {"Content-Type": "application/pdf"}
def raise_for_status(self):
pass
async def aiter_bytes(self, chunk_size):
for _ in range(num_chunks):
yield large_chunk
async_mock_response = AsyncMockResponse()
# We will mock requests.get for synchronous, httpx.AsyncClient.get for asynchronous
# Test sync
start_time = time.time()
with patch("app.api.url_upload.requests.get", return_value=mock_response), \
patch("app.api.url_upload.process_document"):
try:
await process_url(request=request, url_request=url_request)
except Exception as e:
print(f"Error: {e}")
end_time = time.time()
print(f"Original execution time (sync writing): {end_time - start_time:.4f} seconds")
if __name__ == "__main__":
asyncio.run(main())
+75
View File
@@ -0,0 +1,75 @@
import asyncio
import time
import os
import shutil
import tempfile
from unittest.mock import Mock, patch
from fastapi import HTTPException
from app.api.url_upload import process_url, URLUploadRequest
from app.config import settings
async def main():
# Setup test dir
test_dir = tempfile.mkdtemp()
settings.workdir = test_dir
# Mock request and URLUploadRequest
request = Mock()
url_request = URLUploadRequest(url="https://example.com/file.pdf")
# Generate a large chunk
chunk_size = 8192
num_chunks = 20000 # 20000 * 8192 = ~160MB
large_chunk = b"A" * chunk_size
class SyncMockResponse:
def __init__(self):
self.status_code = 200
self.headers = {"Content-Type": "application/pdf"}
def raise_for_status(self):
pass
def iter_content(self, chunk_size):
for _ in range(num_chunks):
# sleep slightly to simulate network latency, otherwise OS file cache obscures the difference
time.sleep(0.0001)
yield large_chunk
sync_mock_response = SyncMockResponse()
class AsyncMockResponse:
def __init__(self):
self.status_code = 200
self.headers = {"Content-Type": "application/pdf"}
self.is_success = True
self.status_code = 200
def raise_for_status(self):
pass
async def aiter_bytes(self, chunk_size=8192):
for _ in range(num_chunks):
await asyncio.sleep(0.0001)
yield large_chunk
class AsyncMockContext:
async def __aenter__(self):
return AsyncMockResponse()
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
async_mock_response = AsyncMockResponse()
# Test sync
start_time = time.time()
with patch("app.api.url_upload.requests.get", return_value=sync_mock_response), \
patch("app.api.url_upload.process_document"):
try:
await process_url(request=request, url_request=url_request)
except Exception as e:
print(f"Error (sync): {e}")
end_time = time.time()
print(f"Original execution time (sync writing): {end_time - start_time:.4f} seconds")
shutil.rmtree(test_dir)
if __name__ == "__main__":
asyncio.run(main())
+81
View File
@@ -457,6 +457,87 @@ default overage buffer applied across all plans.
DocuElevate supports HTTP security headers to improve browser-side security. **These headers are disabled by default** since most deployments use a reverse proxy (Traefik, Nginx, etc.) that already adds them. Enable only if deploying directly without a reverse proxy. See [Deployment Guide - Security Headers](DeploymentGuide.md#security-headers) for detailed configuration examples. DocuElevate supports HTTP security headers to improve browser-side security. **These headers are disabled by default** since most deployments use a reverse proxy (Traefik, Nginx, etc.) that already adds them. Enable only if deploying directly without a reverse proxy. See [Deployment Guide - Security Headers](DeploymentGuide.md#security-headers) for detailed configuration examples.
### Application Logging
DocuElevate uses Python's standard `logging` module. Two environment variables control log verbosity:
| **Variable** | **Description** | **Default** |
|-------------|----------------|-------------|
| `LOG_LEVEL` | Root logger level. Accepts standard Python level names: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`. | `INFO` |
| `DEBUG` | Enable debug mode. When `true` **and** `LOG_LEVEL` is **not** explicitly set, the effective log level is automatically lowered to `DEBUG`. | `false` |
**Precedence rules (standard behaviour):**
1. If `LOG_LEVEL` is explicitly set, it always wins — regardless of `DEBUG`.
2. If only `DEBUG=true` is set (no `LOG_LEVEL`), the effective level becomes `DEBUG`.
3. If neither is set, the default level is `INFO`.
```bash
# Typical production (default)
# LOG_LEVEL=INFO
# Quick debug mode — sets level to DEBUG automatically
DEBUG=true
# Explicit level override (DEBUG flag is ignored for level selection)
LOG_LEVEL=WARNING
```
> **Tip:** At `DEBUG` level, noisy third-party libraries (httpx, authlib, urllib3, etc.) are automatically pinned to `WARNING` so that application debug output remains readable.
#### Structured JSON Logging
Set `LOG_FORMAT=json` to emit structured JSON lines on stdout — one JSON object per log message. This is the standard format for log collectors and SIEM tools:
| **Variable** | **Description** | **Default** |
|-------------|----------------|-------------|
| `LOG_FORMAT` | Log output format: `text` (human-readable) or `json` (structured JSON lines). | `text` |
Each JSON log line contains: `timestamp` (ISO 8601), `level`, `logger`, `message`, `module`, `funcName`, `lineno`, and `exc_info` (when an exception is logged).
```bash
# Enable JSON logging for SIEM / log aggregation
LOG_FORMAT=json
```
**Example JSON output:**
```json
{"timestamp": "2025-03-16T09:18:05.192000+00:00", "level": "INFO", "logger": "app.auth", "message": "[SECURITY] OAUTH_LOGIN_SUCCESS user=alice@example.com admin=False", "module": "auth", "funcName": "oauth_callback", "lineno": 654}
```
**Compatible with:**
- **Grafana Loki** — Promtail scrapes JSON from Docker stdout
- **Splunk** — Universal Forwarder or HEC with JSON sourcetype
- **ELK / OpenSearch** — Filebeat with JSON codec
- **Datadog** — Agent auto-parses JSON logs
- **Fluentd / Vector** — JSON input plugin
- **Docker log drivers** — `--log-driver=json-file` (default) preserves structure
#### Syslog Forwarding (Application Logs)
For traditional (non-container) deployments, application logs can be forwarded directly to a syslog receiver. This is **separate** from audit-log SIEM forwarding (see below) — it sends _every_ Python log message, not just audit events.
| **Variable** | **Description** | **Default** |
|-------------|----------------|-------------|
| `LOG_SYSLOG_ENABLED` | Forward application logs to a syslog receiver in addition to stdout. | `false` |
| `LOG_SYSLOG_HOST` | Hostname or IP of the syslog receiver. | `localhost` |
| `LOG_SYSLOG_PORT` | Port of the syslog receiver. | `514` |
| `LOG_SYSLOG_PROTOCOL` | Protocol: `udp` or `tcp`. | `udp` |
```bash
# Forward all application logs to syslog
LOG_SYSLOG_ENABLED=true
LOG_SYSLOG_HOST=syslog.internal.example.com
LOG_SYSLOG_PORT=514
LOG_SYSLOG_PROTOCOL=udp
# Combine with JSON format for structured syslog messages
LOG_FORMAT=json
LOG_SYSLOG_ENABLED=true
```
> **Note:** When `LOG_FORMAT=json`, syslog messages are also sent as JSON. When `LOG_FORMAT=text`, syslog messages use the standard `name - level - message` format.
### Audit Logging ### Audit Logging
DocuElevate provides comprehensive audit logging that records significant actions (logins, document CRUD, settings changes) to an append-only database table. Every entry captures the timestamp, user, action, resource, client IP, and optional JSON details. DocuElevate provides comprehensive audit logging that records significant actions (logins, document CRUD, settings changes) to an append-only database table. Every entry captures the timestamp, user, action, resource, client IP, and optional JSON details.
+53
View File
@@ -0,0 +1,53 @@
import re
with open("tests/test_api_saved_searches.py", "r") as f:
content = f.read()
# We need to mock get_current_user in app.api.saved_searches (which is imported from app.auth)
# because saved searches uses `_get_user_id` which calls `get_current_user(request)`.
# But `_get_user_id` is NOT a dependency injected via `Depends`!
# Let's verify `app/api/saved_searches.py` uses `Depends` or just calls it.
# In `app/api/saved_searches.py`:
# def _get_user_id(request: Request) -> str:
# user = get_current_user(request)
# if user:
# return user.get("preferred_username") ...
# It's called directly inside the routes: `user_id = _get_user_id(request)`
# It doesn't use `Depends(_get_user_id)`.
# Ah! But earlier I saw `_get_user_id` wasn't mocked properly. Let's use patch to mock `_get_user_id`.
# Wait, `TestClient` can be given an active session, but `app.auth.get_current_user` uses `request.session.get("user")` or Bearer token.
# Is `AUTH_ENABLED` false? The test env has `os.environ["AUTH_ENABLED"] = "False"` in `tests/conftest.py`.
# If `AUTH_ENABLED` is false, `require_login` is a no-op, and `_get_user_id` falls back to "anonymous".
# Actually, `_get_user_id` returns "anonymous" if `get_current_user(request)` is None.
# If `_OWNER` is "test_user@example.com", we should probably just patch `_get_user_id`.
replacement = """def _make_client(int_engine, owner_id: str = _OWNER):
\"\"\"Return a TestClient with *owner_id* injected as the authenticated user.\"\"\"
from app.main import app
from unittest.mock import patch
def override_db():
Session = sessionmaker(bind=int_engine)
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = override_db
with patch("app.api.saved_searches._get_user_id", return_value=owner_id):
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
yield client
app.dependency_overrides.clear()"""
content = re.sub(
r"def _make_client\(int_engine, owner_id: str = _OWNER\):.*?(?=@pytest\.fixture\(\)\ndef int_client\(int_engine\):)",
replacement + "\n\n\n",
content,
flags=re.DOTALL
)
with open("tests/test_api_saved_searches.py", "w") as f:
f.write(content)
+3
View File
@@ -23,6 +23,7 @@ djlint>=1.36.0 # HTML template linter for accessibility and best practices
mkdocs-material>=9.5.0 # MkDocs Material theme same package used in docs/requirements.txt mkdocs-material>=9.5.0 # MkDocs Material theme same package used in docs/requirements.txt
# Type stubs for mypy # Type stubs for mypy
types-aiofiles>=24.1.0
types-requests>=2.31.0 types-requests>=2.31.0
types-paramiko>=3.0.0 types-paramiko>=3.0.0
@@ -37,3 +38,5 @@ pip-licenses==5.5.1 # For license compliance checking
# Release automation # Release automation
python-semantic-release>=9.0.0 python-semantic-release>=9.0.0
types-aiofiles>=24.1.0.20240311 # Type stubs for aiofiles
+4 -2
View File
@@ -50,11 +50,13 @@ litellm>=1.0.0,<2.0.0
pytesseract>=0.3.10 # Python wrapper for Tesseract OCR pytesseract>=0.3.10 # Python wrapper for Tesseract OCR
pdf2image>=1.17.0 # Convert PDF pages to images (used by Tesseract and EasyOCR providers) pdf2image>=1.17.0 # Convert PDF pages to images (used by Tesseract and EasyOCR providers)
ocrmypdf>=16.0.0,<18.0.0 # Post-processing: embeds searchable text layers into PDFs via Tesseract ocrmypdf>=16.0.0,<18.0.0 # Post-processing: embeds searchable text layers into PDFs via Tesseract
meilisearch>=0.31.0 # Full-text search engine client meilisearch>=0.31.0 # Full-text search engine client
stripe>=7.0.0,<15.0.0 # Stripe billing SDK (MIT license) stripe>=7.0.0,<15.0.0 # Stripe billing SDK (MIT license)
# Error and performance monitoring # Error and performance monitoring
sentry-sdk[fastapi,celery,sqlalchemy]>=2.20.0,<3.0.0 sentry-sdk[fastapi,celery,sqlalchemy]>=2.20.0,<3.0.0
# GraphQL API # GraphQL API
strawberry-graphql[fastapi]>=0.243.0,<1.0.0 strawberry-graphql[fastapi]>=0.243.0,<1.0.0
aiofiles>=24.1.0 # Asynchronous file I/O support
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
pytest tests/ -k "not test_e2e_full_stack and not test_upload_tasks and not test_slow" -m "not slow" -n 4
+140
View File
@@ -269,6 +269,53 @@ class TestSavedSearchesCRUD:
response2 = client.post("/api/saved-searches", json=payload) response2 = client.post("/api/saved-searches", json=payload)
assert response2.status_code == 409 assert response2.status_code == 409
def test_create_saved_search_db_error(self, client: TestClient, monkeypatch):
"""POST /api/saved-searches returns 500 on DB exception."""
# Mock db.add or db.commit to raise an exception
# We can monkeypatch the route's dependency or the models
# It's easier to mock the SavedSearch model's __init__ or db's add
# Since we use db: DbSession, it's an instance of sqlalchemy.orm.Session
from sqlalchemy.orm import Session
original_commit = Session.commit
def mock_commit(*args, **kwargs):
raise Exception("Simulated DB error")
monkeypatch.setattr(Session, "commit", mock_commit)
payload = {
"name": "DB Error Search",
"filters": {"status": "completed"},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 500
assert "Failed to save search" in response.json()["detail"]
def test_create_saved_search_limit_reached(self, client: TestClient, monkeypatch):
"""POST /api/saved-searches returns 409 if max limit is reached."""
monkeypatch.setattr("app.api.saved_searches.MAX_SAVED_SEARCHES_PER_USER", 1)
# Create first one
payload1 = {"name": "Search 1", "filters": {"status": "completed"}}
response1 = client.post("/api/saved-searches", json=payload1)
assert response1.status_code == 201
# Creating second one should fail due to limit
payload2 = {"name": "Search 2", "filters": {"status": "pending"}}
response2 = client.post("/api/saved-searches", json=payload2)
assert response2.status_code == 409
assert "Maximum of 1 saved searches reached" in response2.json()["detail"]
def test_create_saved_search_invalid_name_type(self, client: TestClient):
"""POST /api/saved-searches with non-string name returns 422."""
payload = {
"name": 12345,
"filters": {"status": "completed"},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 422
def test_update_saved_search(self, client: TestClient): def test_update_saved_search(self, client: TestClient):
"""PUT /api/saved-searches/{id} updates the saved search.""" """PUT /api/saved-searches/{id} updates the saved search."""
# Create # Create
@@ -296,6 +343,83 @@ class TestSavedSearchesCRUD:
) )
assert response.status_code == 404 assert response.status_code == 404
def test_update_saved_search_duplicate_name(self, client: TestClient):
"""PUT /api/saved-searches/{id} with duplicate name returns 409."""
# Create first search
client.post(
"/api/saved-searches",
json={"name": "First Search", "filters": {"status": "pending"}},
)
# Create second search
create_resp2 = client.post(
"/api/saved-searches",
json={"name": "Second Search", "filters": {"status": "completed"}},
)
search_id2 = create_resp2.json()["id"]
# Try to rename second search to "First Search"
update_resp = client.put(
f"/api/saved-searches/{search_id2}",
json={"name": "First Search", "filters": {"status": "completed"}},
)
assert update_resp.status_code == 409
def test_update_saved_search_name_too_long(self, client: TestClient):
"""PUT /api/saved-searches/{id} with name > 100 chars returns 422."""
create_resp = client.post(
"/api/saved-searches",
json={"name": "Valid Name", "filters": {"status": "pending"}},
)
search_id = create_resp.json()["id"]
update_resp = client.put(
f"/api/saved-searches/{search_id}",
json={"name": "x" * 101, "filters": {"status": "completed"}},
)
assert update_resp.status_code == 422
def test_update_saved_search_empty_name(self, client: TestClient):
"""PUT /api/saved-searches/{id} with empty name returns 422."""
create_resp = client.post(
"/api/saved-searches",
json={"name": "Valid Name", "filters": {"status": "pending"}},
)
search_id = create_resp.json()["id"]
update_resp = client.put(
f"/api/saved-searches/{search_id}",
json={"name": "", "filters": {"status": "completed"}},
)
assert update_resp.status_code == 422
def test_update_saved_search_empty_filters(self, client: TestClient):
"""PUT /api/saved-searches/{id} with empty filters returns 422."""
create_resp = client.post(
"/api/saved-searches",
json={"name": "Valid Name", "filters": {"status": "pending"}},
)
search_id = create_resp.json()["id"]
update_resp = client.put(
f"/api/saved-searches/{search_id}",
json={"name": "Valid Name", "filters": {}},
)
assert update_resp.status_code == 422
def test_update_saved_search_invalid_filters(self, client: TestClient):
"""PUT /api/saved-searches/{id} with only invalid filters returns 422."""
create_resp = client.post(
"/api/saved-searches",
json={"name": "Valid Name", "filters": {"status": "pending"}},
)
search_id = create_resp.json()["id"]
update_resp = client.put(
f"/api/saved-searches/{search_id}",
json={"name": "Valid Name", "filters": {"invalid_key": "value"}},
)
assert update_resp.status_code == 422
def test_delete_saved_search(self, client: TestClient): def test_delete_saved_search(self, client: TestClient):
"""DELETE /api/saved-searches/{id} removes the saved search.""" """DELETE /api/saved-searches/{id} removes the saved search."""
# Create # Create
@@ -318,6 +442,22 @@ class TestSavedSearchesCRUD:
response = client.delete("/api/saved-searches/999") response = client.delete("/api/saved-searches/999")
assert response.status_code == 404 assert response.status_code == 404
def test_delete_saved_search_db_error(self, client: TestClient):
"""DELETE /api/saved-searches/{id} handles database errors (500)."""
from unittest.mock import patch
# Create
create_resp = client.post(
"/api/saved-searches",
json={"name": "To Delete DB Error", "filters": {"status": "failed"}},
)
search_id = create_resp.json()["id"]
with patch("sqlalchemy.orm.Session.delete", side_effect=Exception("DB Delete Error")):
response = client.delete(f"/api/saved-searches/{search_id}")
assert response.status_code == 500
assert response.json()["detail"] == "Failed to delete saved search"
def test_create_name_too_long(self, client: TestClient): def test_create_name_too_long(self, client: TestClient):
"""POST /api/saved-searches with name > 100 chars returns 422.""" """POST /api/saved-searches with name > 100 chars returns 422."""
payload = { payload = {
+11 -7
View File
@@ -7,7 +7,6 @@ Covers Dropbox OAuth endpoints, settings management, and token testing.
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
import pytest import pytest
import requests
@pytest.mark.unit @pytest.mark.unit
@@ -137,7 +136,7 @@ class TestTestDropboxToken:
assert data["status"] == "error" assert data["status"] == "error"
assert "not fully configured" in data["message"] assert "not fully configured" in data["message"]
@patch("app.api.dropbox.requests.post") @patch("app.api.dropbox.httpx.AsyncClient.post")
@patch("app.api.dropbox.settings") @patch("app.api.dropbox.settings")
def test_valid_token(self, mock_settings, mock_post, client): def test_valid_token(self, mock_settings, mock_post, client):
"""Test successful token validation.""" """Test successful token validation."""
@@ -162,7 +161,7 @@ class TestTestDropboxToken:
assert data["account"] == "user@example.com" assert data["account"] == "user@example.com"
assert data["account_name"] == "Test User" assert data["account_name"] == "Test User"
@patch("app.api.dropbox.requests.post") @patch("app.api.dropbox.httpx.AsyncClient.post")
@patch("app.api.dropbox.settings") @patch("app.api.dropbox.settings")
def test_expired_token_refreshed(self, mock_settings, mock_post, client): def test_expired_token_refreshed(self, mock_settings, mock_post, client):
"""Test that expired token triggers refresh and retry.""" """Test that expired token triggers refresh and retry."""
@@ -194,7 +193,7 @@ class TestTestDropboxToken:
data = response.json() data = response.json()
assert data["status"] == "success" assert data["status"] == "success"
@patch("app.api.dropbox.requests.post") @patch("app.api.dropbox.httpx.AsyncClient.post")
@patch("app.api.dropbox.settings") @patch("app.api.dropbox.settings")
def test_refresh_token_expired(self, mock_settings, mock_post, client): def test_refresh_token_expired(self, mock_settings, mock_post, client):
"""Test handling when refresh token itself is expired.""" """Test handling when refresh token itself is expired."""
@@ -220,7 +219,7 @@ class TestTestDropboxToken:
assert data["status"] == "error" assert data["status"] == "error"
assert data["needs_reauth"] is True assert data["needs_reauth"] is True
@patch("app.api.dropbox.requests.post") @patch("app.api.dropbox.httpx.AsyncClient.post")
@patch("app.api.dropbox.settings") @patch("app.api.dropbox.settings")
def test_token_validation_failure(self, mock_settings, mock_post, client): def test_token_validation_failure(self, mock_settings, mock_post, client):
"""Test handling non-401, non-200 response.""" """Test handling non-401, non-200 response."""
@@ -240,16 +239,21 @@ class TestTestDropboxToken:
data = response.json() data = response.json()
assert data["status"] == "error" assert data["status"] == "error"
@patch("app.api.dropbox.requests.post") @patch("app.api.dropbox.httpx.AsyncClient.post")
@patch("app.api.dropbox.settings") @patch("app.api.dropbox.settings")
def test_connection_error(self, mock_settings, mock_post, client): def test_connection_error(self, mock_settings, mock_post, client):
"""Test handling of connection exceptions.""" """Test handling of connection exceptions."""
import httpx
mock_settings.dropbox_refresh_token = "token" mock_settings.dropbox_refresh_token = "token"
mock_settings.dropbox_app_key = "app-key" mock_settings.dropbox_app_key = "app-key"
mock_settings.dropbox_app_secret = "app-secret" mock_settings.dropbox_app_secret = "app-secret"
mock_settings.http_request_timeout = 30 mock_settings.http_request_timeout = 30
mock_post.side_effect = requests.exceptions.ConnectionError("Connection refused") mock_post.side_effect = httpx.RequestError(
"Connection refused",
request=httpx.Request("POST", "https://api.dropboxapi.com/2/users/get_current_account"),
)
response = client.get("/api/dropbox/test-token") response = client.get("/api/dropbox/test-token")
+23 -19
View File
@@ -84,8 +84,8 @@ class TestUpdateDropboxSettings:
class TestTestDropboxToken: class TestTestDropboxToken:
"""Tests for GET /dropbox/test-token endpoint.""" """Tests for GET /dropbox/test-token endpoint."""
@patch("app.api.dropbox.requests.post") @patch("app.api.dropbox.httpx.AsyncClient")
def test_test_token_success(self, mock_post): def test_test_token_success(self, mock_client_cls):
"""Test successful token validation.""" """Test successful token validation."""
from app.config import settings from app.config import settings
@@ -95,7 +95,7 @@ class TestTestDropboxToken:
"email": "test@example.com", "email": "test@example.com",
"name": {"display_name": "Test User"}, "name": {"display_name": "Test User"},
} }
mock_post.return_value = mock_response mock_client_cls.return_value.__aenter__.return_value.post.return_value = mock_response
with patch.object(settings, "dropbox_refresh_token", "token"): with patch.object(settings, "dropbox_refresh_token", "token"):
with patch.object(settings, "dropbox_app_key", "key"): with patch.object(settings, "dropbox_app_key", "key"):
@@ -104,8 +104,8 @@ class TestTestDropboxToken:
# Should include account email and name # Should include account email and name
pass pass
@patch("app.api.dropbox.requests.post") @patch("app.api.dropbox.httpx.AsyncClient")
def test_test_token_not_configured(self, mock_post): def test_test_token_not_configured(self, mock_client_cls):
"""Test when credentials are not configured.""" """Test when credentials are not configured."""
from app.config import settings from app.config import settings
@@ -113,8 +113,8 @@ class TestTestDropboxToken:
# Should return error indicating not configured # Should return error indicating not configured
pass pass
@patch("app.api.dropbox.requests.post") @patch("app.api.dropbox.httpx.AsyncClient")
def test_test_token_partial_config(self, mock_post): def test_test_token_partial_config(self, mock_client_cls):
"""Test with partial configuration (missing some credentials).""" """Test with partial configuration (missing some credentials)."""
from app.config import settings from app.config import settings
@@ -123,8 +123,8 @@ class TestTestDropboxToken:
# Should return error # Should return error
pass pass
@patch("app.api.dropbox.requests.post") @patch("app.api.dropbox.httpx.AsyncClient")
def test_test_token_expired_requires_refresh(self, mock_post): def test_test_token_expired_requires_refresh(self, mock_client_cls):
"""Test when access token is expired and needs refresh.""" """Test when access token is expired and needs refresh."""
from app.config import settings from app.config import settings
@@ -145,7 +145,9 @@ class TestTestDropboxToken:
"name": {"display_name": "Test User"}, "name": {"display_name": "Test User"},
} }
mock_post.side_effect = [mock_response_401, mock_refresh_response, mock_success_response] mock_client = MagicMock()
mock_client.post.side_effect = [mock_response_401, mock_refresh_response, mock_success_response]
mock_client_cls.return_value.__aenter__.return_value = mock_client
with patch.object(settings, "dropbox_refresh_token", "token"): with patch.object(settings, "dropbox_refresh_token", "token"):
with patch.object(settings, "dropbox_app_key", "key"): with patch.object(settings, "dropbox_app_key", "key"):
@@ -153,8 +155,8 @@ class TestTestDropboxToken:
# Should refresh and succeed # Should refresh and succeed
pass pass
@patch("app.api.dropbox.requests.post") @patch("app.api.dropbox.httpx.AsyncClient")
def test_test_token_refresh_failed(self, mock_post): def test_test_token_refresh_failed(self, mock_client_cls):
"""Test when refresh token is invalid.""" """Test when refresh token is invalid."""
from app.config import settings from app.config import settings
@@ -167,7 +169,9 @@ class TestTestDropboxToken:
mock_refresh_response.status_code = 400 mock_refresh_response.status_code = 400
mock_refresh_response.text = "Invalid refresh token" mock_refresh_response.text = "Invalid refresh token"
mock_post.side_effect = [mock_response_401, mock_refresh_response] mock_client = MagicMock()
mock_client.post.side_effect = [mock_response_401, mock_refresh_response]
mock_client_cls.return_value.__aenter__.return_value = mock_client
with patch.object(settings, "dropbox_refresh_token", "token"): with patch.object(settings, "dropbox_refresh_token", "token"):
with patch.object(settings, "dropbox_app_key", "key"): with patch.object(settings, "dropbox_app_key", "key"):
@@ -175,8 +179,8 @@ class TestTestDropboxToken:
# Should return error with needs_reauth: True # Should return error with needs_reauth: True
pass pass
@patch("app.api.dropbox.requests.post") @patch("app.api.dropbox.httpx.AsyncClient")
def test_test_token_perpetual_token_info(self, mock_post): def test_test_token_perpetual_token_info(self, mock_client_cls):
"""Test that perpetual token info is returned.""" """Test that perpetual token info is returned."""
from app.config import settings from app.config import settings
@@ -186,7 +190,7 @@ class TestTestDropboxToken:
"email": "test@example.com", "email": "test@example.com",
"name": {"display_name": "Test User"}, "name": {"display_name": "Test User"},
} }
mock_post.return_value = mock_response mock_client_cls.return_value.__aenter__.return_value.post.return_value = mock_response
with patch.object(settings, "dropbox_refresh_token", "token"): with patch.object(settings, "dropbox_refresh_token", "token"):
with patch.object(settings, "dropbox_app_key", "key"): with patch.object(settings, "dropbox_app_key", "key"):
@@ -194,12 +198,12 @@ class TestTestDropboxToken:
# token_info should indicate never expires # token_info should indicate never expires
pass pass
@patch("app.api.dropbox.requests.post") @patch("app.api.dropbox.httpx.AsyncClient")
def test_test_token_exception_handling(self, mock_post): def test_test_token_exception_handling(self, mock_client_cls):
"""Test handling of exceptions.""" """Test handling of exceptions."""
from app.config import settings from app.config import settings
mock_post.side_effect = Exception("Network error") mock_client_cls.return_value.__aenter__.return_value.post.side_effect = Exception("Network error")
with patch.object(settings, "dropbox_refresh_token", "token"): with patch.object(settings, "dropbox_refresh_token", "token"):
with patch.object(settings, "dropbox_app_key", "key"): with patch.object(settings, "dropbox_app_key", "key"):
+8 -8
View File
@@ -7,9 +7,9 @@ Targets the remaining uncovered branches from the 97.03% baseline:
- 214 : test_google_drive_token generic connection error (not token-related) - 214 : test_google_drive_token generic connection error (not token-related)
- 302->306: get_google_drive_token_info credentials already valid (no refresh) - 302->306: get_google_drive_token_info credentials already valid (no refresh)
- 307->318: get_google_drive_token_info credentials have no expiry - 307->318: get_google_drive_token_info credentials have no expiry
- 395->397: save_dropbox_settings refresh_token falsy inside use_oauth block - 395->397: save_google_drive_settings refresh_token falsy inside use_oauth block
- 449->451: save_dropbox_settings refresh_token falsy in in-memory update - 449->451: save_google_drive_settings refresh_token falsy in in-memory update
- 468->470: save_dropbox_settings folder_id falsy in db-persist block - 468->470: save_google_drive_settings folder_id falsy in db-persist block
""" """
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -152,10 +152,10 @@ class TestGetTokenInfoCredentialsBranches:
@pytest.mark.unit @pytest.mark.unit
class TestSaveGoogleDriveSettingsFalsyFields: class TestSaveGoogleDriveSettingsFalsyFields:
"""Cover branches 395->397, 449->451, 468->470 in save_dropbox_settings. """Cover branches 395->397, 449->451, 468->470 in save_google_drive_settings.
Note: the Google Drive save endpoint is named save_dropbox_settings in the Note: the Google Drive save endpoint is named save_google_drive_settings in the
source (app/api/google_drive.py) due to an existing naming inconsistency. source (app/api/google_drive.py).
""" """
@patch("app.api.google_drive.settings") @patch("app.api.google_drive.settings")
@@ -167,7 +167,7 @@ class TestSaveGoogleDriveSettingsFalsyFields:
from starlette.requests import Request as StarletteRequest from starlette.requests import Request as StarletteRequest
from app.api.google_drive import save_dropbox_settings from app.api.google_drive import save_google_drive_settings
mock_request = MagicMock(spec=StarletteRequest) mock_request = MagicMock(spec=StarletteRequest)
mock_request.session = {} mock_request.session = {}
@@ -175,7 +175,7 @@ class TestSaveGoogleDriveSettingsFalsyFields:
with patch("app.api.google_drive.save_setting_to_db"): with patch("app.api.google_drive.save_setting_to_db"):
with patch("app.api.google_drive.notify_settings_updated"): with patch("app.api.google_drive.notify_settings_updated"):
result = await save_dropbox_settings( result = await save_google_drive_settings(
request=mock_request, request=mock_request,
refresh_token="", # falsy → branches 395->397 and 449->451 refresh_token="", # falsy → branches 395->397 and 449->451
client_id="cid", client_id="cid",
+7 -7
View File
@@ -75,8 +75,8 @@ class TestTestTokenRotation:
patch.object(settings, "onedrive_refresh_token", "old_token"), patch.object(settings, "onedrive_refresh_token", "old_token"),
patch.object(settings, "onedrive_client_id", "cid"), patch.object(settings, "onedrive_client_id", "cid"),
patch.object(settings, "onedrive_client_secret", "sec"), patch.object(settings, "onedrive_client_secret", "sec"),
patch("app.api.onedrive.os.path.join", return_value=str(env_file)), patch("app.utils.env_utils.os.path.join", return_value=str(env_file)),
patch("app.api.onedrive.os.path.exists", return_value=True), patch("app.utils.env_utils.os.path.exists", return_value=True),
patch("app.database.SessionLocal") as mock_session_local, patch("app.database.SessionLocal") as mock_session_local,
patch("app.api.onedrive.save_setting_to_db"), patch("app.api.onedrive.save_setting_to_db"),
patch("app.api.onedrive.notify_settings_updated"), patch("app.api.onedrive.notify_settings_updated"),
@@ -117,7 +117,7 @@ class TestTestTokenRotation:
patch.object(settings, "onedrive_refresh_token", "old_token"), patch.object(settings, "onedrive_refresh_token", "old_token"),
patch.object(settings, "onedrive_client_id", "cid"), patch.object(settings, "onedrive_client_id", "cid"),
patch.object(settings, "onedrive_client_secret", "sec"), patch.object(settings, "onedrive_client_secret", "sec"),
patch("app.api.onedrive.os.path.exists", return_value=False), patch("app.utils.env_utils.os.path.exists", return_value=False),
patch("app.database.SessionLocal") as mock_session_local, patch("app.database.SessionLocal") as mock_session_local,
patch("app.api.onedrive.save_setting_to_db"), patch("app.api.onedrive.save_setting_to_db"),
patch("app.api.onedrive.notify_settings_updated"), patch("app.api.onedrive.notify_settings_updated"),
@@ -157,7 +157,7 @@ class TestTestTokenRotation:
patch.object(settings, "onedrive_refresh_token", "old_token"), patch.object(settings, "onedrive_refresh_token", "old_token"),
patch.object(settings, "onedrive_client_id", "cid"), patch.object(settings, "onedrive_client_id", "cid"),
patch.object(settings, "onedrive_client_secret", "sec"), patch.object(settings, "onedrive_client_secret", "sec"),
patch("app.api.onedrive.os.path.exists", return_value=True), patch("app.utils.env_utils.os.path.exists", return_value=True),
patch("builtins.open", side_effect=PermissionError("Permission denied")), patch("builtins.open", side_effect=PermissionError("Permission denied")),
patch("app.database.SessionLocal") as mock_session_local, patch("app.database.SessionLocal") as mock_session_local,
patch("app.api.onedrive.save_setting_to_db"), patch("app.api.onedrive.save_setting_to_db"),
@@ -198,7 +198,7 @@ class TestTestTokenRotation:
patch.object(settings, "onedrive_refresh_token", "old_token"), patch.object(settings, "onedrive_refresh_token", "old_token"),
patch.object(settings, "onedrive_client_id", "cid"), patch.object(settings, "onedrive_client_id", "cid"),
patch.object(settings, "onedrive_client_secret", "sec"), patch.object(settings, "onedrive_client_secret", "sec"),
patch("app.api.onedrive.os.path.exists", return_value=False), patch("app.utils.env_utils.os.path.exists", return_value=False),
patch("app.database.SessionLocal", side_effect=Exception("DB error")), patch("app.database.SessionLocal", side_effect=Exception("DB error")),
): ):
response = client.get("/api/onedrive/test-token") response = client.get("/api/onedrive/test-token")
@@ -277,8 +277,8 @@ class TestTokenRotationEnvAppendLine:
patch.object(settings, "onedrive_refresh_token", "old_token"), patch.object(settings, "onedrive_refresh_token", "old_token"),
patch.object(settings, "onedrive_client_id", "cid"), patch.object(settings, "onedrive_client_id", "cid"),
patch.object(settings, "onedrive_client_secret", "sec"), patch.object(settings, "onedrive_client_secret", "sec"),
patch("app.api.onedrive.os.path.join", return_value=str(env_file)), patch("app.utils.env_utils.os.path.join", return_value=str(env_file)),
patch("app.api.onedrive.os.path.exists", return_value=True), patch("app.utils.env_utils.os.path.exists", return_value=True),
patch("app.database.SessionLocal") as mock_sl, patch("app.database.SessionLocal") as mock_sl,
patch("app.api.onedrive.save_setting_to_db"), patch("app.api.onedrive.save_setting_to_db"),
patch("app.api.onedrive.notify_settings_updated"), patch("app.api.onedrive.notify_settings_updated"),
+191
View File
@@ -0,0 +1,191 @@
"""Tests for the saved searches API (app/api/saved_searches.py)."""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models import SavedSearch
# ---------------------------------------------------------------------------
# Test data constants
# ---------------------------------------------------------------------------
_OWNER = "test_user@example.com"
_OTHER_OWNER = "other_user@example.com"
# ---------------------------------------------------------------------------
# Shared fixture helpers
# ---------------------------------------------------------------------------
@pytest.fixture()
def int_engine():
"""In-memory SQLite engine for integration tests."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def int_session(int_engine):
"""DB session scoped to one test."""
Session = sessionmaker(bind=int_engine)
session = Session()
yield session
session.close()
def _make_client(int_engine, owner_id: str = _OWNER):
"""Return a TestClient with *owner_id* injected as the authenticated user."""
from unittest.mock import patch
from app.main import app
def override_db():
Session = sessionmaker(bind=int_engine)
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = override_db
with patch("app.api.saved_searches._get_user_id", return_value=owner_id):
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
yield client
app.dependency_overrides.clear()
@pytest.fixture()
def int_client(int_engine):
"""TestClient authenticated as _OWNER."""
yield from _make_client(int_engine, _OWNER)
# ---------------------------------------------------------------------------
# CRUD tests
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestSavedSearchesAPI:
"""Tests for Saved Searches endpoints."""
def test_list_saved_searches_empty(self, int_client):
"""No saved searches returns empty list."""
resp = int_client.get("/api/saved-searches")
assert resp.status_code == 200
assert resp.json() == []
def test_create_saved_search(self, int_client):
"""Create a saved search and verify the response."""
payload = {"name": "My Invoices", "filters": {"tags": "invoice", "document_type": "Invoice"}}
resp = int_client.post("/api/saved-searches", json=payload)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "My Invoices"
assert data["filters"] == {"tags": "invoice", "document_type": "Invoice"}
assert "id" in data
def test_create_saved_search_invalid_filters(self, int_client):
"""Creating with invalid filters returns 422."""
# Missing filters parameter (or empty after sanitization)
payload = {"name": "My Invoices", "filters": {}}
resp = int_client.post("/api/saved-searches", json=payload)
assert resp.status_code == 422
# Invalid filters format
payload2 = {"name": "My Invoices", "filters": "not_a_dict"}
resp2 = int_client.post("/api/saved-searches", json=payload2)
assert resp2.status_code == 422
def test_create_saved_search_duplicate(self, int_client):
"""Creating a duplicate named search returns 409."""
payload = {"name": "Duplicate", "filters": {"q": "test"}}
int_client.post("/api/saved-searches", json=payload)
resp = int_client.post("/api/saved-searches", json=payload)
assert resp.status_code == 409
def test_create_saved_search_limit(self, int_client, int_session):
"""Exceeding MAX_SAVED_SEARCHES_PER_USER returns 409."""
# Create 50 searches using the API to ensure they are visible
for i in range(50):
resp = int_client.post("/api/saved-searches", json={"name": f"Search LIMIT {i}", "filters": {"q": "test"}})
assert resp.status_code == 201
payload = {"name": "One too many", "filters": {"q": "test"}}
resp = int_client.post("/api/saved-searches", json=payload)
assert resp.status_code == 409
def test_update_saved_search(self, int_client):
"""Update an existing saved search."""
payload = {"name": "Original Name", "filters": {"q": "test"}}
created = int_client.post("/api/saved-searches", json=payload).json()
search_id = created["id"]
update_payload = {"name": "Updated Name", "filters": {"tags": "new"}}
resp = int_client.put(f"/api/saved-searches/{search_id}", json=update_payload)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "Updated Name"
assert data["filters"] == {"tags": "new"}
def test_update_saved_search_not_found(self, int_client):
"""Updating a non-existent search returns 404."""
update_payload = {"name": "Updated Name"}
resp = int_client.put("/api/saved-searches/999", json=update_payload)
assert resp.status_code == 404
def test_update_saved_search_duplicate_name(self, int_client):
"""Updating name to an existing search name returns 409."""
payload1 = {"name": "Search 1", "filters": {"q": "a"}}
payload2 = {"name": "Search 2", "filters": {"q": "b"}}
int_client.post("/api/saved-searches", json=payload1)
created2 = int_client.post("/api/saved-searches", json=payload2).json()
search2_id = created2["id"]
update_payload = {"name": "Search 1"}
resp = int_client.put(f"/api/saved-searches/{search2_id}", json=update_payload)
assert resp.status_code == 409
def test_delete_saved_search(self, int_client, int_session):
"""Delete an existing search."""
payload = {"name": "To be deleted", "filters": {"q": "test"}}
created = int_client.post("/api/saved-searches", json=payload).json()
search_id = created["id"]
resp = int_client.delete(f"/api/saved-searches/{search_id}")
assert resp.status_code == 204
assert int_session.query(SavedSearch).filter(SavedSearch.id == search_id).first() is None
def test_delete_saved_search_not_found(self, int_client):
"""Deleting a non-existent search returns 404."""
resp = int_client.delete("/api/saved-searches/999")
assert resp.status_code == 404
def test_other_users_searches_isolated(self, int_engine, int_session):
"""Users only see and can only modify their own saved searches."""
int_session.add(SavedSearch(user_id=_OTHER_OWNER, name="Other Search", filters='{"q": "test"}'))
int_session.commit()
client = next(_make_client(int_engine, _OWNER))
resp = client.get("/api/saved-searches")
assert resp.status_code == 200
assert len(resp.json()) == 0
other_search = int_session.query(SavedSearch).first()
resp = client.put(f"/api/saved-searches/{other_search.id}", json={"name": "Hacked"})
assert resp.status_code == 404
resp = client.delete(f"/api/saved-searches/{other_search.id}")
assert resp.status_code == 404
+129 -6
View File
@@ -101,6 +101,43 @@ def _cleanup(app):
app.dependency_overrides.clear() app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Tests Auth Helper
# ---------------------------------------------------------------------------
class TestGetOwnerId:
"""Tests for the _get_owner_id dependency helper."""
@pytest.mark.unit
def test_get_owner_id_unauthenticated(self):
"""_get_owner_id should raise a 401 if the user is not authenticated."""
from unittest.mock import MagicMock, patch
from fastapi import HTTPException
from app.api.api_tokens import _get_owner_id
mock_request = MagicMock()
with patch("app.api.api_tokens.get_current_owner_id", return_value=None):
with pytest.raises(HTTPException) as exc_info:
_get_owner_id(mock_request)
assert exc_info.value.status_code == 401
assert exc_info.value.detail == "Not authenticated"
@pytest.mark.unit
def test_get_owner_id_authenticated(self):
"""_get_owner_id should return owner_id if user is authenticated."""
from unittest.mock import MagicMock, patch
from app.api.api_tokens import _get_owner_id
mock_request = MagicMock()
with patch("app.api.api_tokens.get_current_owner_id", return_value="owner123"):
owner_id = _get_owner_id(mock_request)
assert owner_id == "owner123"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tests Token CRUD # Tests Token CRUD
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -157,6 +194,46 @@ class TestTokenCreate:
finally: finally:
_cleanup(app) _cleanup(app)
@pytest.mark.unit
def test_create_token_database_error(self, tok_engine, tok_session):
"""Creating a token should rollback and raise 500 if database commit fails."""
from unittest.mock import patch
from sqlalchemy.orm import Session as SASession
from app.main import app
client = _make_client(tok_engine)
try:
# Wrap commit: flush first so changes are staged in the transaction,
# then raise to simulate a commit failure after data has been written.
def _fail_after_flush(self):
self.flush() # stage changes inside the open transaction
raise Exception("DB Failure")
# Spy on rollback so we can assert it is called.
rollback_called = False
real_rollback = SASession.rollback
def _spy_rollback(self):
nonlocal rollback_called
rollback_called = True
real_rollback(self)
with patch.object(SASession, "commit", _fail_after_flush):
with patch.object(SASession, "rollback", _spy_rollback):
resp = client.post("/api/api-tokens/", json={"name": "DB Error Create Test"})
assert resp.status_code == 500
# rollback() must have been called to undo the flushed changes.
assert rollback_called, "db.rollback() was not called after commit failure in create_token"
# After rollback the token must not exist in the database.
db_token = tok_session.query(ApiToken).filter(ApiToken.name == "DB Error Create Test").first()
assert db_token is None
finally:
_cleanup(app)
class TestTokenList: class TestTokenList:
"""Tests for GET /api/api-tokens/.""" """Tests for GET /api/api-tokens/."""
@@ -326,12 +403,10 @@ class TestTokenRevoke:
rollback_called = True rollback_called = True
real_rollback(self) real_rollback(self)
with ( with patch.object(SASession, "commit", _fail_after_flush):
patch.object(SASession, "commit", _fail_after_flush), with patch.object(SASession, "rollback", _spy_rollback):
patch.object(SASession, "rollback", _spy_rollback), resp = client.delete(f"/api/api-tokens/{token_id}")
): assert resp.status_code == 500
resp = client.delete(f"/api/api-tokens/{token_id}")
assert resp.status_code == 500
# rollback() must have been called to undo the flushed changes. # rollback() must have been called to undo the flushed changes.
assert rollback_called, "db.rollback() was not called after commit failure" assert rollback_called, "db.rollback() was not called after commit failure"
@@ -534,6 +609,44 @@ class TestTokenUtils:
tokens = {generate_api_token() for _ in range(100)} tokens = {generate_api_token() for _ in range(100)}
assert len(tokens) == 100 assert len(tokens) == 100
@pytest.mark.unit
def test_generate_api_token_length(self):
"""Generated tokens should have the exact expected length based on TOKEN_BYTES."""
import math
from app.api.api_tokens import TOKEN_BYTES, TOKEN_PREFIX, generate_api_token
# base64url encoding of N bytes without padding: ceil(N * 4 / 3) characters
expected_b64_len = math.ceil(TOKEN_BYTES * 4 / 3)
expected_total_len = len(TOKEN_PREFIX) + expected_b64_len
token = generate_api_token()
assert len(token) == expected_total_len
@pytest.mark.unit
def test_generate_api_token_charset(self):
"""Generated tokens should only contain URL-safe base64 characters and the prefix."""
import re
from app.api.api_tokens import TOKEN_PREFIX, generate_api_token
token = generate_api_token()
# Check it starts with prefix and the rest is base64url chars ([A-Za-z0-9_-])
pattern = f"^{re.escape(TOKEN_PREFIX)}[A-Za-z0-9_\\-]+$"
assert re.match(pattern, token) is not None
@pytest.mark.unit
def test_generate_api_token_uses_secrets(self):
"""Generated tokens should use secrets.token_urlsafe with the correct number of bytes."""
from unittest.mock import patch
from app.api.api_tokens import TOKEN_BYTES, TOKEN_PREFIX, generate_api_token
with patch("app.api.api_tokens.secrets.token_urlsafe", return_value="mocked_token") as mock_secrets:
token = generate_api_token()
mock_secrets.assert_called_once_with(TOKEN_BYTES)
assert token == f"{TOKEN_PREFIX}mocked_token"
@pytest.mark.unit @pytest.mark.unit
def test_hash_token_deterministic(self): def test_hash_token_deterministic(self):
"""Hashing the same token should always produce the same result.""" """Hashing the same token should always produce the same result."""
@@ -554,3 +667,13 @@ class TestTokenUtils:
# All characters should be valid lowercase hex digits. # All characters should be valid lowercase hex digits.
int(h, 16) int(h, 16)
assert h == h.lower() assert h == h.lower()
@pytest.mark.unit
def test_hash_token_known_value(self):
"""hash_token should return the exact expected PBKDF2 digest for a known input."""
from app.api.api_tokens import hash_token
# PBKDF2-HMAC-SHA256 with 100,000 iterations and salt b"api-token-v1"
token = "de_test_token_value"
expected_hash = "9b89d9adf2f390c75bf2fd0ff2bb5622ef5a9dce438354cce6e39f2f5401129e"
assert hash_token(token) == expected_hash
+35
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import hashlib import hashlib
import logging
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@@ -38,6 +39,40 @@ class TestGetCurrentUser:
result = get_current_user(mock_request) result = get_current_user(mock_request)
assert result is None assert result is None
def test_logs_debug_when_session_user_found(self, caplog):
"""Test that get_current_user emits a DEBUG log when session user is found."""
mock_request = MagicMock(spec=Request)
mock_request.session = {"user": {"id": "u1", "preferred_username": "alice"}}
mock_request.state = MagicMock(spec=[]) # no api_token_user attribute
with caplog.at_level(logging.DEBUG, logger="app.auth"):
get_current_user(mock_request)
assert any("[AUTH] get_current_user: resolved from session" in m for m in caplog.messages)
def test_logs_debug_when_no_user(self, caplog):
"""Test that get_current_user emits a DEBUG log when no user is present."""
mock_request = MagicMock(spec=Request)
mock_request.session = {}
mock_request.state = MagicMock(spec=[])
with caplog.at_level(logging.DEBUG, logger="app.auth"):
get_current_user(mock_request)
assert any("[AUTH] get_current_user: no user in session or API token" in m for m in caplog.messages)
def test_logs_debug_when_api_token_user(self, caplog):
"""Test that get_current_user emits a DEBUG log when resolved from API token."""
mock_request = MagicMock(spec=Request)
mock_request.state.api_token_user = {"id": "tok_user"}
mock_request.session = {}
with caplog.at_level(logging.DEBUG, logger="app.auth"):
result = get_current_user(mock_request)
assert result == {"id": "tok_user"}
assert any("[AUTH] get_current_user: resolved from API token" in m for m in caplog.messages)
@pytest.mark.unit @pytest.mark.unit
class TestGetGravatarUrl: class TestGetGravatarUrl:
+149 -2
View File
@@ -704,6 +704,18 @@ def _set_minimal_provider_settings(mock_settings):
class TestSettingsSyncAdditional: class TestSettingsSyncAdditional:
"""Additional tests for settings_sync covering reload failure branch.""" """Additional tests for settings_sync covering reload failure branch."""
def test_notify_settings_updated_redis_failure_logs_warning(self):
"""Test that a Redis failure is logged, not raised (lines 58-59)."""
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 connection failed")
with patch("app.utils.settings_sync.logger") as mock_logger:
notify_settings_updated()
mock_logger.warning.assert_any_call(
"Could not publish settings update to Redis: Redis connection failed"
)
def test_reload_failure_is_logged_not_raised(self): def test_reload_failure_is_logged_not_raised(self):
"""Test that a reload failure is logged, not raised (lines 71-72).""" """Test that a reload failure is logged, not raised (lines 71-72)."""
from app.utils.settings_sync import notify_settings_updated from app.utils.settings_sync import notify_settings_updated
@@ -711,8 +723,66 @@ class TestSettingsSyncAdditional:
with patch("app.utils.settings_sync.redis") as mock_redis_module: with patch("app.utils.settings_sync.redis") as mock_redis_module:
mock_redis_module.from_url.return_value = MagicMock() # Redis OK mock_redis_module.from_url.return_value = MagicMock() # Redis OK
with patch("app.utils.config_loader.reload_settings_from_db", side_effect=Exception("reload failed")): with patch("app.utils.config_loader.reload_settings_from_db", side_effect=Exception("reload failed")):
# Should not raise despite reload failure with patch("app.utils.settings_sync.logger") as mock_logger:
notify_settings_updated() # Should not raise despite reload failure
notify_settings_updated()
mock_logger.warning.assert_any_call("Could not reload in-process settings: reload failed")
def test_notify_settings_updated_ocr_failure_logs_warning(self):
"""Test that OCR check failure is logged, not raised (lines 82-83)."""
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.return_value = MagicMock() # Redis OK
with patch("app.utils.config_loader.reload_settings_from_db"):
with patch(
"app.utils.ocr_language_manager.ensure_ocr_languages_async", side_effect=Exception("OCR failed")
):
with patch("app.utils.settings_sync.logger") as mock_logger:
notify_settings_updated()
mock_logger.warning.assert_any_call("Could not schedule OCR language check: OCR failed")
def test_signal_handler_ocr_check_failure_logs_warning(self):
"""Test that signal handler logs warning if OCR language check fails on worker (lines 115-116)."""
from app.utils.settings_sync import register_settings_reload_signal
handler_fn = None
def capture_connect(fn=None, weak=None, **kwargs):
nonlocal handler_fn
if fn is not None:
handler_fn = fn
return fn
def decorator(func):
nonlocal handler_fn
handler_fn = func
return func
return decorator
with patch("app.utils.settings_sync.task_prerun") as mock_signal:
mock_signal.connect = capture_connect
register_settings_reload_signal()
assert handler_fn is not None
mock_redis = MagicMock()
mock_redis.get.return_value = b"1234567890.0"
with patch("app.utils.settings_sync.redis") as mock_redis_mod:
mock_redis_mod.from_url.return_value = mock_redis
with patch("app.utils.config_loader.reload_settings_from_db"):
with patch("app.utils.settings_sync._last_seen_version", ""):
with patch(
"app.utils.ocr_language_manager.ensure_ocr_languages_async",
side_effect=Exception("Worker OCR fail"),
):
with patch("app.utils.settings_sync.logger") as mock_logger:
handler_fn(sender=None)
mock_logger.warning.assert_any_call(
"Could not schedule OCR language check on worker: Worker OCR fail"
)
def test_signal_handler_reloads_on_version_change(self): def test_signal_handler_reloads_on_version_change(self):
"""Test the task_prerun signal handler reloads settings when version changes (lines 95-98).""" """Test the task_prerun signal handler reloads settings when version changes (lines 95-98)."""
@@ -820,6 +890,83 @@ class TestSettingsSyncAdditional:
# Should not raise # Should not raise
handler_fn(sender=None) handler_fn(sender=None)
def test_signal_handler_no_version_returned(self):
"""Test that handler does nothing if Redis returns None for version."""
from app.utils.settings_sync import register_settings_reload_signal
handler_fn = None
def capture_connect(fn=None, weak=None, **kwargs):
nonlocal handler_fn
if fn is not None:
handler_fn = fn
return fn
def decorator(func):
nonlocal handler_fn
handler_fn = func
return func
return decorator
with patch("app.utils.settings_sync.task_prerun") as mock_signal:
mock_signal.connect = capture_connect
register_settings_reload_signal()
assert handler_fn is not None
mock_redis = MagicMock()
mock_redis.get.return_value = None # Return None for version
with patch("app.utils.settings_sync.redis") as mock_redis_mod:
mock_redis_mod.from_url.return_value = mock_redis
with patch("app.utils.config_loader.reload_settings_from_db") as mock_reload:
handler_fn(sender=None)
mock_reload.assert_not_called()
def test_signal_handler_ocr_language_manager_exception(self):
"""Test that OCR language check exception inside handler is caught and logged."""
from app.utils.settings_sync import register_settings_reload_signal
handler_fn = None
def capture_connect(fn=None, weak=None, **kwargs):
nonlocal handler_fn
if fn is not None:
handler_fn = fn
return fn
def decorator(func):
nonlocal handler_fn
handler_fn = func
return func
return decorator
with patch("app.utils.settings_sync.task_prerun") as mock_signal:
mock_signal.connect = capture_connect
register_settings_reload_signal()
assert handler_fn is not None
mock_redis = MagicMock()
mock_redis.get.return_value = b"9999999.0" # New version
with patch("app.utils.settings_sync.redis") as mock_redis_mod:
mock_redis_mod.from_url.return_value = mock_redis
with patch("app.utils.config_loader.reload_settings_from_db") as mock_reload:
with patch("app.utils.settings_sync._last_seen_version", "111.0"):
with patch("app.utils.settings_sync.logger") as mock_logger:
with patch(
"app.utils.ocr_language_manager.ensure_ocr_languages_async",
side_effect=Exception("OCR failed"),
):
handler_fn(sender=None)
mock_reload.assert_called_once()
mock_logger.warning.assert_called_with(
"Could not schedule OCR language check on worker: OCR failed"
)
# =========================================================================== # ===========================================================================
# app/api/logs.py additional branches # app/api/logs.py additional branches
+1 -1
View File
@@ -345,7 +345,7 @@ class TestUploadErrorHandling:
def test_upload_disk_write_failure(self, client: TestClient): def test_upload_disk_write_failure(self, client: TestClient):
"""Test handling of disk write failures.""" """Test handling of disk write failures."""
with patch("builtins.open", side_effect=IOError("Disk full")): with patch("aiofiles.open", side_effect=IOError("Disk full")):
pdf_content = b"%PDF-1.4\n%EOF" pdf_content = b"%PDF-1.4\n%EOF"
response = client.post( response = client.post(
+285
View File
@@ -0,0 +1,285 @@
"""Tests for application logging configuration.
Validates that the LOG_LEVEL and DEBUG settings correctly control the
Python root-logger level and that the standard precedence rules are respected:
1. Explicit LOG_LEVEL always wins.
2. DEBUG=True without LOG_LEVEL effective DEBUG.
3. Neither set default INFO.
"""
import logging
import os
from unittest.mock import patch
import pytest
from app.config import Settings
@pytest.mark.unit
class TestLogLevelSetting:
"""Tests for the log_level config field."""
_BASE_KWARGS = {
"database_url": "sqlite:///test.db",
"redis_url": "redis://localhost:6379",
"openai_api_key": "test",
"azure_ai_key": "test",
"azure_region": "test",
"azure_endpoint": "https://test.example.com",
"gotenberg_url": "http://localhost:3000",
"workdir": "/tmp",
"auth_enabled": False,
"session_secret": None,
}
def test_log_level_default_is_info(self):
"""Test that log_level defaults to INFO."""
config = Settings(**self._BASE_KWARGS)
assert config.log_level.upper() == "INFO"
def test_log_level_accepts_debug(self):
"""Test that log_level accepts DEBUG."""
config = Settings(**self._BASE_KWARGS, log_level="DEBUG")
assert config.log_level.upper() == "DEBUG"
def test_log_level_accepts_warning(self):
"""Test that log_level accepts WARNING."""
config = Settings(**self._BASE_KWARGS, log_level="WARNING")
assert config.log_level.upper() == "WARNING"
def test_log_level_accepts_error(self):
"""Test that log_level accepts ERROR."""
config = Settings(**self._BASE_KWARGS, log_level="ERROR")
assert config.log_level.upper() == "ERROR"
def test_log_level_case_insensitive(self):
"""Test that log_level is case-insensitive in usage."""
config = Settings(**self._BASE_KWARGS, log_level="debug")
assert config.log_level.upper() == "DEBUG"
def test_debug_flag_defaults_to_false(self):
"""Test that debug defaults to False."""
config = Settings(**self._BASE_KWARGS)
assert config.debug is False
@pytest.mark.unit
class TestEffectiveLogLevel:
"""Tests for the effective log-level resolution logic in main.py."""
def test_debug_true_without_log_level_gives_debug(self):
"""When DEBUG=True and LOG_LEVEL is not set, effective level is DEBUG."""
with patch.dict(os.environ, {"DEBUG": "true"}, clear=False):
# Remove LOG_LEVEL from env if present
env = os.environ.copy()
env.pop("LOG_LEVEL", None)
with patch.dict(os.environ, env, clear=True):
s = Settings(
database_url="sqlite:///test.db",
redis_url="redis://localhost:6379",
openai_api_key="test",
azure_ai_key="test",
azure_region="test",
azure_endpoint="https://test.example.com",
gotenberg_url="http://localhost:3000",
workdir="/tmp",
auth_enabled=False,
session_secret=None,
debug=True,
)
explicit = os.environ.get("LOG_LEVEL")
if s.debug and explicit is None:
effective = "DEBUG"
else:
effective = s.log_level.upper()
assert effective == "DEBUG"
def test_explicit_log_level_overrides_debug(self):
"""When LOG_LEVEL is explicitly set, it takes precedence over DEBUG=True."""
with patch.dict(os.environ, {"LOG_LEVEL": "WARNING", "DEBUG": "true"}, clear=False):
s = Settings(
database_url="sqlite:///test.db",
redis_url="redis://localhost:6379",
openai_api_key="test",
azure_ai_key="test",
azure_region="test",
azure_endpoint="https://test.example.com",
gotenberg_url="http://localhost:3000",
workdir="/tmp",
auth_enabled=False,
session_secret=None,
debug=True,
log_level="WARNING",
)
explicit = os.environ.get("LOG_LEVEL")
if s.debug and explicit is None:
effective = "DEBUG"
else:
effective = s.log_level.upper()
assert effective == "WARNING"
def test_default_no_flags_gives_info(self):
"""When neither DEBUG nor LOG_LEVEL is set, effective level is INFO."""
env = os.environ.copy()
env.pop("LOG_LEVEL", None)
env.pop("DEBUG", None)
with patch.dict(os.environ, env, clear=True):
s = Settings(
database_url="sqlite:///test.db",
redis_url="redis://localhost:6379",
openai_api_key="test",
azure_ai_key="test",
azure_region="test",
azure_endpoint="https://test.example.com",
gotenberg_url="http://localhost:3000",
workdir="/tmp",
auth_enabled=False,
session_secret=None,
)
explicit = os.environ.get("LOG_LEVEL")
if s.debug and explicit is None:
effective = "DEBUG"
else:
effective = s.log_level.upper()
assert effective == "INFO"
def test_effective_level_maps_to_logging_constant(self):
"""The effective level string maps to a valid logging constant."""
for level_name in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
assert getattr(logging, level_name) is not None
@pytest.mark.unit
class TestLoggingConfiguredAtStartup:
"""Tests that the main module configures the root logger on import."""
def test_root_logger_has_handler(self):
"""Root logger should have at least one handler after app import."""
root = logging.getLogger()
assert len(root.handlers) > 0, "Root logger has no handlers after app startup"
def test_root_logger_level_is_not_warning_default(self):
"""Root logger should not be at the unconfigured WARNING default.
Our basicConfig(force=True) should have set it to at least INFO.
"""
root = logging.getLogger()
# The test env doesn't set DEBUG=True, so the level should be INFO (20)
assert root.level <= logging.INFO
@pytest.mark.unit
class TestJsonFormatter:
"""Tests for the _JsonFormatter used when LOG_FORMAT=json."""
def _make_formatter(self):
"""Lazily import the JSON formatter from main module."""
from app.main import _JsonFormatter
return _JsonFormatter()
def test_output_is_valid_json(self):
"""JSON formatter output should be parseable JSON."""
import json
fmt = self._make_formatter()
record = logging.LogRecord(
name="test.logger",
level=logging.INFO,
pathname="test.py",
lineno=42,
msg="Hello %s",
args=("world",),
exc_info=None,
)
result = fmt.format(record)
parsed = json.loads(result)
assert parsed["level"] == "INFO"
assert parsed["logger"] == "test.logger"
assert parsed["message"] == "Hello world"
assert parsed["lineno"] == 42
def test_includes_timestamp_iso8601(self):
"""JSON output should contain an ISO 8601 timestamp."""
import json
fmt = self._make_formatter()
record = logging.LogRecord(
name="x",
level=logging.DEBUG,
pathname="x.py",
lineno=1,
msg="test",
args=(),
exc_info=None,
)
parsed = json.loads(fmt.format(record))
assert "timestamp" in parsed
# ISO 8601 timestamps contain "T" and "+00:00" (UTC)
assert "T" in parsed["timestamp"]
def test_includes_exc_info_when_present(self):
"""JSON output should include exc_info when an exception is logged."""
import json
fmt = self._make_formatter()
try:
raise ValueError("boom") # noqa: TRY301
except ValueError:
import sys
record = logging.LogRecord(
name="x",
level=logging.ERROR,
pathname="x.py",
lineno=1,
msg="error",
args=(),
exc_info=sys.exc_info(),
)
parsed = json.loads(fmt.format(record))
assert "exc_info" in parsed
assert "ValueError" in parsed["exc_info"]
@pytest.mark.unit
class TestLogFormatSetting:
"""Tests for the log_format and log_syslog_* config fields."""
_BASE_KWARGS = {
"database_url": "sqlite:///test.db",
"redis_url": "redis://localhost:6379",
"openai_api_key": "test",
"azure_ai_key": "test",
"azure_region": "test",
"azure_endpoint": "https://test.example.com",
"gotenberg_url": "http://localhost:3000",
"workdir": "/tmp",
"auth_enabled": False,
"session_secret": None,
}
def test_log_format_default_is_text(self):
"""Test that log_format defaults to 'text'."""
config = Settings(**self._BASE_KWARGS)
assert config.log_format == "text"
def test_log_format_accepts_json(self):
"""Test that log_format accepts 'json'."""
config = Settings(**self._BASE_KWARGS, log_format="json")
assert config.log_format == "json"
def test_log_syslog_defaults(self):
"""Test syslog forwarding defaults."""
config = Settings(**self._BASE_KWARGS)
assert config.log_syslog_enabled is False
assert config.log_syslog_host == "localhost"
assert config.log_syslog_port == 514
assert config.log_syslog_protocol == "udp"
def test_log_syslog_can_be_enabled(self):
"""Test that syslog forwarding can be enabled."""
config = Settings(**self._BASE_KWARGS, log_syslog_enabled=True, log_syslog_host="syslog.example.com")
assert config.log_syslog_enabled is True
assert config.log_syslog_host == "syslog.example.com"
+55
View File
@@ -830,3 +830,58 @@ class TestUserNotificationService:
result = _send_email_notification({"smtp_host": "smtp.example.com"}, "Title", "Body") result = _send_email_notification({"smtp_host": "smtp.example.com"}, "Title", "Body")
assert result is False assert result is False
class TestBenchmark:
@pytest.mark.unit
def test_update_preferences_benchmark(self, notif_engine, notif_session):
import statistics
import time
from app.main import app
target = UserNotificationTarget(
owner_id=_OWNER,
channel_type="webhook",
name="My Webhook",
config=json.dumps({"url": "https://x.com"}),
)
notif_session.add(target)
notif_session.commit()
notif_session.refresh(target)
client = _make_client(notif_engine, _OWNER)
try:
items_count = 100
preferences = []
for i in range(items_count):
preferences.append(
{
"event_type": f"event.type.{i}",
"channel_type": "webhook",
"is_enabled": True,
"target_id": target.id,
}
)
payload = {"preferences": preferences}
# Warm up
client.put("/api/user-notifications/preferences", json=payload)
times = []
for _ in range(5):
# Alter the values a bit so it's a real update
for p in payload["preferences"]:
p["is_enabled"] = not p["is_enabled"]
start = time.time()
resp = client.put("/api/user-notifications/preferences", json=payload)
end = time.time()
assert resp.status_code == 200
times.append(end - start)
print(f"\nAverage time: {statistics.mean(times):.4f}s")
finally:
_cleanup(app)
+31 -1
View File
@@ -289,7 +289,8 @@ class TestNotifySettingsUpdated:
call_args = mock_redis_instance.set.call_args[0] call_args = mock_redis_instance.set.call_args[0]
assert call_args[0] == SETTINGS_VERSION_KEY assert call_args[0] == SETTINGS_VERSION_KEY
def test_does_not_raise_on_redis_failure(self): @patch("app.utils.settings_sync.logger")
def test_does_not_raise_on_redis_failure(self, mock_logger):
"""notify_settings_updated must not propagate Redis errors.""" """notify_settings_updated must not propagate Redis errors."""
from app.utils.settings_sync import notify_settings_updated from app.utils.settings_sync import notify_settings_updated
@@ -297,6 +298,35 @@ class TestNotifySettingsUpdated:
mock_redis_module.from_url.side_effect = Exception("Redis down") mock_redis_module.from_url.side_effect = Exception("Redis down")
# Should not raise # Should not raise
notify_settings_updated() notify_settings_updated()
mock_logger.warning.assert_any_call("Could not publish settings update to Redis: Redis down")
@patch("app.utils.settings_sync.logger")
def test_does_not_raise_on_reload_failure(self, mock_logger):
"""notify_settings_updated must not propagate settings reload errors."""
from app.utils.settings_sync import notify_settings_updated
with patch("app.utils.config_loader.reload_settings_from_db") as mock_reload:
mock_reload.side_effect = Exception("Reload error")
# We mock redis so that we skip over the redis block, and mock ensure_ocr_languages_async to prevent its side effects.
with patch("app.utils.settings_sync.redis"):
with patch("app.utils.ocr_language_manager.ensure_ocr_languages_async"):
# Should not raise
notify_settings_updated()
mock_logger.warning.assert_any_call("Could not reload in-process settings: Reload error")
@patch("app.utils.settings_sync.logger")
def test_does_not_raise_on_ocr_language_check_failure(self, mock_logger):
"""notify_settings_updated must not propagate OCR language check errors."""
from app.utils.settings_sync import notify_settings_updated
with patch("app.utils.ocr_language_manager.ensure_ocr_languages_async") as mock_ensure:
mock_ensure.side_effect = Exception("OCR error")
# We mock redis and reload_settings_from_db so we only test the OCR block failure.
with patch("app.utils.settings_sync.redis"):
with patch("app.utils.config_loader.reload_settings_from_db"):
# Should not raise
notify_settings_updated()
mock_logger.warning.assert_any_call("Could not schedule OCR language check: OCR error")
@pytest.mark.unit @pytest.mark.unit
+211
View File
@@ -0,0 +1,211 @@
from unittest.mock import MagicMock, patch
import pytest
import app.utils.settings_sync
from app.utils.settings_sync import (
SETTINGS_VERSION_KEY,
notify_settings_updated,
register_settings_reload_signal,
)
@pytest.fixture
def reset_last_seen_version():
"""Reset the global variable before and after tests."""
app.utils.settings_sync._last_seen_version = ""
yield
app.utils.settings_sync._last_seen_version = ""
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
@patch("app.utils.settings_sync.time.time", return_value=12345.0)
def test_notify_settings_updated_success(mock_time, mock_ensure_ocr, mock_reload, mock_redis):
# Setup mock redis instance
mock_redis_instance = MagicMock()
mock_redis.return_value = mock_redis_instance
notify_settings_updated()
# Verify redis calls
mock_redis.assert_called_once()
mock_redis_instance.set.assert_called_once_with(SETTINGS_VERSION_KEY, "12345.0")
# Verify other calls
mock_reload.assert_called_once()
mock_ensure_ocr.assert_called_once()
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_notify_settings_updated_redis_failure(mock_ensure_ocr, mock_reload, mock_redis, caplog):
# Setup mock redis to fail
mock_redis.side_effect = Exception("Redis connection failed")
notify_settings_updated()
# Verification: should continue and call reload and ocr despite redis failure
mock_reload.assert_called_once()
mock_ensure_ocr.assert_called_once()
assert "Could not publish settings update to Redis: Redis connection failed" in caplog.text
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_notify_settings_updated_reload_failure(mock_ensure_ocr, mock_reload, mock_redis, caplog):
# Setup reload to fail
mock_reload.side_effect = Exception("Reload failed")
mock_redis_instance = MagicMock()
mock_redis.return_value = mock_redis_instance
notify_settings_updated()
# Verification: redis should be called, reload fails, ocr should still be called
mock_redis_instance.set.assert_called_once()
mock_ensure_ocr.assert_called_once()
assert "Could not reload in-process settings: Reload failed" in caplog.text
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_notify_settings_updated_ocr_failure(mock_ensure_ocr, mock_reload, mock_redis, caplog):
# Setup ocr check to fail
mock_ensure_ocr.side_effect = Exception("OCR check failed")
mock_redis_instance = MagicMock()
mock_redis.return_value = mock_redis_instance
notify_settings_updated()
# Verification: all should be called, ocr failure logged
mock_redis_instance.set.assert_called_once()
mock_reload.assert_called_once()
assert "Could not schedule OCR language check: OCR check failed" in caplog.text
@patch("app.utils.settings_sync.task_prerun.connect")
def test_register_settings_reload_signal(mock_connect):
register_settings_reload_signal()
# It should register a signal with task_prerun
mock_connect.assert_called_once_with(weak=False)
@patch("app.utils.settings_sync.task_prerun.connect")
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_reload_if_stale_new_version(mock_ensure_ocr, mock_reload, mock_redis, mock_connect, reset_last_seen_version):
# Capture the registered callback
mock_decorator = MagicMock()
mock_connect.return_value = mock_decorator
register_settings_reload_signal()
mock_connect.assert_called_once_with(weak=False)
# Get the callback function
callback = mock_decorator.call_args[0][0]
# Setup redis to return a new version
mock_redis_instance = MagicMock()
mock_redis_instance.get.return_value = b"new_version"
mock_redis.return_value = mock_redis_instance
# Initial state check
assert app.utils.settings_sync._last_seen_version == ""
# Call the callback
callback(sender="test")
# Verification
mock_redis_instance.get.assert_called_once_with(SETTINGS_VERSION_KEY)
mock_reload.assert_called_once()
mock_ensure_ocr.assert_called_once()
assert app.utils.settings_sync._last_seen_version == "new_version"
@patch("app.utils.settings_sync.task_prerun.connect")
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_reload_if_stale_same_version(mock_ensure_ocr, mock_reload, mock_redis, mock_connect, reset_last_seen_version):
# Set initial state
app.utils.settings_sync._last_seen_version = "existing_version"
mock_decorator = MagicMock()
mock_connect.return_value = mock_decorator
register_settings_reload_signal()
callback = mock_decorator.call_args[0][0]
# Setup redis to return the SAME version
mock_redis_instance = MagicMock()
mock_redis_instance.get.return_value = b"existing_version"
mock_redis.return_value = mock_redis_instance
# Call the callback
callback(sender="test")
# Verification
mock_redis_instance.get.assert_called_once_with(SETTINGS_VERSION_KEY)
# Should NOT reload or check OCR
mock_reload.assert_not_called()
mock_ensure_ocr.assert_not_called()
assert app.utils.settings_sync._last_seen_version == "existing_version"
@patch("app.utils.settings_sync.task_prerun.connect")
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
def test_reload_if_stale_redis_error(mock_reload, mock_redis, mock_connect, reset_last_seen_version, caplog):
import logging
caplog.set_level(logging.DEBUG)
mock_decorator = MagicMock()
mock_connect.return_value = mock_decorator
register_settings_reload_signal()
callback = mock_decorator.call_args[0][0]
# Setup redis to fail
mock_redis.side_effect = Exception("Redis error")
# Call the callback
callback(sender="test")
# Verification
mock_reload.assert_not_called()
assert "Settings version check skipped: Redis error" in caplog.text
@patch("app.utils.settings_sync.task_prerun.connect")
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_reload_if_stale_ocr_error(
mock_ensure_ocr, mock_reload, mock_redis, mock_connect, reset_last_seen_version, caplog
):
mock_decorator = MagicMock()
mock_connect.return_value = mock_decorator
register_settings_reload_signal()
callback = mock_decorator.call_args[0][0]
# Setup redis to return a new version
mock_redis_instance = MagicMock()
mock_redis_instance.get.return_value = b"new_version"
mock_redis.return_value = mock_redis_instance
# Setup OCR check to fail
mock_ensure_ocr.side_effect = Exception("OCR error")
# Call the callback
callback(sender="test")
# Verification
mock_reload.assert_called_once()
mock_ensure_ocr.assert_called_once()
assert "Could not schedule OCR language check on worker: OCR error" in caplog.text
assert app.utils.settings_sync._last_seen_version == "new_version"
+28
View File
@@ -121,6 +121,34 @@ class TestExtractMetadataFromFile:
assert result == {} assert result == {}
def test_extract_metadata_from_pdf(self, tmp_path):
"""Test extracting metadata from a PDF file using pypdf when JSON is missing."""
import pypdf
file_path = tmp_path / "test.pdf"
# Create a test PDF with metadata
writer = pypdf.PdfWriter()
writer.add_blank_page(width=100, height=100)
writer.add_metadata(
{
"/Title": "Test Title",
"/Author": "Test Author",
"/Subject": "Test Document",
"/Keywords": "test, metadata, pypdf",
}
)
with open(file_path, "wb") as f:
writer.write(f)
result = extract_metadata_from_file(str(file_path))
# Check that the leading slash is stripped and keys/values match
assert result.get("Title") == "Test Title"
assert result.get("Author") == "Test Author"
assert result.get("Subject") == "Test Document"
assert result.get("Keywords") == "test, metadata, pypdf"
@pytest.mark.unit @pytest.mark.unit
class TestAttachLogo: class TestAttachLogo:
+208 -105
View File
@@ -2,10 +2,10 @@
Tests for URL-based file upload functionality Tests for URL-based file upload functionality
""" """
from unittest.mock import Mock, patch from unittest.mock import AsyncMock, MagicMock, Mock, patch
import httpx
import pytest import pytest
import requests
@pytest.mark.unit @pytest.mark.unit
@@ -165,17 +165,24 @@ class TestURLUploadValidation:
class TestURLUploadEndpoint: class TestURLUploadEndpoint:
"""Integration tests for URL upload endpoint""" """Integration tests for URL upload endpoint"""
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document") @patch("app.api.url_upload.process_document")
def test_process_url_requires_authentication(self, mock_process_document, mock_requests_get, client, monkeypatch): def test_process_url_requires_authentication(self, mock_process_document, mock_stream, client, monkeypatch):
"""Test that endpoint requires authentication when auth is enabled""" """Test that endpoint requires authentication when auth is enabled"""
# Mock successful download to prevent actual HTTP requests # Mock successful download to prevent actual HTTP requests
mock_response = Mock() mock_response = AsyncMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"} mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
mock_response.iter_content = Mock(return_value=[b"PDF content"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF content"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock() mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task # Mock Celery task
mock_task = Mock() mock_task = Mock()
@@ -192,17 +199,24 @@ class TestURLUploadEndpoint:
# (like no mocking). We're just checking the endpoint exists and is reachable. # (like no mocking). We're just checking the endpoint exists and is reachable.
assert response.status_code != 404 # Endpoint should exist assert response.status_code != 404 # Endpoint should exist
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document") @patch("app.api.url_upload.process_document")
def test_process_url_success(self, mock_process_document, mock_requests_get, client, tmp_path): def test_process_url_success(self, mock_process_document, mock_stream, client, tmp_path):
"""Test successful URL processing""" """Test successful URL processing"""
# Mock successful download # Mock successful download
mock_response = Mock() mock_response = AsyncMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"} mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
mock_response.iter_content = Mock(return_value=[b"PDF content here"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF content here"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock() mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task # Mock Celery task
mock_task = Mock() mock_task = Mock()
@@ -219,8 +233,8 @@ class TestURLUploadEndpoint:
assert "filename" in data assert "filename" in data
assert "size" in data assert "size" in data
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_blocks_private_ip(self, mock_requests_get, client): def test_process_url_blocks_private_ip(self, mock_stream, client):
"""Test that private IPs are blocked""" """Test that private IPs are blocked"""
response = client.post("/api/process-url", json={"url": "http://192.168.1.1/file.pdf"}) response = client.post("/api/process-url", json={"url": "http://192.168.1.1/file.pdf"})
@@ -229,10 +243,10 @@ class TestURLUploadEndpoint:
assert "private/internal" in data["detail"] assert "private/internal" in data["detail"]
# Should not make HTTP request # Should not make HTTP request
mock_requests_get.assert_not_called() mock_stream.assert_not_called()
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_blocks_localhost(self, mock_requests_get, client): def test_process_url_blocks_localhost(self, mock_stream, client):
"""Test that localhost is blocked""" """Test that localhost is blocked"""
response = client.post("/api/process-url", json={"url": "http://localhost/file.pdf"}) response = client.post("/api/process-url", json={"url": "http://localhost/file.pdf"})
@@ -241,10 +255,10 @@ class TestURLUploadEndpoint:
assert "private/internal" in data["detail"] assert "private/internal" in data["detail"]
# Should not make HTTP request # Should not make HTTP request
mock_requests_get.assert_not_called() mock_stream.assert_not_called()
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_blocks_metadata_endpoint(self, mock_requests_get, client): def test_process_url_blocks_metadata_endpoint(self, mock_stream, client):
"""Test that cloud metadata endpoints are blocked""" """Test that cloud metadata endpoints are blocked"""
response = client.post("/api/process-url", json={"url": "http://169.254.169.254/latest/meta-data/"}) response = client.post("/api/process-url", json={"url": "http://169.254.169.254/latest/meta-data/"})
@@ -254,17 +268,20 @@ class TestURLUploadEndpoint:
assert "metadata" in data["detail"] or "private" in data["detail"] assert "metadata" in data["detail"] or "private" in data["detail"]
# Should not make HTTP request # Should not make HTTP request
mock_requests_get.assert_not_called() mock_stream.assert_not_called()
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_invalid_file_type(self, mock_requests_get, client): def test_process_url_invalid_file_type(self, mock_stream, client):
"""Test that invalid file types are rejected""" """Test that invalid file types are rejected"""
# Mock response with executable content-type # Mock response with executable content-type
mock_response = Mock() mock_response = AsyncMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/x-executable"} mock_response.headers = {"Content-Type": "application/x-executable"}
mock_response.raise_for_status = Mock() mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
response = client.post("/api/process-url", json={"url": "https://example.com/malware.exe"}) response = client.post("/api/process-url", json={"url": "https://example.com/malware.exe"})
@@ -272,21 +289,24 @@ class TestURLUploadEndpoint:
data = response.json() data = response.json()
assert "Unsupported file type" in data["detail"] assert "Unsupported file type" in data["detail"]
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document") @patch("app.api.url_upload.process_document")
def test_process_url_file_too_large_by_header(self, mock_process_document, mock_requests_get, client): def test_process_url_file_too_large_by_header(self, mock_process_document, mock_stream, client):
"""Test that files too large are rejected based on Content-Length header""" """Test that files too large are rejected based on Content-Length header"""
from app.config import settings from app.config import settings
# Mock response with large content-length # Mock response with large content-length
mock_response = Mock() mock_response = AsyncMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.headers = { mock_response.headers = {
"Content-Type": "application/pdf", "Content-Type": "application/pdf",
"Content-Length": str(settings.max_upload_size + 1000), "Content-Length": str(settings.max_upload_size + 1000),
} }
mock_response.raise_for_status = Mock() mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
response = client.post("/api/process-url", json={"url": "https://example.com/huge.pdf"}) response = client.post("/api/process-url", json={"url": "https://example.com/huge.pdf"})
@@ -297,10 +317,10 @@ class TestURLUploadEndpoint:
# Should not process document # Should not process document
mock_process_document.delay.assert_not_called() mock_process_document.delay.assert_not_called()
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_timeout_error(self, mock_requests_get, client): def test_process_url_timeout_error(self, mock_stream, client):
"""Test handling of timeout errors""" """Test handling of timeout errors"""
mock_requests_get.side_effect = requests.exceptions.Timeout("Request timed out") mock_stream.side_effect = httpx.TimeoutException("Request timed out")
response = client.post("/api/process-url", json={"url": "https://example.com/slow.pdf"}) response = client.post("/api/process-url", json={"url": "https://example.com/slow.pdf"})
@@ -308,10 +328,10 @@ class TestURLUploadEndpoint:
data = response.json() data = response.json()
assert "timeout" in data["detail"].lower() assert "timeout" in data["detail"].lower()
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_connection_error(self, mock_requests_get, client): def test_process_url_connection_error(self, mock_stream, client):
"""Test handling of connection errors""" """Test handling of connection errors"""
mock_requests_get.side_effect = requests.exceptions.ConnectionError("Failed to connect") mock_stream.side_effect = httpx.ConnectError("Failed to connect")
response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"}) response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"})
@@ -319,15 +339,16 @@ class TestURLUploadEndpoint:
data = response.json() data = response.json()
assert "connect" in data["detail"].lower() assert "connect" in data["detail"].lower()
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_http_error_404(self, mock_requests_get, client): def test_process_url_http_error_404(self, mock_stream, client):
"""Test handling of HTTP 404 errors""" """Test handling of HTTP 404 errors"""
mock_response = Mock() # When raising HTTPStatusError, httpx requires request and response arguments
# For our code, we just need it to hit the exception handler and check status code
mock_request = MagicMock()
mock_response = MagicMock()
mock_response.status_code = 404 mock_response.status_code = 404
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(
"404 Not Found", response=mock_response mock_stream.side_effect = httpx.HTTPStatusError("404 Not Found", request=mock_request, response=mock_response)
)
mock_requests_get.return_value = mock_response
response = client.post("/api/process-url", json={"url": "https://example.com/notfound.pdf"}) response = client.post("/api/process-url", json={"url": "https://example.com/notfound.pdf"})
@@ -335,17 +356,24 @@ class TestURLUploadEndpoint:
data = response.json() data = response.json()
assert "HTTP error" in data["detail"] assert "HTTP error" in data["detail"]
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document") @patch("app.api.url_upload.process_document")
def test_process_url_with_custom_filename(self, mock_process_document, mock_requests_get, client, tmp_path): def test_process_url_with_custom_filename(self, mock_process_document, mock_stream, client, tmp_path):
"""Test URL upload with custom filename""" """Test URL upload with custom filename"""
# Mock successful download # Mock successful download
mock_response = Mock() mock_response = AsyncMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"} mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
mock_response.iter_content = Mock(return_value=[b"PDF content"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF content"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock() mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task # Mock Celery task
mock_task = Mock() mock_task = Mock()
@@ -361,17 +389,24 @@ class TestURLUploadEndpoint:
data = response.json() data = response.json()
assert data["filename"] == "my-document.pdf" assert data["filename"] == "my-document.pdf"
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document") @patch("app.api.url_upload.process_document")
def test_process_url_extracts_filename_from_url(self, mock_process_document, mock_requests_get, client, tmp_path): def test_process_url_extracts_filename_from_url(self, mock_process_document, mock_stream, client, tmp_path):
"""Test that filename is extracted from URL when not provided""" """Test that filename is extracted from URL when not provided"""
# Mock successful download # Mock successful download
mock_response = Mock() mock_response = AsyncMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"} mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
mock_response.iter_content = Mock(return_value=[b"PDF content"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF content"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock() mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task # Mock Celery task
mock_task = Mock() mock_task = Mock()
@@ -386,9 +421,9 @@ class TestURLUploadEndpoint:
# Should extract "annual-report.pdf" from URL # Should extract "annual-report.pdf" from URL
assert "annual-report" in data["filename"] assert "annual-report" in data["filename"]
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document") @patch("app.api.url_upload.process_document")
def test_process_url_file_size_during_download(self, mock_process_document, mock_requests_get, client): def test_process_url_file_size_during_download(self, mock_process_document, mock_stream, client):
"""Test that file size is checked during download""" """Test that file size is checked during download"""
from app.config import settings from app.config import settings
@@ -396,12 +431,19 @@ class TestURLUploadEndpoint:
large_chunk = b"x" * (settings.max_upload_size + 1000) large_chunk = b"x" * (settings.max_upload_size + 1000)
# Mock response without Content-Length header # Mock response without Content-Length header
mock_response = Mock() mock_response = AsyncMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf"} # No Content-Length mock_response.headers = {"Content-Type": "application/pdf"} # No Content-Length
mock_response.iter_content = Mock(return_value=[large_chunk])
async def mock_aiter_bytes(chunk_size=None):
yield large_chunk
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock() mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
response = client.post("/api/process-url", json={"url": "https://example.com/big.pdf"}) response = client.post("/api/process-url", json={"url": "https://example.com/big.pdf"})
@@ -412,10 +454,10 @@ class TestURLUploadEndpoint:
# Should not process document # Should not process document
mock_process_document.delay.assert_not_called() mock_process_document.delay.assert_not_called()
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_request_exception(self, mock_requests_get, client): def test_process_url_request_exception(self, mock_stream, client):
"""Test handling of generic RequestException""" """Test handling of generic RequestError"""
mock_requests_get.side_effect = requests.exceptions.RequestException("Generic request error") mock_stream.side_effect = httpx.RequestError("Generic request error")
response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"}) response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"})
@@ -423,16 +465,23 @@ class TestURLUploadEndpoint:
data = response.json() data = response.json()
assert "Failed to download file" in data["detail"] assert "Failed to download file" in data["detail"]
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_oserror_during_save(self, mock_requests_get, client, tmp_path, monkeypatch): def test_process_url_oserror_during_save(self, mock_stream, client, tmp_path, monkeypatch):
"""Test handling of OSError when saving file""" """Test handling of OSError when saving file"""
# Mock successful download # Mock successful download
mock_response = Mock() mock_response = AsyncMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"} mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
mock_response.iter_content = Mock(return_value=[b"PDF"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock() mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock workdir to a non-existent path to trigger OSError # Mock workdir to a non-existent path to trigger OSError
from app.config import settings from app.config import settings
@@ -450,17 +499,24 @@ class TestURLUploadEndpoint:
# Restore original workdir # Restore original workdir
monkeypatch.setattr(settings, "workdir", original_workdir) monkeypatch.setattr(settings, "workdir", original_workdir)
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document") @patch("app.api.url_upload.process_document")
def test_process_url_unexpected_exception(self, mock_process_document, mock_requests_get, client): def test_process_url_unexpected_exception(self, mock_process_document, mock_stream, client):
"""Test handling of unexpected exceptions""" """Test handling of unexpected exceptions"""
# Mock successful download but process_document.delay raises unexpected error # Mock successful download but process_document.delay raises unexpected error
mock_response = Mock() mock_response = AsyncMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"} mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
mock_response.iter_content = Mock(return_value=[b"PDF"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock() mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock process_document.delay to raise an unexpected exception # Mock process_document.delay to raise an unexpected exception
mock_process_document.delay.side_effect = RuntimeError("Unexpected processing error") mock_process_document.delay.side_effect = RuntimeError("Unexpected processing error")
@@ -471,17 +527,24 @@ class TestURLUploadEndpoint:
data = response.json() data = response.json()
assert "Unexpected error" in data["detail"] assert "Unexpected error" in data["detail"]
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document") @patch("app.api.url_upload.process_document")
def test_process_url_filename_without_extension(self, mock_process_document, mock_requests_get, client): def test_process_url_filename_without_extension(self, mock_process_document, mock_stream, client):
"""Test that files without extensions are handled correctly""" """Test that files without extensions are handled correctly"""
# Mock successful download # Mock successful download
mock_response = Mock() mock_response = AsyncMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"} mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
mock_response.iter_content = Mock(return_value=[b"PDF"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock() mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task # Mock Celery task
mock_task = Mock() mock_task = Mock()
@@ -496,17 +559,24 @@ class TestURLUploadEndpoint:
# Should still work, just without extension # Should still work, just without extension
assert data["task_id"] == "test-task-id" assert data["task_id"] == "test-task-id"
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document") @patch("app.api.url_upload.process_document")
def test_process_url_empty_path_uses_download(self, mock_process_document, mock_requests_get, client): def test_process_url_empty_path_uses_download(self, mock_process_document, mock_stream, client):
"""Test that empty URL path defaults to 'download' filename""" """Test that empty URL path defaults to 'download' filename"""
# Mock successful download # Mock successful download
mock_response = Mock() mock_response = AsyncMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"} mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
mock_response.iter_content = Mock(return_value=[b"PDF"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock() mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task # Mock Celery task
mock_task = Mock() mock_task = Mock()
@@ -560,17 +630,24 @@ class TestURLUploadEndpoint:
# Link-local address # Link-local address
assert is_private_ip("169.254.1.1") is True assert is_private_ip("169.254.1.1") is True
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document") @patch("app.api.url_upload.process_document")
def test_process_url_sanitizes_dangerous_filename(self, mock_process_document, mock_requests_get, client): def test_process_url_sanitizes_dangerous_filename(self, mock_process_document, mock_stream, client):
"""Test that dangerous filenames are sanitized""" """Test that dangerous filenames are sanitized"""
# Mock successful download # Mock successful download
mock_response = Mock() mock_response = AsyncMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"} mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
mock_response.iter_content = Mock(return_value=[b"PDF"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock() mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task # Mock Celery task
mock_task = Mock() mock_task = Mock()
@@ -671,18 +748,25 @@ class TestURLUploadCoverageGaps:
assert validate_file_type("", "filename_without_extension") is False assert validate_file_type("", "filename_without_extension") is False
@patch("app.api.url_upload.sanitize_filename", return_value="") @patch("app.api.url_upload.sanitize_filename", return_value="")
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document") @patch("app.api.url_upload.process_document")
def test_process_url_sanitize_filename_returns_empty( def test_process_url_sanitize_filename_returns_empty(
self, mock_process_document, mock_requests_get, mock_sanitize, client self, mock_process_document, mock_stream, mock_sanitize, client
): ):
"""Test that when sanitize_filename returns empty string, filename defaults to 'download' (line 177)""" """Test that when sanitize_filename returns empty string, filename defaults to 'download' (line 177)"""
mock_response = Mock() mock_response = AsyncMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"} mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
mock_response.iter_content = Mock(return_value=[b"PDF content"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF content"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock() mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
mock_task = Mock() mock_task = Mock()
mock_task.id = "test-task-id-sanitize" mock_task.id = "test-task-id-sanitize"
@@ -695,17 +779,26 @@ class TestURLUploadCoverageGaps:
# When sanitize_filename returns "", safe_filename defaults to "download" # When sanitize_filename returns "", safe_filename defaults to "download"
assert data["filename"] == "download" assert data["filename"] == "download"
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document") @patch("app.api.url_upload.process_document")
def test_process_url_skips_empty_chunks(self, mock_process_document, mock_requests_get, client): def test_process_url_skips_empty_chunks(self, mock_process_document, mock_stream, client):
"""Test that empty bytes chunks are skipped during download (line 234->233 branch)""" """Test that empty bytes chunks are skipped during download (line 234->233 branch)"""
mock_response = Mock() mock_response = AsyncMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf"} mock_response.headers = {"Content-Type": "application/pdf"}
# Mix empty bytes (falsy) with real content - covers the `if chunk:` False branch # Mix empty bytes (falsy) with real content - covers the `if chunk:` False branch
mock_response.iter_content = Mock(return_value=[b"", b"PDF content", b""])
async def mock_aiter_bytes(chunk_size=None):
yield b""
yield b"PDF content"
yield b""
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock() mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
mock_task = Mock() mock_task = Mock()
mock_task.id = "test-task-id-chunks" mock_task.id = "test-task-id-chunks"
@@ -719,9 +812,9 @@ class TestURLUploadCoverageGaps:
@patch("app.api.url_upload.os.remove") @patch("app.api.url_upload.os.remove")
@patch("app.api.url_upload.os.path.exists", return_value=True) @patch("app.api.url_upload.os.path.exists", return_value=True)
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_oserror_cleanup_removes_existing_file( def test_process_url_oserror_cleanup_removes_existing_file(
self, mock_requests_get, mock_exists, mock_remove, client, tmp_path, monkeypatch self, mock_stream, mock_exists, mock_remove, client, tmp_path, monkeypatch
): ):
"""Test OSError handler removes the partial file when it exists (line 285)""" """Test OSError handler removes the partial file when it exists (line 285)"""
import os import os
@@ -735,12 +828,19 @@ class TestURLUploadCoverageGaps:
monkeypatch.setattr(settings, "workdir", str(non_existent)) monkeypatch.setattr(settings, "workdir", str(non_existent))
mock_response = Mock() mock_response = AsyncMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"} mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
mock_response.iter_content = Mock(return_value=[b"PDF"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock() mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"}) response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"})
@@ -750,14 +850,17 @@ class TestURLUploadCoverageGaps:
mock_remove.assert_called_once() mock_remove.assert_called_once()
@patch("app.api.url_upload.validate_file_type", side_effect=ValueError("unexpected internal error")) @patch("app.api.url_upload.validate_file_type", side_effect=ValueError("unexpected internal error"))
@patch("app.api.url_upload.requests.get") @patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_unexpected_exception_with_no_file_created(self, mock_requests_get, mock_validate, client): def test_process_url_unexpected_exception_with_no_file_created(self, mock_stream, mock_validate, client):
"""Test unexpected exception before target_path is assigned; no file cleanup attempted (line 291->293)""" """Test unexpected exception before target_path is assigned; no file cleanup attempted (line 291->293)"""
mock_response = Mock() mock_response = AsyncMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf"} mock_response.headers = {"Content-Type": "application/pdf"}
mock_response.raise_for_status = Mock() mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"}) response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"})