diff --git a/tests/test_upload_to_sharepoint.py b/tests/test_upload_to_sharepoint.py
new file mode 100644
index 00000000..0a67f3cb
--- /dev/null
+++ b/tests/test_upload_to_sharepoint.py
@@ -0,0 +1,554 @@
+"""
+Tests for app/tasks/upload_to_sharepoint.py module.
+
+Covers get_sharepoint_token, resolve_sharepoint_drive,
+create_sharepoint_upload_session, upload_large_file_sharepoint,
+and upload_to_sharepoint Celery task.
+"""
+
+from unittest.mock import Mock, patch
+
+import pytest
+
+
+@pytest.mark.unit
+class TestGetSharepointToken:
+ """Tests for get_sharepoint_token function."""
+
+ @patch("app.tasks.upload_to_sharepoint.msal.ConfidentialClientApplication")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_refresh_token_flow(self, mock_settings, mock_msal):
+ """Test token acquisition using refresh token."""
+ from app.tasks.upload_to_sharepoint import get_sharepoint_token
+
+ mock_settings.sharepoint_client_id = "client-id"
+ mock_settings.sharepoint_client_secret = "client-secret"
+ mock_settings.sharepoint_refresh_token = "refresh-token"
+ mock_settings.sharepoint_tenant_id = "common"
+
+ mock_app = Mock()
+ mock_app.acquire_token_by_refresh_token.return_value = {
+ "access_token": "new-access-token",
+ }
+ mock_msal.return_value = mock_app
+
+ token = get_sharepoint_token()
+ assert token == "new-access-token"
+
+ @patch("app.tasks.upload_to_sharepoint.msal.ConfidentialClientApplication")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_refresh_token_updates_new_token(self, mock_settings, mock_msal):
+ """Test that a new refresh token updates settings."""
+ from app.tasks.upload_to_sharepoint import get_sharepoint_token
+
+ mock_settings.sharepoint_client_id = "client-id"
+ mock_settings.sharepoint_client_secret = "client-secret"
+ mock_settings.sharepoint_refresh_token = "old-refresh-token"
+ mock_settings.sharepoint_tenant_id = "common"
+
+ mock_app = Mock()
+ mock_app.acquire_token_by_refresh_token.return_value = {
+ "access_token": "access-token",
+ "refresh_token": "new-refresh-token",
+ }
+ mock_msal.return_value = mock_app
+
+ get_sharepoint_token()
+ assert mock_settings.sharepoint_refresh_token == "new-refresh-token"
+
+ @patch("app.tasks.upload_to_sharepoint.msal.ConfidentialClientApplication")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_refresh_token_failure(self, mock_settings, mock_msal):
+ """Test error handling when refresh token fails."""
+ from app.tasks.upload_to_sharepoint import get_sharepoint_token
+
+ mock_settings.sharepoint_client_id = "client-id"
+ mock_settings.sharepoint_client_secret = "client-secret"
+ mock_settings.sharepoint_refresh_token = "expired-token"
+ mock_settings.sharepoint_tenant_id = "common"
+
+ mock_app = Mock()
+ mock_app.acquire_token_by_refresh_token.return_value = {
+ "error": "invalid_grant",
+ "error_description": "Token expired",
+ }
+ mock_msal.return_value = mock_app
+
+ with pytest.raises(ValueError, match="Failed to get SharePoint access token"):
+ get_sharepoint_token()
+
+ @patch("app.tasks.upload_to_sharepoint.msal.ConfidentialClientApplication")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_client_credentials_flow(self, mock_settings, mock_msal):
+ """Test token acquisition using client credentials (org accounts)."""
+ from app.tasks.upload_to_sharepoint import get_sharepoint_token
+
+ mock_settings.sharepoint_client_id = "client-id"
+ mock_settings.sharepoint_client_secret = "client-secret"
+ mock_settings.sharepoint_refresh_token = ""
+ mock_settings.sharepoint_tenant_id = "org-tenant-id"
+
+ mock_app = Mock()
+ mock_app.acquire_token_for_client.return_value = {
+ "access_token": "client-cred-token",
+ }
+ mock_msal.return_value = mock_app
+
+ token = get_sharepoint_token()
+ assert token == "client-cred-token"
+
+ @patch("app.tasks.upload_to_sharepoint.msal.ConfidentialClientApplication")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_client_credentials_failure(self, mock_settings, mock_msal):
+ """Test error handling when client credentials flow fails."""
+ from app.tasks.upload_to_sharepoint import get_sharepoint_token
+
+ mock_settings.sharepoint_client_id = "client-id"
+ mock_settings.sharepoint_client_secret = "client-secret"
+ mock_settings.sharepoint_refresh_token = ""
+ mock_settings.sharepoint_tenant_id = "org-tenant-id"
+
+ mock_app = Mock()
+ mock_app.acquire_token_for_client.return_value = {
+ "error": "unauthorized_client",
+ "error_description": "Not authorized",
+ }
+ mock_msal.return_value = mock_app
+
+ with pytest.raises(ValueError, match="Failed to get SharePoint access token"):
+ get_sharepoint_token()
+
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_missing_client_id(self, mock_settings):
+ """Test error when client ID is missing."""
+ from app.tasks.upload_to_sharepoint import get_sharepoint_token
+
+ mock_settings.sharepoint_client_id = ""
+ mock_settings.sharepoint_client_secret = "secret"
+
+ with pytest.raises(ValueError, match="client ID and client secret"):
+ get_sharepoint_token()
+
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_no_refresh_token_common_tenant(self, mock_settings):
+ """Test error for common tenant without refresh token."""
+ from app.tasks.upload_to_sharepoint import get_sharepoint_token
+
+ mock_settings.sharepoint_client_id = "client-id"
+ mock_settings.sharepoint_client_secret = "secret"
+ mock_settings.sharepoint_refresh_token = ""
+ mock_settings.sharepoint_tenant_id = "common"
+
+ with pytest.raises(ValueError, match="either a refresh token or a non-'common' tenant ID"):
+ get_sharepoint_token()
+
+
+@pytest.mark.unit
+class TestResolveSharepointDrive:
+ """Tests for resolve_sharepoint_drive function."""
+
+ @patch("app.tasks.upload_to_sharepoint.requests.get")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_successful_resolution(self, mock_settings, mock_get):
+ """Test successful site and drive resolution."""
+ from app.tasks.upload_to_sharepoint import resolve_sharepoint_drive
+
+ mock_settings.http_request_timeout = 30
+
+ site_resp = Mock()
+ site_resp.status_code = 200
+ site_resp.json.return_value = {"id": "site-id-123"}
+
+ drives_resp = Mock()
+ drives_resp.status_code = 200
+ drives_resp.json.return_value = {
+ "value": [
+ {"id": "drive-1", "name": "Documents"},
+ {"id": "drive-2", "name": "Site Assets"},
+ ]
+ }
+
+ mock_get.side_effect = [site_resp, drives_resp]
+
+ site_id, drive_id = resolve_sharepoint_drive(
+ "access-token", "https://tenant.sharepoint.com/sites/mysite", "Documents"
+ )
+
+ assert site_id == "site-id-123"
+ assert drive_id == "drive-1"
+
+ @patch("app.tasks.upload_to_sharepoint.requests.get")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_library_not_found(self, mock_settings, mock_get):
+ """Test error when document library is not found."""
+ from app.tasks.upload_to_sharepoint import resolve_sharepoint_drive
+
+ mock_settings.http_request_timeout = 30
+
+ site_resp = Mock()
+ site_resp.status_code = 200
+ site_resp.json.return_value = {"id": "site-id-123"}
+
+ drives_resp = Mock()
+ drives_resp.status_code = 200
+ drives_resp.json.return_value = {
+ "value": [
+ {"id": "drive-1", "name": "Documents"},
+ ]
+ }
+
+ mock_get.side_effect = [site_resp, drives_resp]
+
+ with pytest.raises(RuntimeError, match="not found on site"):
+ resolve_sharepoint_drive("access-token", "https://tenant.sharepoint.com/sites/mysite", "NonExistentLibrary")
+
+ @patch("app.tasks.upload_to_sharepoint.requests.get")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_site_resolution_failure(self, mock_settings, mock_get):
+ """Test error when site resolution fails."""
+ from app.tasks.upload_to_sharepoint import resolve_sharepoint_drive
+
+ mock_settings.http_request_timeout = 30
+
+ site_resp = Mock()
+ site_resp.status_code = 404
+ site_resp.text = "Site not found"
+
+ mock_get.return_value = site_resp
+
+ with pytest.raises(RuntimeError, match="Failed to resolve SharePoint site"):
+ resolve_sharepoint_drive("access-token", "https://tenant.sharepoint.com/sites/nonexistent", "Documents")
+
+ def test_invalid_site_url(self):
+ """Test error with invalid site URL."""
+ from app.tasks.upload_to_sharepoint import resolve_sharepoint_drive
+
+ with pytest.raises(ValueError, match="Invalid SharePoint site URL"):
+ resolve_sharepoint_drive("access-token", "not-a-url", "Documents")
+
+ @patch("app.tasks.upload_to_sharepoint.requests.get")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_case_insensitive_library_match(self, mock_settings, mock_get):
+ """Test that library name matching is case-insensitive."""
+ from app.tasks.upload_to_sharepoint import resolve_sharepoint_drive
+
+ mock_settings.http_request_timeout = 30
+
+ site_resp = Mock()
+ site_resp.status_code = 200
+ site_resp.json.return_value = {"id": "site-id"}
+
+ drives_resp = Mock()
+ drives_resp.status_code = 200
+ drives_resp.json.return_value = {
+ "value": [
+ {"id": "drive-1", "name": "Shared Documents"},
+ ]
+ }
+
+ mock_get.side_effect = [site_resp, drives_resp]
+
+ site_id, drive_id = resolve_sharepoint_drive(
+ "access-token", "https://tenant.sharepoint.com/sites/mysite", "shared documents"
+ )
+
+ assert drive_id == "drive-1"
+
+
+@pytest.mark.unit
+class TestCreateSharepointUploadSession:
+ """Tests for create_sharepoint_upload_session function."""
+
+ @patch("app.tasks.upload_to_sharepoint.requests.post")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_successful_session_creation(self, mock_settings, mock_post):
+ """Test successful upload session creation."""
+ from app.tasks.upload_to_sharepoint import create_sharepoint_upload_session
+
+ mock_settings.http_request_timeout = 30
+
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {"uploadUrl": "https://upload.url/session123"}
+ mock_post.return_value = mock_response
+
+ url = create_sharepoint_upload_session("test.pdf", "Uploads", "drive-id", "site-id", "access-token")
+
+ assert url == "https://upload.url/session123"
+
+ @patch("app.tasks.upload_to_sharepoint.requests.post")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_session_without_folder(self, mock_settings, mock_post):
+ """Test upload session creation without folder path."""
+ from app.tasks.upload_to_sharepoint import create_sharepoint_upload_session
+
+ mock_settings.http_request_timeout = 30
+
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {"uploadUrl": "https://upload.url/session456"}
+ mock_post.return_value = mock_response
+
+ url = create_sharepoint_upload_session("test.pdf", None, "drive-id", "site-id", "access-token")
+
+ assert url == "https://upload.url/session456"
+
+ @patch("app.tasks.upload_to_sharepoint.requests.post")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_session_creation_failure(self, mock_settings, mock_post):
+ """Test error handling when session creation fails."""
+ from app.tasks.upload_to_sharepoint import create_sharepoint_upload_session
+
+ mock_settings.http_request_timeout = 30
+
+ mock_response = Mock()
+ mock_response.status_code = 403
+ mock_response.text = "Access denied"
+ mock_post.return_value = mock_response
+
+ with pytest.raises(RuntimeError, match="Failed to create SharePoint upload session"):
+ create_sharepoint_upload_session("test.pdf", "Uploads", "drive-id", "site-id", "access-token")
+
+ @patch("app.tasks.upload_to_sharepoint.requests.post")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_url_encoding_special_characters(self, mock_settings, mock_post):
+ """Test that special characters in folder path are URL-encoded."""
+ from app.tasks.upload_to_sharepoint import create_sharepoint_upload_session
+
+ mock_settings.http_request_timeout = 30
+
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {"uploadUrl": "https://upload.url/session"}
+ mock_post.return_value = mock_response
+
+ create_sharepoint_upload_session("file with spaces.pdf", "My Documents/Uploads", "drive-id", "site-id", "token")
+
+ call_url = mock_post.call_args[0][0]
+ assert "My%20Documents" in call_url
+ assert "file%20with%20spaces.pdf" in call_url
+
+
+@pytest.mark.unit
+class TestUploadLargeFileSharepoint:
+ """Tests for upload_large_file_sharepoint function."""
+
+ @patch("app.tasks.upload_to_sharepoint.requests.put")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_small_single_chunk_upload(self, mock_settings, mock_put, tmp_path):
+ """Test uploading a file that fits in a single chunk."""
+ from app.tasks.upload_to_sharepoint import upload_large_file_sharepoint
+
+ mock_settings.http_request_timeout = 30
+
+ test_file = tmp_path / "small.pdf"
+ test_file.write_bytes(b"small content")
+
+ mock_response = Mock()
+ mock_response.status_code = 201
+ mock_response.json.return_value = {"id": "file123", "name": "small.pdf"}
+ mock_put.return_value = mock_response
+
+ result = upload_large_file_sharepoint(str(test_file), "https://upload.url/session")
+
+ assert result["id"] == "file123"
+
+ @patch("app.tasks.upload_to_sharepoint.time.sleep")
+ @patch("app.tasks.upload_to_sharepoint.requests.put")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_chunk_upload_retry_on_failure(self, mock_settings, mock_put, mock_sleep, tmp_path):
+ """Test retry logic when a chunk upload fails."""
+ from app.tasks.upload_to_sharepoint import upload_large_file_sharepoint
+
+ mock_settings.http_request_timeout = 30
+
+ test_file = tmp_path / "test.pdf"
+ test_file.write_bytes(b"test content")
+
+ mock_fail = Mock()
+ mock_fail.status_code = 500
+
+ mock_success = Mock()
+ mock_success.status_code = 201
+ mock_success.json.return_value = {"id": "file123"}
+
+ mock_put.side_effect = [mock_fail, mock_success]
+
+ result = upload_large_file_sharepoint(str(test_file), "https://upload.url/session")
+
+ assert result["id"] == "file123"
+
+ @patch("app.tasks.upload_to_sharepoint.time.sleep")
+ @patch("app.tasks.upload_to_sharepoint.requests.put")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_chunk_upload_retry_on_exception(self, mock_settings, mock_put, mock_sleep, tmp_path):
+ """Test retry logic when an exception occurs during upload."""
+ from app.tasks.upload_to_sharepoint import upload_large_file_sharepoint
+
+ mock_settings.http_request_timeout = 30
+
+ test_file = tmp_path / "test.pdf"
+ test_file.write_bytes(b"test content")
+
+ mock_success = Mock()
+ mock_success.status_code = 201
+ mock_success.json.return_value = {"id": "file123"}
+
+ mock_put.side_effect = [Exception("Network error"), mock_success]
+
+ result = upload_large_file_sharepoint(str(test_file), "https://upload.url/session")
+
+ assert result["id"] == "file123"
+
+ @patch("app.tasks.upload_to_sharepoint.time.sleep")
+ @patch("app.tasks.upload_to_sharepoint.requests.put")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_all_retries_exhausted(self, mock_settings, mock_put, mock_sleep, tmp_path):
+ """Test that exhausting all retries raises an exception."""
+ from app.tasks.upload_to_sharepoint import upload_large_file_sharepoint
+
+ mock_settings.http_request_timeout = 30
+
+ test_file = tmp_path / "test.pdf"
+ test_file.write_bytes(b"test content")
+
+ mock_fail = Mock()
+ mock_fail.status_code = 500
+ mock_fail.text = "Server Error"
+ mock_put.return_value = mock_fail
+
+ with pytest.raises(RuntimeError, match="Failed to upload chunk"):
+ upload_large_file_sharepoint(str(test_file), "https://upload.url/session")
+
+
+@pytest.mark.unit
+class TestUploadToSharepoint:
+ """Tests for upload_to_sharepoint Celery task."""
+
+ @patch("app.tasks.upload_to_sharepoint.log_task_progress")
+ def test_file_not_found(self, mock_log):
+ """Test that missing file raises FileNotFoundError."""
+ from app.tasks.upload_to_sharepoint import upload_to_sharepoint
+
+ with pytest.raises(FileNotFoundError):
+ upload_to_sharepoint.__wrapped__("/nonexistent/file.pdf", file_id=1)
+
+ @patch("app.tasks.upload_to_sharepoint.log_task_progress")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_missing_client_id(self, mock_settings, mock_log, tmp_path):
+ """Test error when SharePoint client ID is not configured."""
+ from app.tasks.upload_to_sharepoint import upload_to_sharepoint
+
+ mock_settings.sharepoint_client_id = ""
+
+ test_file = tmp_path / "test.pdf"
+ test_file.write_bytes(b"test content")
+
+ with pytest.raises(ValueError, match="client ID is not configured"):
+ upload_to_sharepoint.__wrapped__(str(test_file), file_id=1)
+
+ @patch("app.tasks.upload_to_sharepoint.log_task_progress")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_missing_site_url(self, mock_settings, mock_log, tmp_path):
+ """Test error when SharePoint site URL is not configured."""
+ from app.tasks.upload_to_sharepoint import upload_to_sharepoint
+
+ mock_settings.sharepoint_client_id = "client-id"
+ mock_settings.sharepoint_site_url = ""
+
+ test_file = tmp_path / "test.pdf"
+ test_file.write_bytes(b"test content")
+
+ with pytest.raises(ValueError, match="site URL is not configured"):
+ upload_to_sharepoint.__wrapped__(str(test_file), file_id=1)
+
+ @patch("app.tasks.upload_to_sharepoint.upload_large_file_sharepoint")
+ @patch("app.tasks.upload_to_sharepoint.create_sharepoint_upload_session")
+ @patch("app.tasks.upload_to_sharepoint.resolve_sharepoint_drive")
+ @patch("app.tasks.upload_to_sharepoint.get_sharepoint_token")
+ @patch("app.tasks.upload_to_sharepoint.log_task_progress")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_successful_upload(
+ self, mock_settings, mock_log, mock_token, mock_resolve, mock_session, mock_upload, tmp_path
+ ):
+ """Test successful SharePoint upload."""
+ from app.tasks.upload_to_sharepoint import upload_to_sharepoint
+
+ mock_settings.sharepoint_client_id = "client-id"
+ mock_settings.sharepoint_client_secret = "secret"
+ mock_settings.sharepoint_refresh_token = "token"
+ mock_settings.sharepoint_site_url = "https://tenant.sharepoint.com/sites/mysite"
+ mock_settings.sharepoint_document_library = "Documents"
+ mock_settings.sharepoint_folder_path = "Uploads"
+ mock_settings.sharepoint_tenant_id = "common"
+
+ test_file = tmp_path / "test.pdf"
+ test_file.write_bytes(b"test content")
+
+ mock_token.return_value = "access-token"
+ mock_resolve.return_value = ("site-id", "drive-id")
+ mock_session.return_value = "https://upload.url/session"
+ mock_upload.return_value = {"webUrl": "https://tenant.sharepoint.com/sites/mysite/test.pdf"}
+
+ result = upload_to_sharepoint.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
+
+ assert result["status"] == "Completed"
+ assert "Uploads" in result["sharepoint_path"]
+ assert result["web_url"] == "https://tenant.sharepoint.com/sites/mysite/test.pdf"
+
+ @patch("app.tasks.upload_to_sharepoint.get_sharepoint_token")
+ @patch("app.tasks.upload_to_sharepoint.log_task_progress")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_upload_exception_handling(self, mock_settings, mock_log, mock_token, tmp_path):
+ """Test that upload errors are properly handled."""
+ from app.tasks.upload_to_sharepoint import upload_to_sharepoint
+
+ mock_settings.sharepoint_client_id = "client-id"
+ mock_settings.sharepoint_client_secret = "secret"
+ mock_settings.sharepoint_site_url = "https://tenant.sharepoint.com/sites/mysite"
+ mock_settings.sharepoint_folder_path = "Uploads"
+ mock_settings.sharepoint_document_library = "Documents"
+
+ test_file = tmp_path / "test.pdf"
+ test_file.write_bytes(b"test content")
+
+ mock_token.side_effect = ValueError("Token error")
+
+ with pytest.raises(Exception, match="Failed to upload"):
+ upload_to_sharepoint.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
+
+ @patch("app.tasks.upload_to_sharepoint.upload_large_file_sharepoint")
+ @patch("app.tasks.upload_to_sharepoint.create_sharepoint_upload_session")
+ @patch("app.tasks.upload_to_sharepoint.resolve_sharepoint_drive")
+ @patch("app.tasks.upload_to_sharepoint.get_sharepoint_token")
+ @patch("app.tasks.upload_to_sharepoint.log_task_progress")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_folder_override(
+ self, mock_settings, mock_log, mock_token, mock_resolve, mock_session, mock_upload, tmp_path
+ ):
+ """Test that folder_override is used instead of settings."""
+ from app.tasks.upload_to_sharepoint import upload_to_sharepoint
+
+ mock_settings.sharepoint_client_id = "client-id"
+ mock_settings.sharepoint_client_secret = "secret"
+ mock_settings.sharepoint_refresh_token = "token"
+ mock_settings.sharepoint_site_url = "https://tenant.sharepoint.com/sites/mysite"
+ mock_settings.sharepoint_document_library = "Documents"
+ mock_settings.sharepoint_folder_path = "DefaultFolder"
+ mock_settings.sharepoint_tenant_id = "common"
+
+ test_file = tmp_path / "test.pdf"
+ test_file.write_bytes(b"test content")
+
+ mock_token.return_value = "access-token"
+ mock_resolve.return_value = ("site-id", "drive-id")
+ mock_session.return_value = "https://upload.url/session"
+ mock_upload.return_value = {"webUrl": "https://example.com/test.pdf"}
+
+ result = upload_to_sharepoint.apply(
+ args=[str(test_file)], kwargs={"file_id": 1, "folder_override": "CustomFolder"}
+ ).get()
+
+ # Verify the session was created with the override folder
+ mock_session.assert_called_once_with("test.pdf", "CustomFolder", "drive-id", "site-id", "access-token")
+ assert result["status"] == "Completed"
From 13aa14b8e4102437f72f6c260b2795a6ee761eb9 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 10 Mar 2026 00:22:07 +0000
Subject: [PATCH 03/37] docs: add SharePoint setup guide and update all
references
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
docs/ConfigurationGuide.md | 24 +++++
docs/CredentialRotationGuide.md | 11 +-
docs/DeploymentGuide.md | 2 +-
docs/README.md | 1 +
docs/SettingsManagement.md | 2 +-
docs/SharePointSetup.md | 185 ++++++++++++++++++++++++++++++++
docs/StorageArchitecture.md | 1 +
docs/UserGuide.md | 2 +-
8 files changed, 224 insertions(+), 4 deletions(-)
create mode 100644 docs/SharePointSetup.md
diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md
index 009dc5a9..dcdc86d6 100644
--- a/docs/ConfigurationGuide.md
+++ b/docs/ConfigurationGuide.md
@@ -1038,6 +1038,20 @@ For detailed setup instructions, see the [Google Drive Setup Guide](GoogleDriveS
For detailed setup instructions, see the [OneDrive Setup Guide](OneDriveSetup.md).
+### SharePoint Online
+
+| **Variable** | **Description** |
+|---------------------------------|-------------------------------------------------------|
+| `SHAREPOINT_CLIENT_ID` | Azure AD application client ID |
+| `SHAREPOINT_CLIENT_SECRET` | Azure AD application client secret |
+| `SHAREPOINT_TENANT_ID` | Azure AD tenant ID (use "common" for multi-tenant apps) |
+| `SHAREPOINT_REFRESH_TOKEN` | OAuth 2.0 refresh token |
+| `SHAREPOINT_SITE_URL` | SharePoint site URL (e.g. `https://tenant.sharepoint.com/sites/sitename`) |
+| `SHAREPOINT_DOCUMENT_LIBRARY` | Document library name (default: `Documents`) |
+| `SHAREPOINT_FOLDER_PATH` | Subfolder path inside the document library |
+
+SharePoint uses the same Microsoft Graph API as OneDrive. See the [OneDrive Setup Guide](OneDriveSetup.md) for Azure AD app registration instructions — the same app registration can be reused for SharePoint with the `Sites.ReadWrite.All` permission.
+
### Amazon S3
| **Variable** | **Description** |
@@ -1359,6 +1373,7 @@ For example:
| S3 | `docs/uploads/` | `docs/uploads/pdfa/` |
| Nextcloud | `/Files` | `/Files/pdfa` |
| OneDrive | `Documents/Uploads` | `Documents/Uploads/pdfa` |
+| SharePoint | `Uploads` | `Uploads/pdfa` |
| Google Drive | *(folder ID)* | `GOOGLE_DRIVE_PDFA_FOLDER_ID` |
Set `PDFA_UPLOAD_FOLDER` to an empty string to upload PDF/A files into the
@@ -1565,6 +1580,15 @@ ONEDRIVE_TENANT_ID=common
ONEDRIVE_REFRESH_TOKEN=your_refresh_token
ONEDRIVE_FOLDER_PATH=Documents/Uploads
+# SharePoint Online
+SHAREPOINT_CLIENT_ID=12345678-1234-1234-1234-123456789012
+SHAREPOINT_CLIENT_SECRET=your_client_secret
+SHAREPOINT_TENANT_ID=your-tenant-id
+SHAREPOINT_REFRESH_TOKEN=your_refresh_token
+SHAREPOINT_SITE_URL=https://tenant.sharepoint.com/sites/sitename
+SHAREPOINT_DOCUMENT_LIBRARY=Documents
+SHAREPOINT_FOLDER_PATH=Uploads
+
# Amazon S3
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
diff --git a/docs/CredentialRotationGuide.md b/docs/CredentialRotationGuide.md
index bb911752..3dbe3c20 100644
--- a/docs/CredentialRotationGuide.md
+++ b/docs/CredentialRotationGuide.md
@@ -11,7 +11,7 @@ Credentials fall into two categories:
| Category | Examples |
|---|---|
| **API keys** | OpenAI API key, Azure AI key, Paperless-ngx API token, AWS access keys |
-| **OAuth tokens / secrets** | Dropbox, Google Drive, OneDrive, Authentik client secrets and refresh tokens |
+| **OAuth tokens / secrets** | Dropbox, Google Drive, OneDrive, SharePoint, Authentik client secrets and refresh tokens |
| **Passwords** | Admin password, Nextcloud, Email (SMTP), IMAP, FTP, SFTP, WebDAV |
| **Private keys** | SFTP private key and passphrase |
@@ -119,6 +119,15 @@ For service-account credentials (`google_drive_credentials_json`):
4. Re-authorize via the OAuth flow to get a fresh `onedrive_refresh_token`.
5. Delete the old client secret in Azure.
+### SharePoint (Microsoft OAuth)
+
+1. SharePoint uses the same Azure AD app registration as OneDrive.
+2. In **Azure App Registrations**, navigate to **Certificates & secrets** for your app.
+3. Add a new client secret.
+4. Update `sharepoint_client_secret` in DocuElevate.
+5. Re-authorize via the OAuth flow to get a fresh `sharepoint_refresh_token`.
+6. Delete the old client secret in Azure.
+
### Authentik (OIDC)
1. In your Authentik admin panel, navigate to the DocuElevate application and regenerate the client secret.
diff --git a/docs/DeploymentGuide.md b/docs/DeploymentGuide.md
index cb66bfa9..94dc634f 100644
--- a/docs/DeploymentGuide.md
+++ b/docs/DeploymentGuide.md
@@ -19,7 +19,7 @@ This guide covers all supported deployment methods for DocuElevate.
- Access to required external services (if configured):
- AI provider API key (OpenAI, Anthropic, Gemini, or other configured provider)
- Azure Document Intelligence
- - Dropbox, Google Drive, OneDrive, S3, or other storage APIs
+ - Dropbox, Google Drive, OneDrive, SharePoint, S3, or other storage APIs
- SMTP / IMAP server (for email processing)
- Notification services (Discord, Telegram, etc.)
diff --git a/docs/README.md b/docs/README.md
index a3a6a96c..bc210343 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -22,6 +22,7 @@ Welcome to the DocuElevate documentation. This directory contains comprehensive
- [Google Drive Setup](GoogleDriveSetup.md) - How to set up Google Drive integration
- [Dropbox Setup](DropboxSetup.md) - How to set up Dropbox integration
- [OneDrive Setup](OneDriveSetup.md) - How to set up Microsoft OneDrive/Graph integration
+ - [SharePoint Setup](SharePointSetup.md) - How to set up Microsoft SharePoint Online integration
- [Amazon S3 Setup](AmazonS3Setup.md) - How to set up Amazon S3 integration
- [Authentication Setup](AuthenticationSetup.md) - How to set up user authentication
- [Notifications Setup](NotificationsSetup.md) - How to set up system notifications
diff --git a/docs/SettingsManagement.md b/docs/SettingsManagement.md
index b64c1ef4..288c261e 100644
--- a/docs/SettingsManagement.md
+++ b/docs/SettingsManagement.md
@@ -28,7 +28,7 @@ Settings are organized into logical categories for easy navigation:
- **Authentication**: Login settings, session secrets, OAuth configuration, admin group
- **AI Services**: AI provider selection, model configuration, embeddings, and credentials (OpenAI, Azure, Anthropic, Gemini, Ollama, OpenRouter, Portkey, LiteLLM)
- **OCR Engines**: OCR provider selection and configuration (Tesseract, EasyOCR, Mistral, Google DocAI, AWS Textract)
-- **Storage Providers**: Dropbox, Google Drive, OneDrive, S3, FTP, SFTP, WebDAV, Nextcloud, Paperless
+- **Storage Providers**: Dropbox, Google Drive, OneDrive, SharePoint, S3, FTP, SFTP, WebDAV, Nextcloud, Paperless
- **Email**: SMTP configuration for sending emails
- **IMAP**: Email ingestion configuration (supports two mailbox accounts)
- **Monitoring**: Uptime Kuma integration
diff --git a/docs/SharePointSetup.md b/docs/SharePointSetup.md
new file mode 100644
index 00000000..560f1671
--- /dev/null
+++ b/docs/SharePointSetup.md
@@ -0,0 +1,185 @@
+# Setting up SharePoint Integration
+
+This guide explains how to set up the Microsoft SharePoint Online integration for DocuElevate.
+
+## Required Configuration Parameters
+
+| **Variable** | **Description** |
+|---------------------------------|-------------------------------------------------------|
+| `SHAREPOINT_CLIENT_ID` | Azure AD application client ID |
+| `SHAREPOINT_CLIENT_SECRET` | Azure AD application client secret |
+| `SHAREPOINT_TENANT_ID` | Azure AD tenant ID (use "common" for multi-tenant apps) |
+| `SHAREPOINT_REFRESH_TOKEN` | OAuth 2.0 refresh token |
+| `SHAREPOINT_SITE_URL` | SharePoint site URL (e.g. `https://tenant.sharepoint.com/sites/sitename`) |
+| `SHAREPOINT_DOCUMENT_LIBRARY` | Document library name (default: `Documents`) |
+| `SHAREPOINT_FOLDER_PATH` | Subfolder path inside the document library |
+
+For a complete list of configuration options, see the [Configuration Guide](ConfigurationGuide.md).
+
+## Overview
+
+SharePoint Online integration uses the same Microsoft Graph API as OneDrive. The key difference is that SharePoint targets a **site-specific document library** rather than a personal OneDrive. Documents are uploaded via chunked upload sessions for reliability with large files.
+
+> **Tip:** If you already have an Azure AD app registration for OneDrive, you can reuse it for SharePoint — just add the `Sites.ReadWrite.All` permission.
+
+## Setup Steps
+
+### 1. Register an application in Azure Active Directory
+
+If you don't already have an app registration (e.g. from OneDrive setup):
+
+1. Go to the [Azure Portal](https://portal.azure.com/)
+2. Navigate to **Azure Active Directory** > **App registrations**
+3. Click **New registration**
+4. Enter a name for your application (e.g., "DocuElevate")
+5. For **Supported account types**, select:
+ - **Single tenant**: "Accounts in this organizational directory only"
+ - **Multi-tenant**: "Accounts in any organizational directory"
+6. For **Redirect URI**, select "Web" and enter your callback URL (e.g., `https://your-domain.com/onedrive-callback`)
+7. Click **Register**
+
+### 2. Get Application (client) ID
+
+1. After registration, note the **Application (client) ID** from the overview page
+2. Set this value as `SHAREPOINT_CLIENT_ID`
+
+### 3. Create a client secret
+
+1. In your application page, go to **Certificates & secrets**
+2. Under **Client secrets**, click **New client secret**
+3. Add a description and select an expiration period
+4. Click **Add** and immediately copy the secret value (it will only be shown once)
+5. Set this value as `SHAREPOINT_CLIENT_SECRET`
+
+### 4. Configure API permissions
+
+1. In your application page, go to **API permissions**
+2. Click **Add a permission**
+3. Select **Microsoft Graph**
+4. For **delegated permissions** (user-context access), add:
+ - `Sites.ReadWrite.All` — Read and write items in all site collections
+ - `offline_access` — Required for refresh tokens
+5. For **application permissions** (app-only access without a user), add:
+ - `Sites.ReadWrite.All` — Read and write items in all site collections
+6. Click **Add permissions**
+7. Click **Grant admin consent** (requires admin privileges)
+
+> **Important:** SharePoint access requires `Sites.ReadWrite.All` rather than the `Files.ReadWrite` permission used by OneDrive.
+
+### 5. Get your Tenant ID
+
+1. In the Azure Portal, find your **Tenant ID** (also called "Directory ID")
+2. It is on the **Azure Active Directory** overview page
+3. Set this value as `SHAREPOINT_TENANT_ID`
+
+### 6. Generate a Refresh Token
+
+#### Using the OneDrive Auth Wizard
+
+The SharePoint integration reuses the same MSAL token flow as OneDrive:
+
+1. Navigate to `/onedrive-setup`
+2. Enter your SharePoint Client ID and Tenant ID
+3. Click **Start Authentication Flow** and follow the prompts
+4. Copy the generated refresh token and set it as `SHAREPOINT_REFRESH_TOKEN`
+
+#### Manual Method
+
+1. Open the following URL in your browser (replace placeholders):
+ ```
+ https://login.microsoftonline.com/YOUR_TENANT_ID/oauth2/v2.0/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=YOUR_REDIRECT_URI&response_mode=query&scope=https://graph.microsoft.com/.default offline_access&prompt=consent
+ ```
+2. Sign in with your Microsoft work account
+3. After authentication, copy the `code` parameter from the redirect URL
+4. Exchange the code for tokens:
+ ```bash
+ curl -X POST https://login.microsoftonline.com/YOUR_TENANT_ID/oauth2/v2.0/token \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "client_id=YOUR_CLIENT_ID&scope=https://graph.microsoft.com/.default offline_access&code=YOUR_AUTH_CODE&redirect_uri=YOUR_REDIRECT_URI&grant_type=authorization_code&client_secret=YOUR_CLIENT_SECRET"
+ ```
+5. From the response JSON, copy the `refresh_token` value
+6. Set this as `SHAREPOINT_REFRESH_TOKEN`
+
+### 7. Find your SharePoint Site URL
+
+Your SharePoint site URL follows the pattern:
+```
+https://YOUR-TENANT.sharepoint.com/sites/SITE-NAME
+```
+
+For example:
+- `https://contoso.sharepoint.com/sites/documents`
+- `https://contoso.sharepoint.com/sites/engineering-team`
+
+Set this as `SHAREPOINT_SITE_URL`.
+
+### 8. Choose your Document Library
+
+Each SharePoint site has one or more document libraries. The default library is usually called `Documents` (or `Shared Documents`). You can find your library names by navigating to your SharePoint site in a browser and looking at the left sidebar.
+
+Set the library name as `SHAREPOINT_DOCUMENT_LIBRARY` (default: `Documents`).
+
+### 9. Set the Upload Folder (Optional)
+
+If you want documents to be uploaded into a subfolder inside the library, set `SHAREPOINT_FOLDER_PATH`. For example, `Uploads` or `DocuElevate/Processed`.
+
+## App-Only Access (No User Token)
+
+For fully automated scenarios without user interaction:
+
+1. Add **Application permissions** (not Delegated) for `Sites.ReadWrite.All`
+2. Grant admin consent
+3. Set `SHAREPOINT_TENANT_ID` to your organization's tenant ID
+4. Leave `SHAREPOINT_REFRESH_TOKEN` empty — the app will use the client credentials flow
+
+> **Note:** Client credentials flow requires a specific tenant ID (not "common").
+
+## Configuration Examples
+
+**With Refresh Token (Delegated Permissions):**
+```dotenv
+SHAREPOINT_CLIENT_ID=12345678-1234-1234-1234-123456789012
+SHAREPOINT_CLIENT_SECRET=your_client_secret
+SHAREPOINT_TENANT_ID=87654321-4321-4321-4321-210987654321
+SHAREPOINT_REFRESH_TOKEN=your_refresh_token
+SHAREPOINT_SITE_URL=https://contoso.sharepoint.com/sites/documents
+SHAREPOINT_DOCUMENT_LIBRARY=Documents
+SHAREPOINT_FOLDER_PATH=Uploads
+```
+
+**App-Only Access (Application Permissions):**
+```dotenv
+SHAREPOINT_CLIENT_ID=12345678-1234-1234-1234-123456789012
+SHAREPOINT_CLIENT_SECRET=your_client_secret
+SHAREPOINT_TENANT_ID=87654321-4321-4321-4321-210987654321
+# No refresh token needed for app-only access
+SHAREPOINT_SITE_URL=https://contoso.sharepoint.com/sites/documents
+SHAREPOINT_DOCUMENT_LIBRARY=Shared Documents
+SHAREPOINT_FOLDER_PATH=DocuElevate/Processed
+```
+
+## Troubleshooting
+
+### "Failed to resolve SharePoint site"
+
+- Verify `SHAREPOINT_SITE_URL` is correct and accessible
+- Ensure your app has `Sites.ReadWrite.All` permission with admin consent
+- Check that the site exists and your account has access to it
+
+### "Document library not found"
+
+- Verify the library name in `SHAREPOINT_DOCUMENT_LIBRARY` matches exactly (case-insensitive)
+- Navigate to your SharePoint site in a browser to confirm the library name
+- Common names: `Documents`, `Shared Documents`
+
+### Token errors
+
+- If using a refresh token, try re-authorizing via the OAuth flow
+- Ensure `offline_access` scope is included in your permissions
+- For app-only access, verify the tenant ID is not set to "common"
+
+### Permission errors
+
+- Ensure an admin has granted consent for `Sites.ReadWrite.All`
+- Verify the app registration has the correct permissions
+- Check that the site's sharing settings allow API access
diff --git a/docs/StorageArchitecture.md b/docs/StorageArchitecture.md
index 0e24bd1b..8da38de6 100644
--- a/docs/StorageArchitecture.md
+++ b/docs/StorageArchitecture.md
@@ -341,6 +341,7 @@ in task messages or logs.
| `S3` | boto3 `upload_file`, per-user access key |
| `GOOGLE_DRIVE` | Google Drive API v3, OAuth or service account |
| `ONEDRIVE` | Microsoft Graph API, MSAL confidential-client |
+| `SHAREPOINT` | Microsoft Graph API, site/drive resolution + chunked upload |
| `WEBDAV` | HTTP PUT request, Basic Auth |
| `NEXTCLOUD` | WebDAV (same as WEBDAV, Nextcloud-compatible path) |
| `FTP` | ftplib FTPS (TLS preferred, plaintext configurable) |
diff --git a/docs/UserGuide.md b/docs/UserGuide.md
index 9906a89c..de130125 100644
--- a/docs/UserGuide.md
+++ b/docs/UserGuide.md
@@ -161,7 +161,7 @@ The **Integrations** page (`/integrations`) provides a unified view of all your
- **S3** — bucket, region, access key, secret key
- **WebDAV / Nextcloud** — URL, folder, username, password
- **FTP / SFTP** — host, port, remote path, username, password
- - **Dropbox / Google Drive / OneDrive** — folder path, with a link to the OAuth setup page
+ - **Dropbox / Google Drive / OneDrive / SharePoint** — folder path, with a link to the OAuth setup page
- **Email Forward** — recipient email address
- **Watch Folder** — source type (Local, S3, Dropbox, Google Drive, OneDrive, Nextcloud, WebDAV), per-type config fields, delete after processing toggle
- **Paperless NGX** — URL and API token
From 2b698cc6940fb731b1ab87300ad0f7fdebc8f024 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 10 Mar 2026 00:23:52 +0000
Subject: [PATCH 04/37] fix(storage): use RuntimeError instead of bare
Exception in SharePoint task
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
app/tasks/upload_to_sharepoint.py | 2 +-
tests/test_upload_to_sharepoint.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/tasks/upload_to_sharepoint.py b/app/tasks/upload_to_sharepoint.py
index 3bd70a90..0e2593c1 100644
--- a/app/tasks/upload_to_sharepoint.py
+++ b/app/tasks/upload_to_sharepoint.py
@@ -335,4 +335,4 @@ def upload_to_sharepoint(self, file_path: str, file_id: int = None, folder_overr
error_msg = f"Failed to upload {filename} to SharePoint: {str(e)}"
logger.error("[%s] %s", task_id, error_msg)
log_task_progress(task_id, "upload_to_sharepoint", "failure", error_msg, file_id=file_id)
- raise Exception(error_msg)
+ raise RuntimeError(error_msg) from e
diff --git a/tests/test_upload_to_sharepoint.py b/tests/test_upload_to_sharepoint.py
index 0a67f3cb..1acfa40b 100644
--- a/tests/test_upload_to_sharepoint.py
+++ b/tests/test_upload_to_sharepoint.py
@@ -514,7 +514,7 @@ class TestUploadToSharepoint:
mock_token.side_effect = ValueError("Token error")
- with pytest.raises(Exception, match="Failed to upload"):
+ with pytest.raises(RuntimeError, match="Failed to upload"):
upload_to_sharepoint.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
@patch("app.tasks.upload_to_sharepoint.upload_large_file_sharepoint")
From bcf2d00c3324a3ded3852e16759ea4d0af3af666 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Mar 2026 11:32:48 +0000
Subject: [PATCH 05/37] fix(test): add missing _should_upload_to_sharepoint
mock to send_to_all tests
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
tests/test_coverage_uploads_notification.py | 4 +++
tests/test_send_to_all.py | 27 +++++++++++++++++++++
2 files changed, 31 insertions(+)
diff --git a/tests/test_coverage_uploads_notification.py b/tests/test_coverage_uploads_notification.py
index c59ada46..a255605c 100644
--- a/tests/test_coverage_uploads_notification.py
+++ b/tests/test_coverage_uploads_notification.py
@@ -665,6 +665,7 @@ def _all_should_upload_false():
"email",
"onedrive",
"s3",
+ "sharepoint",
"icloud",
]
return [patch(f"app.tasks.send_to_all._should_upload_to_{s}", return_value=False) for s in services]
@@ -694,6 +695,7 @@ class TestSendToAllCoverage:
patch("app.tasks.send_to_all._should_upload_to_email", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_onedrive", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_s3", return_value=False),
+ patch("app.tasks.send_to_all._should_upload_to_sharepoint", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_icloud", return_value=False),
patch("app.tasks.send_to_all.SessionLocal") as mock_session_cls,
):
@@ -806,6 +808,7 @@ class TestSendToAllCoverage:
patch("app.tasks.send_to_all._should_upload_to_email", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_onedrive", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_s3", return_value=False),
+ patch("app.tasks.send_to_all._should_upload_to_sharepoint", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_icloud", return_value=False),
patch("app.tasks.send_to_all.SessionLocal"),
):
@@ -866,6 +869,7 @@ class TestSendToAllCoverage:
patch("app.tasks.send_to_all._should_upload_to_email", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_onedrive", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_s3", return_value=False),
+ patch("app.tasks.send_to_all._should_upload_to_sharepoint", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_icloud", return_value=False),
patch("app.tasks.send_to_all.SessionLocal"),
):
diff --git a/tests/test_send_to_all.py b/tests/test_send_to_all.py
index 0bab975f..3cf0c42f 100644
--- a/tests/test_send_to_all.py
+++ b/tests/test_send_to_all.py
@@ -360,6 +360,7 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all._should_upload_to_sftp")
@patch("app.tasks.send_to_all._should_upload_to_email")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
+ @patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all.upload_to_dropbox")
@@ -368,6 +369,7 @@ class TestSendToAllDestinations:
mock_upload,
mock_s3,
mock_icloud,
+ mock_sharepoint,
mock_onedrive,
mock_email,
mock_sftp,
@@ -397,6 +399,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
+ mock_sharepoint.return_value = False
mock_icloud.return_value = False
mock_upload.delay.return_value = MagicMock(id="task-123")
@@ -410,6 +413,7 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all.settings")
@patch("app.tasks.send_to_all._should_upload_to_dropbox")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
+ @patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all._should_upload_to_nextcloud")
@patch("app.tasks.send_to_all._should_upload_to_paperless")
@@ -434,6 +438,7 @@ class TestSendToAllDestinations:
mock_paperless,
mock_nextcloud,
mock_should_s3,
+ mock_sharepoint,
mock_icloud,
mock_should_dropbox,
mock_settings,
@@ -456,6 +461,7 @@ class TestSendToAllDestinations:
mock_sftp.return_value = False
mock_email.return_value = False
mock_onedrive.return_value = False
+ mock_sharepoint.return_value = False
mock_icloud.return_value = False
mock_dropbox_upload.delay.return_value = MagicMock(id="dropbox-task")
mock_s3_upload.delay.return_value = MagicMock(id="s3-task")
@@ -478,12 +484,14 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all._should_upload_to_sftp")
@patch("app.tasks.send_to_all._should_upload_to_email")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
+ @patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
def test_skips_unconfigured_services(
self,
mock_s3,
mock_icloud,
+ mock_sharepoint,
mock_onedrive,
mock_email,
mock_sftp,
@@ -513,6 +521,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
+ mock_sharepoint.return_value = False
mock_icloud.return_value = False
result = send_to_all_destinations.apply(args=[str(test_file), False, 1])
@@ -534,6 +543,7 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all._should_upload_to_sftp")
@patch("app.tasks.send_to_all._should_upload_to_email")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
+ @patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all.upload_to_dropbox")
@@ -542,6 +552,7 @@ class TestSendToAllDestinations:
mock_upload,
mock_s3,
mock_icloud,
+ mock_sharepoint,
mock_onedrive,
mock_email,
mock_sftp,
@@ -571,6 +582,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
+ mock_sharepoint.return_value = False
mock_icloud.return_value = False
mock_upload.delay.return_value = MagicMock(id="task-123")
@@ -593,6 +605,7 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all._should_upload_to_sftp")
@patch("app.tasks.send_to_all._should_upload_to_email")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
+ @patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all.get_configured_services_from_validator")
@@ -603,6 +616,7 @@ class TestSendToAllDestinations:
mock_validator,
mock_s3,
mock_icloud,
+ mock_sharepoint,
mock_onedrive,
mock_email,
mock_sftp,
@@ -633,6 +647,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
+ mock_sharepoint.return_value = False
mock_icloud.return_value = False
mock_upload.delay.return_value = MagicMock(id="task-123")
@@ -653,6 +668,7 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all._should_upload_to_sftp")
@patch("app.tasks.send_to_all._should_upload_to_email")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
+ @patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all.get_configured_services_from_validator")
@@ -661,6 +677,7 @@ class TestSendToAllDestinations:
mock_validator,
mock_s3,
mock_icloud,
+ mock_sharepoint,
mock_onedrive,
mock_email,
mock_sftp,
@@ -691,6 +708,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
+ mock_sharepoint.return_value = False
mock_icloud.return_value = False
# Should not raise, should fall back to individual checks
@@ -710,6 +728,7 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all._should_upload_to_sftp")
@patch("app.tasks.send_to_all._should_upload_to_email")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
+ @patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all.upload_to_dropbox")
@@ -718,6 +737,7 @@ class TestSendToAllDestinations:
mock_upload,
mock_s3,
mock_icloud,
+ mock_sharepoint,
mock_onedrive,
mock_email,
mock_sftp,
@@ -747,6 +767,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
+ mock_sharepoint.return_value = False
mock_icloud.return_value = False
mock_upload.delay.side_effect = Exception("Queue error")
@@ -758,6 +779,7 @@ class TestSendToAllDestinations:
assert "dropbox_error" in result.result["tasks"]
@patch("app.tasks.send_to_all._should_upload_to_icloud")
+ @patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
@patch("app.tasks.send_to_all._should_upload_to_email")
@@ -786,6 +808,7 @@ class TestSendToAllDestinations:
mock_email,
mock_onedrive,
mock_s3,
+ mock_sharepoint,
mock_icloud,
tmp_path,
):
@@ -808,6 +831,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
+ mock_sharepoint.return_value = False
mock_icloud.return_value = False
# Mock database session
@@ -836,12 +860,14 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all._should_upload_to_sftp")
@patch("app.tasks.send_to_all._should_upload_to_email")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
+ @patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
def test_should_upload_check_exception_handling(
self,
mock_s3,
mock_icloud,
+ mock_sharepoint,
mock_onedrive,
mock_email,
mock_sftp,
@@ -871,6 +897,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
+ mock_sharepoint.return_value = False
mock_icloud.return_value = False
# Should not raise, should treat as not configured
From 9fe50a87d1bcaceea01238f3766887d40e5226ca Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Mar 2026 11:53:39 +0000
Subject: [PATCH 06/37] Initial plan
From 0f6a1ee1ec8186d70afc17abe50258c968058c92 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Mar 2026 12:02:33 +0000
Subject: [PATCH 07/37] fix(api): add ttl_seconds to QR challenge response and
fix client-side countdown
The QR login page countdown timer compared the server's UTC expiration
timestamp against the client's local clock, causing the QR code to appear
immediately expired when the client clock was ahead of the server.
Changes:
- Add ttl_seconds field to CreateChallengeResponse (seconds until expiry)
- Frontend countdown now uses relative elapsed time since response was
received, eliminating clock-skew issues
- Mobile app: replace alert-only QR button with actual camera-based
QR code scanner using expo-camera
- Add QRScannerScreen with barcode scanning, permission handling, and
scan area overlay
- Update camera permission description to mention QR code scanning
- Add tests for ttl_seconds computation
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
app/api/qr_auth.py | 7 +
frontend/templates/qr_login.html | 9 +-
mobile/app.json | 4 +-
mobile/app/(auth)/_layout.tsx | 1 +
mobile/app/(auth)/qr-scanner.tsx | 4 +
mobile/src/screens/LoginScreen.tsx | 5 +-
mobile/src/screens/QRScannerScreen.tsx | 295 +++++++++++++++++++++++++
tests/test_session_management.py | 29 +++
8 files changed, 346 insertions(+), 8 deletions(-)
create mode 100644 mobile/app/(auth)/qr-scanner.tsx
create mode 100644 mobile/src/screens/QRScannerScreen.tsx
diff --git a/app/api/qr_auth.py b/app/api/qr_auth.py
index b7e1d439..b9dbaa5d 100644
--- a/app/api/qr_auth.py
+++ b/app/api/qr_auth.py
@@ -70,6 +70,7 @@ class CreateChallengeResponse(BaseModel):
challenge_id: int
challenge_token: str
expires_at: datetime
+ ttl_seconds: int = Field(description="Seconds until the challenge expires (use for client-side countdown).")
qr_payload: str = Field(description="The string to encode in the QR code.")
@@ -131,10 +132,16 @@ async def create_challenge(
base_url = str(request.base_url).rstrip("/")
qr_payload = f"docuelevate://qr-login?token={challenge.challenge_token}&server={base_url}"
+ # Compute the TTL in seconds so the client can run a countdown timer
+ # without comparing absolute timestamps (which breaks when client and
+ # server clocks are out of sync).
+ ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds()))
+
return {
"challenge_id": challenge.id,
"challenge_token": challenge.challenge_token,
"expires_at": challenge.expires_at,
+ "ttl_seconds": ttl_seconds,
"qr_payload": qr_payload,
}
diff --git a/frontend/templates/qr_login.html b/frontend/templates/qr_login.html
index 517dd3ca..7b29f594 100644
--- a/frontend/templates/qr_login.html
+++ b/frontend/templates/qr_login.html
@@ -126,6 +126,8 @@ function qrLoginPage() {
errorMsg: '',
_pollTimer: null,
_countdownTimer: null,
+ _ttlSeconds: 0,
+ _receivedAt: null,
_csrfToken() {
return document.cookie
@@ -156,6 +158,8 @@ function qrLoginPage() {
this.challengeToken = data.challenge_token;
this.qrPayload = data.qr_payload;
this.expiresAt = new Date(data.expires_at);
+ this._ttlSeconds = data.ttl_seconds || 120;
+ this._receivedAt = Date.now();
this.status = 'pending';
this.deviceName = '';
@@ -214,8 +218,9 @@ function qrLoginPage() {
},
_updateCountdown() {
- if (!this.expiresAt) { this.countdown = 0; return; }
- const remaining = Math.max(0, Math.floor((this.expiresAt - new Date()) / 1000));
+ if (!this._receivedAt) { this.countdown = 0; return; }
+ const elapsed = (Date.now() - this._receivedAt) / 1000;
+ const remaining = Math.max(0, Math.floor(this._ttlSeconds - elapsed));
this.countdown = remaining;
},
diff --git a/mobile/app.json b/mobile/app.json
index 1aa4aef0..26cc937b 100644
--- a/mobile/app.json
+++ b/mobile/app.json
@@ -19,7 +19,7 @@
"bundleIdentifier": "org.docuelevate.mobile",
"appleTeamId": "975U2ZESBM",
"infoPlist": {
- "NSCameraUsageDescription": "DocuElevate uses the camera to capture documents for upload.",
+ "NSCameraUsageDescription": "DocuElevate uses the camera to scan QR codes for login and to capture documents for upload.",
"NSPhotoLibraryUsageDescription": "DocuElevate accesses your photo library to select documents for upload.",
"NSPhotoLibraryAddUsageDescription": "DocuElevate saves scanned documents to your photo library.",
"UIBackgroundModes": ["fetch", "remote-notification"],
@@ -101,7 +101,7 @@
[
"expo-camera",
{
- "cameraPermission": "DocuElevate uses the camera to capture documents for upload."
+ "cameraPermission": "DocuElevate uses the camera to scan QR codes for login and to capture documents for upload."
}
],
"expo-document-picker",
diff --git a/mobile/app/(auth)/_layout.tsx b/mobile/app/(auth)/_layout.tsx
index 7040faec..e7288998 100644
--- a/mobile/app/(auth)/_layout.tsx
+++ b/mobile/app/(auth)/_layout.tsx
@@ -12,6 +12,7 @@ export default function AuthLayout() {
+
);
}
diff --git a/mobile/app/(auth)/qr-scanner.tsx b/mobile/app/(auth)/qr-scanner.tsx
new file mode 100644
index 00000000..b6c018fb
--- /dev/null
+++ b/mobile/app/(auth)/qr-scanner.tsx
@@ -0,0 +1,4 @@
+/**
+ * QR scanner route – camera-based QR code scanning for mobile login.
+ */
+export { default } from "../../src/screens/QRScannerScreen";
diff --git a/mobile/src/screens/LoginScreen.tsx b/mobile/src/screens/LoginScreen.tsx
index cc2b3133..8a0ace90 100644
--- a/mobile/src/screens/LoginScreen.tsx
+++ b/mobile/src/screens/LoginScreen.tsx
@@ -144,10 +144,7 @@ export default function LoginScreen() {
{
- Alert.alert(
- "Scan QR Code",
- "Open the DocuElevate web app on your computer, go to Profile → Security & Sessions → \"Log in on mobile via QR code\", and scan the QR code shown there.\n\nThe app will automatically detect the QR code when scanned with your device camera."
- );
+ router.push("/(auth)/qr-scanner");
}}
disabled={loading || qrLoading}
accessibilityRole="button"
diff --git a/mobile/src/screens/QRScannerScreen.tsx b/mobile/src/screens/QRScannerScreen.tsx
new file mode 100644
index 00000000..9831112a
--- /dev/null
+++ b/mobile/src/screens/QRScannerScreen.tsx
@@ -0,0 +1,295 @@
+/**
+ * QRScannerScreen – camera-based QR code scanner for mobile login.
+ *
+ * Opens the device camera and scans for QR codes containing a
+ * `docuelevate://qr-login?token=...&server=...` payload. On successful
+ * scan the token is claimed via the API and the user is signed in.
+ */
+
+import { CameraView, useCameraPermissions } from "expo-camera";
+import { useRouter } from "expo-router";
+import React, { useCallback, useRef, useState } from "react";
+import {
+ ActivityIndicator,
+ Alert,
+ Pressable,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
+import { useAuth } from "../context/AuthContext";
+
+export default function QRScannerScreen() {
+ const { signInWithQR } = useAuth();
+ const router = useRouter();
+ const [permission, requestPermission] = useCameraPermissions();
+ const [scanned, setScanned] = useState(false);
+ const [processing, setProcessing] = useState(false);
+ const processingRef = useRef(false);
+
+ const handleBarCodeScanned = useCallback(
+ async (result: { data: string }) => {
+ // Prevent duplicate scans while processing
+ if (processingRef.current) return;
+
+ const { data } = result;
+
+ // Only accept docuelevate:// QR codes
+ if (!data.startsWith("docuelevate://qr-login")) return;
+
+ processingRef.current = true;
+ setScanned(true);
+ setProcessing(true);
+
+ try {
+ const url = new URL(data);
+ const token = url.searchParams.get("token");
+ const server = url.searchParams.get("server");
+
+ if (!token || !server) {
+ Alert.alert("Invalid QR Code", "This QR code does not contain valid login information.");
+ setScanned(false);
+ processingRef.current = false;
+ setProcessing(false);
+ return;
+ }
+
+ await signInWithQR(server, token);
+ // signInWithQR updates AuthContext → AuthGuard redirects to main app
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : "QR login failed";
+ Alert.alert("QR Login Failed", message);
+ setScanned(false);
+ processingRef.current = false;
+ setProcessing(false);
+ }
+ },
+ [signInWithQR]
+ );
+
+ // Permissions not yet determined
+ if (!permission) {
+ return (
+
+
+
+ );
+ }
+
+ // Permission denied
+ if (!permission.granted) {
+ return (
+
+
+ Camera access is required to scan QR codes.
+
+
+ Grant Camera Access
+
+ router.back()}
+ style={styles.backLink}
+ accessibilityRole="button"
+ accessibilityLabel="Go back"
+ >
+ ← Back
+
+
+ );
+ }
+
+ return (
+
+
+
+ {/* Overlay with scan area indicator */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {processing ? (
+
+
+ Signing in…
+
+ ) : (
+
+ Point your camera at the QR code{"\n"}shown on the DocuElevate web app
+
+ )}
+
+ router.back()}
+ style={styles.cancelButton}
+ accessibilityRole="button"
+ accessibilityLabel="Cancel QR scan"
+ >
+ Cancel
+
+
+
+
+ );
+}
+
+const SCAN_AREA_SIZE = 250;
+const CORNER_SIZE = 24;
+const CORNER_WIDTH = 3;
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: "#000",
+ },
+ camera: {
+ flex: 1,
+ },
+ centered: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ backgroundColor: "#f3f4f6",
+ padding: 24,
+ },
+ permissionText: {
+ fontSize: 16,
+ color: "#374151",
+ textAlign: "center",
+ marginBottom: 20,
+ },
+ permissionButton: {
+ backgroundColor: "#1e40af",
+ borderRadius: 8,
+ paddingVertical: 14,
+ paddingHorizontal: 24,
+ minHeight: 48,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ permissionButtonText: {
+ color: "#fff",
+ fontSize: 16,
+ fontWeight: "600",
+ },
+ backLink: {
+ marginTop: 20,
+ minHeight: 44,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ backLinkText: {
+ fontSize: 14,
+ color: "#6b7280",
+ },
+ overlay: {
+ ...StyleSheet.absoluteFillObject,
+ },
+ overlayTop: {
+ flex: 1,
+ backgroundColor: "rgba(0,0,0,0.5)",
+ },
+ overlayMiddle: {
+ flexDirection: "row",
+ height: SCAN_AREA_SIZE,
+ },
+ overlaySide: {
+ flex: 1,
+ backgroundColor: "rgba(0,0,0,0.5)",
+ },
+ scanArea: {
+ width: SCAN_AREA_SIZE,
+ height: SCAN_AREA_SIZE,
+ },
+ corner: {
+ position: "absolute",
+ width: CORNER_SIZE,
+ height: CORNER_SIZE,
+ },
+ cornerTopLeft: {
+ top: 0,
+ left: 0,
+ borderTopWidth: CORNER_WIDTH,
+ borderLeftWidth: CORNER_WIDTH,
+ borderColor: "#fff",
+ },
+ cornerTopRight: {
+ top: 0,
+ right: 0,
+ borderTopWidth: CORNER_WIDTH,
+ borderRightWidth: CORNER_WIDTH,
+ borderColor: "#fff",
+ },
+ cornerBottomLeft: {
+ bottom: 0,
+ left: 0,
+ borderBottomWidth: CORNER_WIDTH,
+ borderLeftWidth: CORNER_WIDTH,
+ borderColor: "#fff",
+ },
+ cornerBottomRight: {
+ bottom: 0,
+ right: 0,
+ borderBottomWidth: CORNER_WIDTH,
+ borderRightWidth: CORNER_WIDTH,
+ borderColor: "#fff",
+ },
+ overlayBottom: {
+ flex: 1,
+ backgroundColor: "rgba(0,0,0,0.5)",
+ alignItems: "center",
+ paddingTop: 32,
+ },
+ statusContainer: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 10,
+ },
+ statusText: {
+ color: "#fff",
+ fontSize: 16,
+ fontWeight: "600",
+ },
+ instructionText: {
+ color: "#fff",
+ fontSize: 15,
+ textAlign: "center",
+ lineHeight: 22,
+ },
+ cancelButton: {
+ marginTop: 24,
+ paddingVertical: 12,
+ paddingHorizontal: 32,
+ borderRadius: 8,
+ borderWidth: 1,
+ borderColor: "rgba(255,255,255,0.5)",
+ minHeight: 44,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ cancelButtonText: {
+ color: "#fff",
+ fontSize: 15,
+ fontWeight: "500",
+ },
+});
diff --git a/tests/test_session_management.py b/tests/test_session_management.py
index 039aca46..e11c93ca 100644
--- a/tests/test_session_management.py
+++ b/tests/test_session_management.py
@@ -401,6 +401,35 @@ class TestQRLogin:
expires = expires.replace(tzinfo=timezone.utc)
assert expires > datetime.now(timezone.utc)
+ @patch("app.utils.session_manager.settings")
+ def test_create_qr_challenge_ttl_seconds(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test that ttl_seconds can be derived from created_at and expires_at.
+
+ The API endpoint computes ttl_seconds = (expires_at - created_at) to
+ allow the client to run a countdown timer without comparing absolute
+ timestamps (avoiding clock-skew issues).
+ """
+ from app.utils.session_manager import create_qr_challenge
+
+ mock_settings.qr_login_challenge_ttl_seconds = 120
+
+ challenge = create_qr_challenge(db_session, sample_user_id)
+
+ ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds()))
+ assert ttl_seconds == 120
+
+ @patch("app.utils.session_manager.settings")
+ def test_create_qr_challenge_custom_ttl(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test that a custom TTL is correctly reflected in the challenge timestamps."""
+ from app.utils.session_manager import create_qr_challenge
+
+ mock_settings.qr_login_challenge_ttl_seconds = 300
+
+ challenge = create_qr_challenge(db_session, sample_user_id)
+
+ ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds()))
+ assert ttl_seconds == 300
+
@patch("app.utils.session_manager.settings")
def test_validate_qr_challenge_valid(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test validating a valid QR challenge."""
From 723b14e660737887c454b8e8300ac38bb390841f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Mar 2026 12:04:53 +0000
Subject: [PATCH 08/37] docs: update QR code login documentation with scanner
and TTL details
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
docs/AuthenticationSetup.md | 11 +++++++----
docs/MobileApp.md | 19 +++++++++++++++++--
2 files changed, 24 insertions(+), 6 deletions(-)
diff --git a/docs/AuthenticationSetup.md b/docs/AuthenticationSetup.md
index 2c9e15a9..c77f48b3 100644
--- a/docs/AuthenticationSetup.md
+++ b/docs/AuthenticationSetup.md
@@ -190,20 +190,23 @@ QR code login allows users to authenticate a mobile device by scanning a QR code
### How It Works
1. The authenticated web user opens the **QR Login** page and a challenge QR code is displayed.
-2. The mobile app scans the QR code and calls the claim endpoint.
-3. An API token is issued for the mobile device and the web UI is notified via polling.
+2. The user opens the DocuElevate mobile app and taps **Scan QR Code to Login**, which opens the device camera.
+3. The mobile app scans the QR code. The QR code contains both the challenge token and the server URL (`docuelevate://qr-login?token=...&server=...`), so there is no need to enter the server URL manually.
+4. An API token is issued for the mobile device and the web UI is notified via polling.
+
+> **Note:** The countdown timer on the web page uses server-relative time (TTL in seconds) rather than absolute timestamps, so it works correctly even when the client's clock is not in sync with the server.
### Configuration
| Variable | Description | Default |
|----------|-------------|---------|
-| `QR_LOGIN_CHALLENGE_TTL_SECONDS` | How long a QR challenge is valid | `120` |
+| `QR_LOGIN_CHALLENGE_TTL_SECONDS` | How long a QR challenge is valid (seconds) | `120` |
### API Endpoints
| Method | Path | Description |
|--------|------|-------------|
-| `POST` | `/api/qr-auth/challenge` | Create a new QR login challenge |
+| `POST` | `/api/qr-auth/challenge` | Create a new QR login challenge (returns `ttl_seconds` for client countdown) |
| `GET` | `/api/qr-auth/challenge/{id}/status` | Poll the status of a challenge |
| `POST` | `/api/qr-auth/claim` | Claim a challenge from a mobile device |
diff --git a/docs/MobileApp.md b/docs/MobileApp.md
index 77df917f..8b8e6f79 100644
--- a/docs/MobileApp.md
+++ b/docs/MobileApp.md
@@ -8,6 +8,7 @@ DocuElevate includes a native mobile application for iOS and Android built with
|---------|-----|---------|
| SSO login (OAuth2) | ✅ | ✅ |
| Local / basic auth login | ✅ | ✅ |
+| QR code login (scan from web) | ✅ | ✅ |
| Auto-generated API token | ✅ | ✅ |
| Camera capture → upload | ✅ | ✅ |
| File picker upload | ✅ | ✅ |
@@ -112,6 +113,18 @@ When developing with **Expo Go** the app does not have the `docuelevate://` cust
No extra configuration is needed — just run `npx expo start` and scan the QR code with the **Expo Go** app.
+### QR Code Login Flow
+
+As an alternative to SSO, users can log in by scanning a QR code displayed in the web UI:
+
+1. The authenticated web user navigates to **Profile → Security & Sessions → Log in on mobile via QR code**.
+2. A QR code is displayed containing a deep link: `docuelevate://qr-login?token=&server=`.
+3. In the mobile app, the user taps **Scan QR Code to Login**, which opens the device camera.
+4. The app scans the QR code, extracts both the server URL and the challenge token, and calls `POST /api/qr-auth/claim`.
+5. An API token is issued and stored securely — no need to enter the server URL manually.
+
+> **Note:** The QR code already contains the server URL, so users do not need to type it in when using QR login.
+
### Auto-generated Mobile Token
When the mobile app completes login it automatically creates a named API token (`"Mobile App – "`) via `POST /api/mobile/generate-token`. This token:
@@ -286,7 +299,8 @@ mobile/
│ ├── (auth)/ # Unauthenticated route group
│ │ ├── _layout.tsx # Stack navigator (headerless)
│ │ ├── index.tsx # Welcome screen
-│ │ └── login.tsx # Login screen
+│ │ ├── login.tsx # Login screen
+│ │ └── qr-scanner.tsx # QR code scanner screen
│ └── (tabs)/ # Authenticated route group
│ ├── _layout.tsx # Tab navigator
│ ├── index.tsx # Upload screen (default tab)
@@ -303,7 +317,8 @@ mobile/
├── hooks/
│ └── usePushNotifications.ts # Push token registration
├── screens/
- │ ├── LoginScreen.tsx # Server URL + SSO button
+ │ ├── LoginScreen.tsx # Server URL + SSO button + QR code scanner
+ │ ├── QRScannerScreen.tsx # Camera-based QR code scanner for login
│ ├── UploadScreen.tsx # Camera capture + photo library + file picker
│ ├── FilesScreen.tsx # Processed document list
│ └── ProfileScreen.tsx # User profile + sign out
From 70b193e07d2f85e353b6b12c56bf5bcbf828ee24 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Mar 2026 12:05:39 +0000
Subject: [PATCH 09/37] fix(mobile): replace gap with marginLeft for React
Native compatibility
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
mobile/src/screens/QRScannerScreen.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/mobile/src/screens/QRScannerScreen.tsx b/mobile/src/screens/QRScannerScreen.tsx
index 9831112a..696c8e2b 100644
--- a/mobile/src/screens/QRScannerScreen.tsx
+++ b/mobile/src/screens/QRScannerScreen.tsx
@@ -263,12 +263,12 @@ const styles = StyleSheet.create({
statusContainer: {
flexDirection: "row",
alignItems: "center",
- gap: 10,
},
statusText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
+ marginLeft: 10,
},
instructionText: {
color: "#fff",
From 3ac49965a5d3fa47da24bddc723a30af31f22259 Mon Sep 17 00:00:00 2001
From: semantic-release
Date: Tue, 17 Mar 2026 13:03:19 +0000
Subject: [PATCH 10/37] 0.155.1
Automatically generated by python-semantic-release
---
CHANGELOG.md | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 19be28ad..d5434fd0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
+## v0.155.1 (2026-03-17)
+
+### Bug Fixes
+
+- **api**: Add ttl_seconds to QR challenge response and fix client-side countdown
+ ([`0f6a1ee`](https://github.com/christianlouis/DocuElevate/commit/0f6a1ee1ec8186d70afc17abe50258c968058c92))
+
+- **mobile**: Replace gap with marginLeft for React Native compatibility
+ ([`70b193e`](https://github.com/christianlouis/DocuElevate/commit/70b193e07d2f85e353b6b12c56bf5bcbf828ee24))
+
+### Documentation
+
+- Update QR code login documentation with scanner and TTL details
+ ([`723b14e`](https://github.com/christianlouis/DocuElevate/commit/723b14e660737887c454b8e8300ac38bb390841f))
+
+
## v0.155.0 (2026-03-17)
From a77a29444dd5e09a2b93e0d73327b4f8cce8b2ec Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Tue, 17 Mar 2026 13:03:22 +0000
Subject: [PATCH 11/37] chore(release): update build metadata files [skip ci]
---
BUILD_DATE | 2 +-
GIT_SHA | 2 +-
RUNTIME_INFO | 12 ++++++------
VERSION | 2 +-
4 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/BUILD_DATE b/BUILD_DATE
index 81deb728..4e37fa38 100644
--- a/BUILD_DATE
+++ b/BUILD_DATE
@@ -1 +1 @@
-2026-03-17T11:23:05Z
+2026-03-17T13:03:19Z
diff --git a/GIT_SHA b/GIT_SHA
index 78a6cd29..58b1decc 100644
--- a/GIT_SHA
+++ b/GIT_SHA
@@ -1 +1 @@
-30c2e9a
+ad329d0
diff --git a/RUNTIME_INFO b/RUNTIME_INFO
index b9756ce3..678a7911 100644
--- a/RUNTIME_INFO
+++ b/RUNTIME_INFO
@@ -1,10 +1,10 @@
DocuElevate Build Information
==============================
-Version: 0.155.0
-Build Date: 2026-03-17T11:23:05Z
-Git Commit: 30c2e9afefc57c8d1e19548afec7475f5a838f24
-Git Short SHA: 30c2e9a
+Version: 0.155.1
+Build Date: 2026-03-17T13:03:19Z
+Git Commit: ad329d0aa78f10e8fd3910d1af8297682b9e55fa
+Git Short SHA: ad329d0
Git Branch: main
-Commit Date: 2026-03-17T12:22:07+01:00
-Build Timestamp: 2026-03-17T11:23:05Z
+Commit Date: 2026-03-17T14:02:55+01:00
+Build Timestamp: 2026-03-17T13:03:19Z
==============================
diff --git a/VERSION b/VERSION
index 03ed6e33..7cfba730 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.155.0
+0.155.1
From 4a07c49bc7c9002b5b4347f3e44f0df8b37d0e3f Mon Sep 17 00:00:00 2001
From: semantic-release
Date: Tue, 17 Mar 2026 13:12:02 +0000
Subject: [PATCH 12/37] 0.156.0
Automatically generated by python-semantic-release
---
CHANGELOG.md | 21 +++++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d5434fd0..53af22b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
+## v0.156.0 (2026-03-17)
+
+### Bug Fixes
+
+- **storage**: Use RuntimeError instead of bare Exception in SharePoint task
+ ([`2b698cc`](https://github.com/christianlouis/DocuElevate/commit/2b698cc6940fb731b1ab87300ad0f7fdebc8f024))
+
+- **test**: Add missing _should_upload_to_sharepoint mock to send_to_all tests
+ ([`bcf2d00`](https://github.com/christianlouis/DocuElevate/commit/bcf2d00c3324a3ded3852e16759ea4d0af3af666))
+
+### Documentation
+
+- Add SharePoint setup guide and update all references
+ ([`13aa14b`](https://github.com/christianlouis/DocuElevate/commit/13aa14b8e4102437f72f6c260b2795a6ee761eb9))
+
+### Features
+
+- **storage**: Add SharePoint integration for document storage
+ ([`b85fc1d`](https://github.com/christianlouis/DocuElevate/commit/b85fc1d277475c06c1efa100c391b7a3c43e7c25))
+
+
## v0.155.1 (2026-03-17)
### Bug Fixes
From bf6b9177af01bcd641b1378fd0d48a40982ad6cf Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Tue, 17 Mar 2026 13:12:05 +0000
Subject: [PATCH 13/37] chore(release): update build metadata files [skip ci]
---
BUILD_DATE | 2 +-
GIT_SHA | 2 +-
RUNTIME_INFO | 12 ++++++------
VERSION | 2 +-
4 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/BUILD_DATE b/BUILD_DATE
index 4e37fa38..13dc73cd 100644
--- a/BUILD_DATE
+++ b/BUILD_DATE
@@ -1 +1 @@
-2026-03-17T13:03:19Z
+2026-03-17T13:12:02Z
diff --git a/GIT_SHA b/GIT_SHA
index 58b1decc..0872f7b3 100644
--- a/GIT_SHA
+++ b/GIT_SHA
@@ -1 +1 @@
-ad329d0
+a3c657b
diff --git a/RUNTIME_INFO b/RUNTIME_INFO
index 678a7911..a46c1eb1 100644
--- a/RUNTIME_INFO
+++ b/RUNTIME_INFO
@@ -1,10 +1,10 @@
DocuElevate Build Information
==============================
-Version: 0.155.1
-Build Date: 2026-03-17T13:03:19Z
-Git Commit: ad329d0aa78f10e8fd3910d1af8297682b9e55fa
-Git Short SHA: ad329d0
+Version: 0.156.0
+Build Date: 2026-03-17T13:12:02Z
+Git Commit: a3c657b947d774255ede33bcf6143ab60c69d1ac
+Git Short SHA: a3c657b
Git Branch: main
-Commit Date: 2026-03-17T14:02:55+01:00
-Build Timestamp: 2026-03-17T13:03:19Z
+Commit Date: 2026-03-17T14:11:41+01:00
+Build Timestamp: 2026-03-17T13:12:02Z
==============================
diff --git a/VERSION b/VERSION
index 7cfba730..b97a9dda 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.155.1
+0.156.0
From f6a2bae05ec8fc188e6da4cb439b523a4a72a63d Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Wed, 18 Mar 2026 03:44:12 +0000
Subject: [PATCH 14/37] Fix authorization bypass in API (IDOR) by applying
owner filter
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
app/api/files.py | 36 +++++++++++++++++++++++++++---------
1 file changed, 27 insertions(+), 9 deletions(-)
diff --git a/app/api/files.py b/app/api/files.py
index 64aa62b0..65cdaaf8 100644
--- a/app/api/files.py
+++ b/app/api/files.py
@@ -346,7 +346,9 @@ def bulk_delete_files(request: Request, file_ids: List[int], db: DbSession):
try:
# Find all file records
- file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all()
+ query = db.query(FileRecord).filter(FileRecord.id.in_(file_ids))
+ query = apply_owner_filter(query, request)
+ file_records = query.all()
if not file_records:
raise HTTPException(status_code=404, detail="No files found with the provided IDs")
@@ -385,7 +387,9 @@ def bulk_reprocess_files(request: Request, file_ids: List[int], db: DbSession):
"""
try:
# Find all file records
- file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all()
+ query = db.query(FileRecord).filter(FileRecord.id.in_(file_ids))
+ query = apply_owner_filter(query, request)
+ file_records = query.all()
if not file_records:
raise HTTPException(status_code=404, detail="No files found with the provided IDs")
@@ -457,7 +461,9 @@ def bulk_reprocess_files_cloud_ocr(request: Request, file_ids: List[int], db: Db
Useful for re-running OCR on files with poor text quality or missing OCR text.
"""
try:
- file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all()
+ query = db.query(FileRecord).filter(FileRecord.id.in_(file_ids))
+ query = apply_owner_filter(query, request)
+ file_records = query.all()
if not file_records:
raise HTTPException(status_code=404, detail="No files found with the provided IDs")
@@ -537,7 +543,9 @@ def bulk_download_files(request: Request, file_ids: List[int], db: DbSession):
Files not found on disk are silently skipped.
"""
try:
- file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all()
+ query = db.query(FileRecord).filter(FileRecord.id.in_(file_ids))
+ query = apply_owner_filter(query, request)
+ file_records = query.all()
if not file_records:
raise HTTPException(status_code=404, detail="No files found with the provided IDs")
@@ -619,7 +627,9 @@ def reprocess_single_file(request: Request, file_id: int, db: DbSession):
"""
try:
# Find the file record
- file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
+ query = db.query(FileRecord).filter(FileRecord.id == file_id)
+ query = apply_owner_filter(query, request)
+ file_record = query.first()
if not file_record:
raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found")
@@ -675,7 +685,9 @@ def reprocess_with_cloud_ocr(request: Request, file_id: int, db: DbSession):
"""
try:
# Find the file record
- file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
+ query = db.query(FileRecord).filter(FileRecord.id == file_id)
+ query = apply_owner_filter(query, request)
+ file_record = query.first()
if not file_record:
raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found")
@@ -938,7 +950,9 @@ def retry_subtask(
"""
try:
# Find the file record
- file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
+ query = db.query(FileRecord).filter(FileRecord.id == file_id)
+ query = apply_owner_filter(query, request)
+ file_record = query.first()
if not file_record:
raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found")
@@ -1080,7 +1094,9 @@ def get_file_preview(
try:
# Find the file record
- file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
+ query = db.query(FileRecord).filter(FileRecord.id == file_id)
+ query = apply_owner_filter(query, request)
+ file_record = query.first()
if not file_record:
raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found")
@@ -1160,7 +1176,9 @@ def download_file(
try:
# Find the file record
- file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
+ query = db.query(FileRecord).filter(FileRecord.id == file_id)
+ query = apply_owner_filter(query, request)
+ file_record = query.first()
if not file_record:
raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found")
From 2db65647eed4827245b87ecfdeb1c8a2ee346c49 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 18 Mar 2026 07:41:38 +0000
Subject: [PATCH 15/37] Initial plan
From dc0a19bd118d3503ad50608f55fb0bc4ce104948 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 18 Mar 2026 07:53:16 +0000
Subject: [PATCH 16/37] fix: add missing SETTING_METADATA entries for db pool
and upload rate limit settings
- Add db_pool_size, db_max_overflow, db_pool_timeout, db_pool_recycle fields to app/config.py
- Add upload_rate_limit_per_user, upload_rate_limit_window fields to app/config.py
- Update app/database.py to use NullPool for SQLite and QueuePool with config-driven
pool settings for PostgreSQL/MySQL
- Add all 6 settings to SETTING_METADATA in app/utils/settings_service.py
Fixes test_all_config_settings_have_metadata failure
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
app/config.py | 39 +++++++++++++++++++++
app/database.py | 16 ++++++++-
app/utils/settings_service.py | 66 +++++++++++++++++++++++++++++++++++
3 files changed, 120 insertions(+), 1 deletion(-)
diff --git a/app/config.py b/app/config.py
index cff42826..14080ac5 100644
--- a/app/config.py
+++ b/app/config.py
@@ -1115,6 +1115,45 @@ class Settings(BaseSettings):
),
)
+ # Database Connection Pool Configuration
+ # Controls SQLAlchemy QueuePool behaviour for PostgreSQL/MySQL.
+ # SQLite uses NullPool and ignores these settings.
+ db_pool_size: int = Field(
+ default=5,
+ description="Number of persistent connections kept in the pool. Ignored for SQLite.",
+ )
+ db_max_overflow: int = Field(
+ default=10,
+ description=("Maximum number of connections that can be opened beyond db_pool_size. Ignored for SQLite."),
+ )
+ db_pool_timeout: int = Field(
+ default=30,
+ description="Seconds to wait for a connection from the pool before raising an error. Ignored for SQLite.",
+ )
+ db_pool_recycle: int = Field(
+ default=1800,
+ description=(
+ "Seconds after which a connection is recycled to prevent stale connections. "
+ "Ignored for SQLite. Default: 1800 (30 minutes)."
+ ),
+ )
+
+ # Per-user upload rate limiting (health-aware limiter)
+ # Controls how many uploads a single user may submit within a sliding window.
+ upload_rate_limit_per_user: int = Field(
+ default=20,
+ description=(
+ "Maximum number of uploads allowed per user within the upload_rate_limit_window. "
+ "The limiter may dynamically reduce this value when Redis queue depth or CPU load is high."
+ ),
+ )
+ upload_rate_limit_window: int = Field(
+ default=60,
+ description=(
+ "Sliding window in seconds over which upload_rate_limit_per_user is enforced. Default: 60 seconds."
+ ),
+ )
+
# Rate Limiting Configuration (see SECURITY_AUDIT.md and docs/API.md)
# Protects against DoS attacks and API abuse
rate_limiting_enabled: bool = Field(
diff --git a/app/database.py b/app/database.py
index f563931a..06fc7471 100644
--- a/app/database.py
+++ b/app/database.py
@@ -10,6 +10,7 @@ from typing import Any
from sqlalchemy import create_engine, exc
from sqlalchemy.engine.url import make_url
from sqlalchemy.orm import Session, declarative_base, sessionmaker
+from sqlalchemy.pool import NullPool, QueuePool
from app.config import settings
@@ -19,7 +20,20 @@ Base = declarative_base()
# Parse the DATABASE_URL
DB_URL = settings.database_url
-engine = create_engine(DB_URL, connect_args={"check_same_thread": False})
+_db_url = make_url(DB_URL)
+if _db_url.get_backend_name() == "sqlite":
+ # SQLite does not benefit from connection pooling; NullPool avoids contention.
+ engine = create_engine(DB_URL, connect_args={"check_same_thread": False}, poolclass=NullPool)
+else:
+ # PostgreSQL / MySQL / other: use a configurable QueuePool.
+ engine = create_engine(
+ DB_URL,
+ poolclass=QueuePool,
+ pool_size=settings.db_pool_size,
+ max_overflow=settings.db_max_overflow,
+ pool_timeout=settings.db_pool_timeout,
+ pool_recycle=settings.db_pool_recycle,
+ )
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py
index 41e870ec..fef1429d 100644
--- a/app/utils/settings_service.py
+++ b/app/utils/settings_service.py
@@ -2557,6 +2557,72 @@ SETTING_METADATA = {
"required": False,
"restart_required": False,
},
+ # Database Connection Pool
+ "db_pool_size": {
+ "category": "Core",
+ "description": (
+ "Number of persistent connections kept in the SQLAlchemy QueuePool. "
+ "Has no effect for SQLite databases. Default: 5."
+ ),
+ "type": "integer",
+ "sensitive": False,
+ "required": False,
+ "restart_required": True,
+ },
+ "db_max_overflow": {
+ "category": "Core",
+ "description": (
+ "Maximum extra connections that can be opened beyond db_pool_size. "
+ "Has no effect for SQLite databases. Default: 10."
+ ),
+ "type": "integer",
+ "sensitive": False,
+ "required": False,
+ "restart_required": True,
+ },
+ "db_pool_timeout": {
+ "category": "Core",
+ "description": (
+ "Seconds to wait for a connection from the pool before raising an error. "
+ "Has no effect for SQLite databases. Default: 30."
+ ),
+ "type": "integer",
+ "sensitive": False,
+ "required": False,
+ "restart_required": True,
+ },
+ "db_pool_recycle": {
+ "category": "Core",
+ "description": (
+ "Seconds after which idle connections are recycled to prevent stale connections. "
+ "Has no effect for SQLite databases. Default: 1800 (30 minutes)."
+ ),
+ "type": "integer",
+ "sensitive": False,
+ "required": False,
+ "restart_required": True,
+ },
+ # Per-user upload rate limiting
+ "upload_rate_limit_per_user": {
+ "category": "Security",
+ "description": (
+ "Maximum number of uploads a single user may submit within upload_rate_limit_window seconds. "
+ "The health-aware limiter may reduce this dynamically under high Redis queue depth or CPU load. "
+ "Default: 20."
+ ),
+ "type": "integer",
+ "sensitive": False,
+ "required": False,
+ "restart_required": False,
+ },
+ "upload_rate_limit_window": {
+ "category": "Security",
+ "description": ("Sliding window in seconds over which upload_rate_limit_per_user is enforced. Default: 60."),
+ "type": "integer",
+ "sensitive": False,
+ "required": False,
+ "restart_required": False,
+ },
# Rate Limiting
"rate_limiting_enabled": {
"category": "Security",
From bf4be557799385aee11752a116a3f9956d3033af Mon Sep 17 00:00:00 2001
From: semantic-release
Date: Wed, 18 Mar 2026 08:08:56 +0000
Subject: [PATCH 17/37] 0.156.1
Automatically generated by python-semantic-release
---
CHANGELOG.md | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 53af22b1..75c2fbc9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
+## v0.156.1 (2026-03-18)
+
+### Bug Fixes
+
+- Add missing SETTING_METADATA entries for db pool and upload rate limit settings
+ ([`dc0a19b`](https://github.com/christianlouis/DocuElevate/commit/dc0a19bd118d3503ad50608f55fb0bc4ce104948))
+
+
## v0.156.0 (2026-03-17)
### Bug Fixes
From 020bc6a9c7ce7c03fd86dad38246978fb7b76e66 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Wed, 18 Mar 2026 08:08:59 +0000
Subject: [PATCH 18/37] chore(release): update build metadata files [skip ci]
---
BUILD_DATE | 2 +-
GIT_SHA | 2 +-
RUNTIME_INFO | 12 ++++++------
VERSION | 2 +-
4 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/BUILD_DATE b/BUILD_DATE
index 13dc73cd..29d93907 100644
--- a/BUILD_DATE
+++ b/BUILD_DATE
@@ -1 +1 @@
-2026-03-17T13:12:02Z
+2026-03-18T08:08:56Z
diff --git a/GIT_SHA b/GIT_SHA
index 0872f7b3..c7a45d09 100644
--- a/GIT_SHA
+++ b/GIT_SHA
@@ -1 +1 @@
-a3c657b
+7e78671
diff --git a/RUNTIME_INFO b/RUNTIME_INFO
index a46c1eb1..cf3d2111 100644
--- a/RUNTIME_INFO
+++ b/RUNTIME_INFO
@@ -1,10 +1,10 @@
DocuElevate Build Information
==============================
-Version: 0.156.0
-Build Date: 2026-03-17T13:12:02Z
-Git Commit: a3c657b947d774255ede33bcf6143ab60c69d1ac
-Git Short SHA: a3c657b
+Version: 0.156.1
+Build Date: 2026-03-18T08:08:56Z
+Git Commit: 7e786712f42251c52f10283f58d87b81dc81ca7b
+Git Short SHA: 7e78671
Git Branch: main
-Commit Date: 2026-03-17T14:11:41+01:00
-Build Timestamp: 2026-03-17T13:12:02Z
+Commit Date: 2026-03-18T09:08:37+01:00
+Build Timestamp: 2026-03-18T08:08:56Z
==============================
diff --git a/VERSION b/VERSION
index b97a9dda..96bbc366 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.156.0
+0.156.1
From e7eda8af5e8949d278f6fc542324fd7aba1988fd Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 18 Mar 2026 08:24:08 +0000
Subject: [PATCH 19/37] Initial plan
From 6727253958a6ae8436fc1184736ec43eef2ab820 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 18 Mar 2026 08:27:07 +0000
Subject: [PATCH 20/37] chore: initial plan for server-side QR code rendering
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
=1.6.0 | 0
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 =1.6.0
diff --git a/=1.6.0 b/=1.6.0
new file mode 100644
index 00000000..e69de29b
From a8eb6504ac100c14b10f57511fde8f1202deeffe Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 18 Mar 2026 08:34:22 +0000
Subject: [PATCH 21/37] fix(qr-login): render QR code server-side using segno
instead of CDN JS library
The QR code on /qr-login was not rendering because it depended on loading
qrcode@1.5.4 from the jsdelivr CDN, which may be blocked in some network
environments.
- Add segno>=1.6.0 (pure-Python QR library, no Pillow needed) to requirements.txt
- Generate QR code as a base64 SVG data URI server-side in the challenge endpoint
- Add qr_code_svg field to CreateChallengeResponse Pydantic model
- Replace canvas+CDN script in qr_login.html with an
- Remove the $nextTick/QRCode.toCanvas() client-side rendering block
- Extract QR rendering parameters (_QR_ERROR_LEVEL, _QR_SCALE) as module constants
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
app/api/qr_auth.py | 28 ++++++++++++++++++++++++++++
frontend/templates/qr_login.html | 25 ++++++++++---------------
requirements.txt | 1 +
3 files changed, 39 insertions(+), 15 deletions(-)
diff --git a/app/api/qr_auth.py b/app/api/qr_auth.py
index b9dbaa5d..8f891e4e 100644
--- a/app/api/qr_auth.py
+++ b/app/api/qr_auth.py
@@ -19,10 +19,13 @@ Security properties:
from __future__ import annotations
+import base64
+import io
import logging
from datetime import datetime
from typing import Annotated, Any
+import segno
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
@@ -72,6 +75,7 @@ class CreateChallengeResponse(BaseModel):
expires_at: datetime
ttl_seconds: int = Field(description="Seconds until the challenge expires (use for client-side countdown).")
qr_payload: str = Field(description="The string to encode in the QR code.")
+ qr_code_svg: str = Field(description="Base64-encoded SVG data URI of the QR code, ready for use in an
src.")
class ChallengeStatusResponse(BaseModel):
@@ -106,6 +110,29 @@ class ClaimChallengeResponse(BaseModel):
created_at: datetime
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+# QR code rendering parameters
+_QR_ERROR_LEVEL = "M" # Medium error correction (~15% recovery); sufficient for on-screen display
+_QR_SCALE = 4 # Each QR module is rendered as 4×4 SVG pixels
+
+
+def _generate_qr_svg(payload: str) -> str:
+ """Generate a QR code for *payload* and return it as a base64 SVG data URI.
+
+ Using ``segno`` (pure-Python, no Pillow dependency) and SVG output so the
+ QR code scales crisply at any resolution without requiring a canvas or any
+ client-side JavaScript library.
+ """
+ qr = segno.make(payload, error=_QR_ERROR_LEVEL)
+ buf = io.BytesIO()
+ qr.save(buf, kind="svg", scale=_QR_SCALE, xmldecl=False, svgclass=None, lineclass=None, omitsize=True)
+ svg_bytes = buf.getvalue()
+ return "data:image/svg+xml;base64," + base64.b64encode(svg_bytes).decode("ascii")
+
+
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@@ -143,6 +170,7 @@ async def create_challenge(
"expires_at": challenge.expires_at,
"ttl_seconds": ttl_seconds,
"qr_payload": qr_payload,
+ "qr_code_svg": _generate_qr_svg(qr_payload),
}
diff --git a/frontend/templates/qr_login.html b/frontend/templates/qr_login.html
index 7b29f594..a2ecac26 100644
--- a/frontend/templates/qr_login.html
+++ b/frontend/templates/qr_login.html
@@ -32,7 +32,13 @@
id="qr-container"
aria-label="{{ _('qr_login.description') }}"
>
-
+