fix: merge main branch and renumber migration 037→040

Resolve all merge conflicts between our automation feature branch and
current main (v0.163.0, 920 commits ahead).

Conflicts resolved:
- app/api/__init__.py: add automation_router alongside main's new routers
  (classification_rules, qr_auth, sessions, system_reset)
- app/config.py: add main's new settings (dropbox_use_global_credentials,
  factory_reset_on_startup, enable_factory_reset)
- app/models.py: add main's new models (ClassificationRuleModel, UserSession,
  QRLoginChallenge, SharePoint integration type)
- app/utils/settings_service.py: merge automation_hooks_enabled with main's
  new metadata entries
- docs/API.md: merge automation API docs with main's classification rules docs
- docs/ConfigurationGuide.md: add factory reset settings
- tests/conftest.py: import both AutomationHook and new main models

Migration renumbered:
- 037_add_automation_hooks → 040_add_automation_hooks
- down_revision: 039_add_classification_rules (was 036_add_document_translation_fields)
- Chain: 036 → 037 → 038 → 039 → 040 (automation hooks)

For all non-automation files with conflicts, main's version was taken since
our branch did not modify those files (conflicts were from a stale prior merge).

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/cb62f012-3b69-4415-835e-3857ce3e9f45
This commit is contained in:
copilot-swe-agent[bot]
2026-03-20 23:54:04 +00:00
parent d1ebac74a1
commit e518bce922
153 changed files with 16306 additions and 922 deletions
+10
View File
@@ -63,6 +63,7 @@ from app.models import ( # noqa: F401, E402
ApiToken,
AuditLog,
AutomationHook,
ClassificationRuleModel,
ComplianceTemplate,
DocumentMetadata,
FileRecord,
@@ -116,6 +117,7 @@ def client(db_session) -> TestClient:
# Import the canonical get_db function
from app.database import get_db
from app.middleware.upload_rate_limit import require_upload_rate_limit
# Override the get_db dependency to use our test database
def override_get_db():
@@ -127,6 +129,14 @@ def client(db_session) -> TestClient:
# Override the single canonical get_db dependency
fastapi_app.dependency_overrides[get_db] = override_get_db
# Disable per-user upload rate limiting in tests so that upload-heavy
# test suites are not rejected with 429 Too Many Requests.
async def _no_rate_limit() -> None:
"""No-op override: skip upload rate limiting during tests."""
return None
fastapi_app.dependency_overrides[require_upload_rate_limit] = _no_rate_limit
# Use base_url to satisfy TrustedHostMiddleware
with TestClient(fastapi_app, base_url="http://localhost") as test_client:
yield test_client
+2
View File
@@ -83,6 +83,8 @@ class TestGotenbergCoverageDocuments:
".tif",
".webp",
".svg",
".heic",
".heif",
}
_html_extensions = {".html", ".htm"}
_markdown_extensions = {".md", ".markdown"}
+290
View File
@@ -0,0 +1,290 @@
"""Tests for the classification rules API endpoints.
Covers CRUD operations, validation, and access control for
``/api/classification-rules``.
"""
import pytest
from app.models import ClassificationRuleModel
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_rule(db_session, owner_id="anonymous", **overrides):
"""Insert a ClassificationRuleModel and return it."""
defaults = {
"owner_id": owner_id,
"name": "test_rule",
"category": "invoice",
"rule_type": "filename_pattern",
"pattern": r"(?i)invoice",
"priority": 0,
"case_sensitive": False,
"enabled": True,
}
defaults.update(overrides)
rule = ClassificationRuleModel(**defaults)
db_session.add(rule)
db_session.commit()
db_session.refresh(rule)
return rule
# ---------------------------------------------------------------------------
# Categories & Rule Types endpoints
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestCategoriesEndpoint:
"""Tests for GET /api/classification-rules/categories."""
def test_list_categories(self, client):
"""Should return a dict of built-in categories."""
r = client.get("/api/classification-rules/categories")
assert r.status_code == 200
data = r.json()
assert isinstance(data, dict)
assert "invoice" in data
assert "contract" in data
assert "receipt" in data
assert "unknown" in data
@pytest.mark.unit
class TestRuleTypesEndpoint:
"""Tests for GET /api/classification-rules/rule-types."""
def test_list_rule_types(self, client):
"""Should return a list of valid rule types."""
r = client.get("/api/classification-rules/rule-types")
assert r.status_code == 200
data = r.json()
assert isinstance(data, list)
assert len(data) == 3
type_values = {item["type"] for item in data}
assert "filename_pattern" in type_values
assert "content_keyword" in type_values
assert "metadata_match" in type_values
# ---------------------------------------------------------------------------
# CRUD Operations
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestClassificationRuleCRUD:
"""Full CRUD test-suite for classification rules."""
def test_list_rules_empty(self, client):
"""List returns an empty array when no rules exist."""
r = client.get("/api/classification-rules/")
assert r.status_code == 200
assert r.json() == []
def test_create_rule(self, client):
"""POST should create a new classification rule."""
r = client.post(
"/api/classification-rules/",
json={
"name": "My Invoice Rule",
"category": "invoice",
"rule_type": "filename_pattern",
"pattern": r"(?i)rechnung",
"priority": 10,
},
)
assert r.status_code == 201
data = r.json()
assert data["name"] == "My Invoice Rule"
assert data["category"] == "invoice"
assert data["rule_type"] == "filename_pattern"
assert data["priority"] == 10
assert data["enabled"] is True
assert data["id"] is not None
def test_create_rule_invalid_type_rejected(self, client):
"""Creating a rule with an invalid rule_type should be rejected."""
r = client.post(
"/api/classification-rules/",
json={
"name": "Bad Rule",
"category": "test",
"rule_type": "invalid_type",
"pattern": "test",
},
)
assert r.status_code == 400
def test_create_duplicate_name_rejected(self, client):
"""Creating two rules with the same name should be rejected."""
payload = {
"name": "Dupe Rule",
"category": "invoice",
"rule_type": "filename_pattern",
"pattern": "test",
}
r1 = client.post("/api/classification-rules/", json=payload)
assert r1.status_code == 201
r2 = client.post("/api/classification-rules/", json=payload)
assert r2.status_code == 409
def test_get_rule(self, client):
"""GET should return a specific rule by ID."""
create_resp = client.post(
"/api/classification-rules/",
json={
"name": "Get Test Rule",
"category": "contract",
"rule_type": "content_keyword",
"pattern": "agreement|terms",
},
)
rule_id = create_resp.json()["id"]
r = client.get(f"/api/classification-rules/{rule_id}")
assert r.status_code == 200
assert r.json()["name"] == "Get Test Rule"
assert r.json()["category"] == "contract"
def test_get_nonexistent_rule(self, client):
"""GET for a nonexistent rule should return 404."""
r = client.get("/api/classification-rules/99999")
assert r.status_code == 404
def test_update_rule(self, client):
"""PUT should update an existing rule."""
create_resp = client.post(
"/api/classification-rules/",
json={
"name": "Update Test",
"category": "receipt",
"rule_type": "filename_pattern",
"pattern": "receipt",
},
)
rule_id = create_resp.json()["id"]
r = client.put(
f"/api/classification-rules/{rule_id}",
json={"category": "invoice", "priority": 50},
)
assert r.status_code == 200
assert r.json()["category"] == "invoice"
assert r.json()["priority"] == 50
# Name should be unchanged
assert r.json()["name"] == "Update Test"
def test_update_nonexistent_rule(self, client):
"""PUT for a nonexistent rule should return 404."""
r = client.put("/api/classification-rules/99999", json={"category": "test"})
assert r.status_code == 404
def test_update_invalid_rule_type_rejected(self, client):
"""PUT with an invalid rule_type should be rejected."""
create_resp = client.post(
"/api/classification-rules/",
json={
"name": "Invalid Update",
"category": "test",
"rule_type": "filename_pattern",
"pattern": "test",
},
)
rule_id = create_resp.json()["id"]
r = client.put(
f"/api/classification-rules/{rule_id}",
json={"rule_type": "bad_type"},
)
assert r.status_code == 400
def test_delete_rule(self, client):
"""DELETE should remove the rule."""
create_resp = client.post(
"/api/classification-rules/",
json={
"name": "Delete Test",
"category": "test",
"rule_type": "content_keyword",
"pattern": "test",
},
)
rule_id = create_resp.json()["id"]
r = client.delete(f"/api/classification-rules/{rule_id}")
assert r.status_code == 204
# Verify it's gone
r2 = client.get(f"/api/classification-rules/{rule_id}")
assert r2.status_code == 404
def test_delete_nonexistent_rule(self, client):
"""DELETE for a nonexistent rule should return 404."""
r = client.delete("/api/classification-rules/99999")
assert r.status_code == 404
def test_list_rules_after_create(self, client):
"""List should return created rules."""
client.post(
"/api/classification-rules/",
json={
"name": "List Rule 1",
"category": "invoice",
"rule_type": "filename_pattern",
"pattern": "test1",
},
)
client.post(
"/api/classification-rules/",
json={
"name": "List Rule 2",
"category": "contract",
"rule_type": "content_keyword",
"pattern": "test2",
},
)
r = client.get("/api/classification-rules/")
assert r.status_code == 200
assert len(r.json()) == 2
def test_create_rule_with_all_fields(self, client):
"""Create a rule providing all optional fields."""
r = client.post(
"/api/classification-rules/",
json={
"name": "Full Rule",
"category": "tax_document",
"rule_type": "metadata_match",
"pattern": "department=finance",
"priority": 100,
"case_sensitive": True,
"enabled": False,
},
)
assert r.status_code == 201
data = r.json()
assert data["case_sensitive"] is True
assert data["enabled"] is False
assert data["priority"] == 100
def test_create_rule_defaults(self, client):
"""Create a rule with minimal fields to test defaults."""
r = client.post(
"/api/classification-rules/",
json={
"name": "Minimal Rule",
"category": "invoice",
"rule_type": "filename_pattern",
"pattern": "test",
},
)
assert r.status_code == 201
data = r.json()
assert data["priority"] == 0
assert data["case_sensitive"] is False
assert data["enabled"] is True
+250
View File
@@ -416,3 +416,253 @@ class TestSaveDropboxSettings:
# .env write is best-effort; endpoint should still succeed via DB write
assert response.status_code == 200
assert response.json()["status"] == "success"
@pytest.mark.unit
class TestListDropboxFolders:
"""Tests for list_dropbox_folders endpoint."""
@patch("app.api.dropbox.requests.post")
def test_list_folders_success(self, mock_post, client):
"""Test successful folder listing at root."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"entries": [
{".tag": "folder", "name": "Documents", "path_display": "/Documents", "id": "id:1"},
{".tag": "folder", "name": "Photos", "path_display": "/Photos", "id": "id:2"},
{".tag": "file", "name": "readme.txt", "path_display": "/readme.txt", "id": "id:3"},
],
"has_more": False,
}
mock_post.return_value = mock_response
response = client.post(
"/api/dropbox/list-folders",
data={"access_token": "test-token", "path": ""},
)
assert response.status_code == 200
data = response.json()
assert len(data["folders"]) == 2
assert data["folders"][0]["name"] == "Documents"
assert data["folders"][1]["name"] == "Photos"
assert data["path"] == "/"
assert data["has_more"] is False
@patch("app.api.dropbox.requests.post")
def test_list_folders_subfolder(self, mock_post, client):
"""Test listing folders in a subfolder."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"entries": [
{".tag": "folder", "name": "Invoices", "path_display": "/Documents/Invoices", "id": "id:4"},
],
"has_more": False,
}
mock_post.return_value = mock_response
response = client.post(
"/api/dropbox/list-folders",
data={"access_token": "test-token", "path": "/Documents"},
)
assert response.status_code == 200
data = response.json()
assert len(data["folders"]) == 1
assert data["folders"][0]["path"] == "/Documents/Invoices"
@patch("app.api.dropbox.requests.post")
def test_list_folders_empty(self, mock_post, client):
"""Test listing folders in an empty directory."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"entries": [], "has_more": False}
mock_post.return_value = mock_response
response = client.post(
"/api/dropbox/list-folders",
data={"access_token": "test-token", "path": "/EmptyFolder"},
)
assert response.status_code == 200
assert len(response.json()["folders"]) == 0
@patch("app.api.dropbox.requests.post")
def test_list_folders_unauthorized(self, mock_post, client):
"""Test listing folders with invalid token returns 401."""
mock_response = Mock()
mock_response.status_code = 401
mock_response.text = "Invalid access token"
mock_post.return_value = mock_response
response = client.post(
"/api/dropbox/list-folders",
data={"access_token": "bad-token", "path": ""},
)
assert response.status_code == 401
@patch("app.api.dropbox.requests.post")
def test_list_folders_api_error(self, mock_post, client):
"""Test listing folders when Dropbox API returns an error."""
mock_response = Mock()
mock_response.status_code = 500
mock_response.text = "Internal server error"
mock_post.return_value = mock_response
response = client.post(
"/api/dropbox/list-folders",
data={"access_token": "test-token", "path": ""},
)
assert response.status_code == 502
@patch("app.api.dropbox.requests.post")
def test_list_folders_root_path_normalization(self, mock_post, client):
"""Test that '/' is normalized to empty string for Dropbox API."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"entries": [], "has_more": False}
mock_post.return_value = mock_response
response = client.post(
"/api/dropbox/list-folders",
data={"access_token": "test-token", "path": "/"},
)
assert response.status_code == 200
# Check the actual API call used empty string for root
call_args = mock_post.call_args
assert call_args[1]["json"]["path"] == ""
@patch("app.api.dropbox.requests.post")
def test_list_folders_sorted_alphabetically(self, mock_post, client):
"""Test that folders are returned in alphabetical order."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"entries": [
{".tag": "folder", "name": "Zebra", "path_display": "/Zebra", "id": "id:1"},
{".tag": "folder", "name": "Alpha", "path_display": "/Alpha", "id": "id:2"},
{".tag": "folder", "name": "middle", "path_display": "/middle", "id": "id:3"},
],
"has_more": False,
}
mock_post.return_value = mock_response
response = client.post(
"/api/dropbox/list-folders",
data={"access_token": "test-token", "path": ""},
)
assert response.status_code == 200
names = [f["name"] for f in response.json()["folders"]]
assert names == ["Alpha", "middle", "Zebra"]
class TestBuildDropboxRedirectUri:
"""Tests for the _build_dropbox_redirect_uri helper."""
def test_uses_public_base_url_when_set(self):
"""When PUBLIC_BASE_URL is configured, redirect URI should use it."""
from unittest.mock import MagicMock
with patch("app.api.dropbox.settings") as mock_settings:
mock_settings.public_base_url = "https://myapp.example.com"
from app.api.dropbox import _build_dropbox_redirect_uri
mock_request = MagicMock()
result = _build_dropbox_redirect_uri(mock_request)
assert result == "https://myapp.example.com/dropbox-callback"
def test_uses_public_base_url_strips_trailing_slash(self):
"""PUBLIC_BASE_URL with trailing slash should be handled correctly."""
from unittest.mock import MagicMock
with patch("app.api.dropbox.settings") as mock_settings:
mock_settings.public_base_url = "https://myapp.example.com/"
from app.api.dropbox import _build_dropbox_redirect_uri
mock_request = MagicMock()
result = _build_dropbox_redirect_uri(mock_request)
assert result == "https://myapp.example.com/dropbox-callback"
def test_falls_back_to_request_when_public_base_url_not_set(self):
"""When PUBLIC_BASE_URL is not set, use request scheme and netloc."""
from unittest.mock import MagicMock
with patch("app.api.dropbox.settings") as mock_settings:
mock_settings.public_base_url = None
from app.api.dropbox import _build_dropbox_redirect_uri
mock_request = MagicMock()
mock_request.url.scheme = "https"
mock_request.url.netloc = "other.example.com"
result = _build_dropbox_redirect_uri(mock_request)
assert result == "https://other.example.com/dropbox-callback"
@pytest.mark.unit
class TestGlobalAuthorizeUrl:
"""Tests for GET /api/dropbox/global-authorize-url endpoint."""
@patch("app.api.dropbox.settings")
def test_returns_authorize_url(self, mock_settings, client):
"""Test that a valid authorize URL is returned when global creds are configured."""
mock_settings.dropbox_allow_global_credentials_for_integrations = True
mock_settings.dropbox_app_key = "test-app-key"
mock_settings.dropbox_app_secret = "test-app-secret"
mock_settings.public_base_url = "https://example.com"
response = client.get("/api/dropbox/global-authorize-url")
assert response.status_code == 200
data = response.json()
assert "authorize_url" in data
assert "https://www.dropbox.com/oauth2/authorize" in data["authorize_url"]
assert "client_id=test-app-key" in data["authorize_url"]
# redirect_uri should be URL-encoded
assert "redirect_uri=" in data["authorize_url"]
assert "https%3A%2F%2Fexample.com%2Fdropbox-callback" in data["authorize_url"]
@patch("app.api.dropbox.settings")
def test_returns_403_when_global_creds_disabled(self, mock_settings, client):
"""Test 403 when global credentials for integrations are disabled."""
mock_settings.dropbox_allow_global_credentials_for_integrations = False
mock_settings.dropbox_app_key = "test-app-key"
mock_settings.dropbox_app_secret = "test-app-secret"
response = client.get("/api/dropbox/global-authorize-url")
assert response.status_code == 403
@patch("app.api.dropbox.settings")
def test_returns_503_when_creds_not_configured(self, mock_settings, client):
"""Test 503 when global Dropbox credentials are not configured."""
mock_settings.dropbox_allow_global_credentials_for_integrations = True
mock_settings.dropbox_app_key = None
mock_settings.dropbox_app_secret = None
response = client.get("/api/dropbox/global-authorize-url")
assert response.status_code == 503
@patch("app.api.dropbox.settings")
def test_redirect_uri_uses_public_base_url(self, mock_settings, client):
"""Redirect URI in authorize URL must use PUBLIC_BASE_URL when configured."""
mock_settings.dropbox_allow_global_credentials_for_integrations = True
mock_settings.dropbox_app_key = "my-key"
mock_settings.dropbox_app_secret = "my-secret"
mock_settings.public_base_url = "https://prod.example.com"
response = client.get("/api/dropbox/global-authorize-url")
assert response.status_code == 200
authorize_url = response.json()["authorize_url"]
# The redirect_uri must be URL-encoded and contain the public base URL
assert "https%3A%2F%2Fprod.example.com%2Fdropbox-callback" in authorize_url
+79 -2
View File
@@ -887,9 +887,9 @@ class TestConnectionTestEndpoint:
def test_test_unsupported_type(self, int_client):
"""Unsupported integration types return a helpful non-error message."""
payload = {
"integration_type": "DROPBOX",
"integration_type": "FTP",
"config": {},
"credentials": {"token": "abc"},
"credentials": {"username": "user", "password": "pass"},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
@@ -897,6 +897,83 @@ class TestConnectionTestEndpoint:
assert data["success"] is False
assert "not yet supported" in data["message"]
def test_test_dropbox_missing_refresh_token(self, int_client):
"""Dropbox test with missing refresh_token returns failure."""
payload = {
"integration_type": "DROPBOX",
"config": {},
"credentials": {"app_key": "key", "app_secret": "secret"},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "refresh_token" in data["message"].lower()
def test_test_dropbox_missing_app_key(self, int_client):
"""Dropbox test with missing app_key/app_secret returns failure."""
payload = {
"integration_type": "DROPBOX",
"config": {},
"credentials": {"refresh_token": "rtoken"},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "app_key" in data["message"].lower()
def test_test_dropbox_invalid_credentials(self, int_client):
"""Dropbox test with bad credentials returns an auth failure."""
from unittest.mock import MagicMock, patch
import dropbox.exceptions as dbx_exc
with patch("app.api.integrations.dbx_lib") as mock_dbx:
mock_instance = MagicMock()
mock_dbx.Dropbox.return_value = mock_instance
mock_instance.users_get_current_account.side_effect = dbx_exc.AuthError("req_id", MagicMock())
payload = {
"integration_type": "DROPBOX",
"config": {},
"credentials": {
"app_key": "bad_key",
"app_secret": "bad_secret",
"refresh_token": "bad_token",
},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "authentication failed" in data["message"].lower()
def test_test_dropbox_success(self, int_client):
"""Dropbox test with valid (mocked) credentials returns success."""
from unittest.mock import MagicMock, patch
with patch("app.api.integrations.dbx_lib") as mock_dbx:
mock_instance = MagicMock()
mock_dbx.Dropbox.return_value = mock_instance
mock_account = MagicMock()
mock_account.name.display_name = "Test User"
mock_instance.users_get_current_account.return_value = mock_account
payload = {
"integration_type": "DROPBOX",
"config": {},
"credentials": {
"app_key": "valid_key",
"app_secret": "valid_secret",
"refresh_token": "valid_token",
},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert "dropbox connection successful" in data["message"].lower()
def test_test_invalid_type_returns_400(self, int_client):
"""Invalid integration_type returns 400."""
payload = {
+66 -2
View File
@@ -329,7 +329,7 @@ class TestDeactivateDevice:
"""Tests for DELETE /api/mobile/devices/{device_id}."""
def test_deactivate_own_device(self, mob_engine, mob_session):
"""Deactivating a device sets is_active to False."""
"""Deactivating an active device sets is_active to False (soft-delete, returns 200)."""
from app.main import app
device = MobileDevice(
@@ -346,7 +346,8 @@ class TestDeactivateDevice:
client = _make_client(mob_engine)
try:
resp = client.delete(f"/api/mobile/devices/{device_id}")
assert resp.status_code == 204
assert resp.status_code == 200
assert resp.json()["detail"] == "Device deactivated"
mob_session.expire_all()
updated = mob_session.get(MobileDevice, device_id)
@@ -355,6 +356,33 @@ class TestDeactivateDevice:
finally:
_cleanup(app)
def test_delete_inactive_device(self, mob_engine, mob_session):
"""Deleting an already-inactive device permanently removes it (hard-delete, returns 200)."""
from app.main import app
device = MobileDevice(
owner_id=_OWNER,
push_token=_EXPO_TOKEN,
platform="ios",
is_active=False,
)
mob_session.add(device)
mob_session.commit()
mob_session.refresh(device)
device_id = device.id
client = _make_client(mob_engine)
try:
resp = client.delete(f"/api/mobile/devices/{device_id}")
assert resp.status_code == 200
assert resp.json()["detail"] == "Device deleted"
mob_session.expire_all()
deleted = mob_session.get(MobileDevice, device_id)
assert deleted is None
finally:
_cleanup(app)
def test_deactivate_other_users_device_returns_404(self, mob_engine, mob_session):
"""Attempting to deactivate another user's device returns 404."""
from app.main import app
@@ -439,6 +467,42 @@ class TestWhoAmI:
assert data["email"] == _OWNER
assert data["avatar_url"] is not None # Gravatar URL
assert data["is_admin"] is False
assert data["preferred_language"] is None # not set yet
finally:
_cleanup(app)
def test_whoami_returns_preferred_language(self, mob_engine, mob_session):
"""preferred_language from UserProfile is included in the whoami response."""
from app.main import app
from app.models import UserProfile
profile = UserProfile(
user_id=_OWNER,
display_name="Bob Test",
preferred_language="de",
)
mob_session.add(profile)
mob_session.commit()
client = _make_client(mob_engine)
try:
resp = client.get("/api/mobile/whoami")
assert resp.status_code == 200
data = resp.json()
assert data["preferred_language"] == "de"
finally:
_cleanup(app)
def test_whoami_no_profile_preferred_language_is_null(self, mob_engine):
"""preferred_language is null when no UserProfile exists."""
from app.main import app
client = _make_client(mob_engine)
try:
resp = client.get("/api/mobile/whoami")
assert resp.status_code == 200
data = resp.json()
assert data["preferred_language"] is None
finally:
_cleanup(app)
+179
View File
@@ -695,3 +695,182 @@ class TestOneDriveIntegration:
# Verify env format is present (exact values may vary)
assert "env_format" in config_data
@pytest.mark.unit
class TestListOneDriveFolders:
"""Tests for list_onedrive_folders endpoint."""
@patch("app.api.onedrive.requests.get")
def test_list_folders_success(self, mock_get, client):
"""Test successful folder listing at root."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"value": [
{
"name": "Documents",
"id": "id:1",
"folder": {"childCount": 3},
"parentReference": {"path": "/drive/root:"},
},
{
"name": "Pictures",
"id": "id:2",
"folder": {"childCount": 10},
"parentReference": {"path": "/drive/root:"},
},
],
}
mock_get.return_value = mock_response
response = client.post(
"/api/onedrive/list-folders",
data={"access_token": "test-token", "path": ""},
)
assert response.status_code == 200
data = response.json()
assert len(data["folders"]) == 2
assert data["folders"][0]["name"] == "Documents"
assert data["folders"][0]["path"] == "/Documents"
assert data["folders"][1]["name"] == "Pictures"
assert data["path"] == "/"
@patch("app.api.onedrive.requests.get")
def test_list_folders_subfolder(self, mock_get, client):
"""Test listing folders in a subfolder."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"value": [
{
"name": "Invoices",
"id": "id:3",
"folder": {"childCount": 0},
"parentReference": {"path": "/drive/root:/Documents"},
},
],
}
mock_get.return_value = mock_response
response = client.post(
"/api/onedrive/list-folders",
data={"access_token": "test-token", "path": "Documents"},
)
assert response.status_code == 200
data = response.json()
assert len(data["folders"]) == 1
assert data["folders"][0]["path"] == "/Documents/Invoices"
assert data["path"] == "/Documents"
@patch("app.api.onedrive.requests.get")
def test_list_folders_empty(self, mock_get, client):
"""Test listing folders in an empty directory."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"value": []}
mock_get.return_value = mock_response
response = client.post(
"/api/onedrive/list-folders",
data={"access_token": "test-token", "path": "EmptyFolder"},
)
assert response.status_code == 200
assert len(response.json()["folders"]) == 0
@patch("app.api.onedrive.requests.get")
def test_list_folders_unauthorized(self, mock_get, client):
"""Test listing folders with invalid token returns 401."""
mock_response = Mock()
mock_response.status_code = 401
mock_response.text = "Invalid access token"
mock_get.return_value = mock_response
response = client.post(
"/api/onedrive/list-folders",
data={"access_token": "bad-token", "path": ""},
)
assert response.status_code == 401
@patch("app.api.onedrive.requests.get")
def test_list_folders_api_error(self, mock_get, client):
"""Test listing folders when Graph API returns an error."""
mock_response = Mock()
mock_response.status_code = 500
mock_response.text = "Internal server error"
mock_get.return_value = mock_response
response = client.post(
"/api/onedrive/list-folders",
data={"access_token": "test-token", "path": ""},
)
assert response.status_code == 502
@patch("app.api.onedrive.requests.get")
def test_list_folders_sorted_alphabetically(self, mock_get, client):
"""Test that folders are returned in alphabetical order."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"value": [
{
"name": "Zebra",
"id": "id:1",
"folder": {"childCount": 0},
"parentReference": {"path": "/drive/root:"},
},
{
"name": "Alpha",
"id": "id:2",
"folder": {"childCount": 0},
"parentReference": {"path": "/drive/root:"},
},
{
"name": "middle",
"id": "id:3",
"folder": {"childCount": 0},
"parentReference": {"path": "/drive/root:"},
},
],
}
mock_get.return_value = mock_response
response = client.post(
"/api/onedrive/list-folders",
data={"access_token": "test-token", "path": ""},
)
assert response.status_code == 200
names = [f["name"] for f in response.json()["folders"]]
assert names == ["Alpha", "middle", "Zebra"]
@patch("app.api.onedrive.requests.get")
def test_list_folders_root_drive_parent(self, mock_get, client):
"""Test folder path construction when parentReference.path is /drive/root."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"value": [
{
"name": "TopLevel",
"id": "id:1",
"folder": {"childCount": 0},
"parentReference": {"path": "/drive/root"},
},
],
}
mock_get.return_value = mock_response
response = client.post(
"/api/onedrive/list-folders",
data={"access_token": "test-token", "path": ""},
)
assert response.status_code == 200
data = response.json()
assert data["folders"][0]["path"] == "/TopLevel"
+223 -4
View File
@@ -314,8 +314,8 @@ class TestTokenRevoke:
_cleanup(app)
@pytest.mark.unit
def test_revoke_already_revoked_token(self, tok_engine):
"""Revoking an already-revoked token should return 400."""
def test_delete_already_revoked_token(self, tok_engine):
"""Deleting an already-revoked token should permanently remove it (hard-delete, 200)."""
from app.main import app
client = _make_client(tok_engine)
@@ -324,9 +324,15 @@ class TestTokenRevoke:
token_id = create_resp.json()["id"]
client.delete(f"/api/api-tokens/{token_id}")
# Second DELETE should hard-delete the revoked token.
resp = client.delete(f"/api/api-tokens/{token_id}")
assert resp.status_code == 400
assert resp.json()["detail"] == "Token is already revoked"
assert resp.status_code == 200
assert resp.json()["detail"] == "Token deleted"
# Token must no longer appear in the list.
list_resp = client.get("/api/api-tokens/")
ids = [t["id"] for t in list_resp.json()]
assert token_id not in ids
finally:
_cleanup(app)
@@ -677,3 +683,216 @@ class TestTokenUtils:
token = "de_test_token_value"
expected_hash = "9b89d9adf2f390c75bf2fd0ff2bb5622ef5a9dce438354cce6e39f2f5401129e"
assert hash_token(token) == expected_hash
# ---------------------------------------------------------------------------
# Tests Token reactivation
# ---------------------------------------------------------------------------
class TestTokenReactivate:
"""Tests for POST /api/api-tokens/{id}/reactivate."""
@pytest.mark.unit
def test_reactivate_revoked_token(self, tok_engine):
"""Reactivating a revoked token should set is_active=True and clear revoked_at."""
from app.main import app
client = _make_client(tok_engine)
try:
create_resp = client.post("/api/api-tokens/", json={"name": "Reactivate Me"})
token_id = create_resp.json()["id"]
client.delete(f"/api/api-tokens/{token_id}")
resp = client.post(f"/api/api-tokens/{token_id}/reactivate")
assert resp.status_code == 200
data = resp.json()
assert data["is_active"] is True
assert data["revoked_at"] is None
finally:
_cleanup(app)
@pytest.mark.unit
def test_reactivate_active_token_returns_400(self, tok_engine):
"""Reactivating an already-active token should return 400."""
from app.main import app
client = _make_client(tok_engine)
try:
create_resp = client.post("/api/api-tokens/", json={"name": "Already Active"})
token_id = create_resp.json()["id"]
resp = client.post(f"/api/api-tokens/{token_id}/reactivate")
assert resp.status_code == 400
assert resp.json()["detail"] == "Token is already active"
finally:
_cleanup(app)
@pytest.mark.unit
def test_reactivate_nonexistent_token(self, tok_engine):
"""Reactivating a non-existent token should return 404."""
from app.main import app
client = _make_client(tok_engine)
try:
resp = client.post("/api/api-tokens/99999/reactivate")
assert resp.status_code == 404
finally:
_cleanup(app)
@pytest.mark.unit
def test_reactivate_other_users_token(self, tok_engine):
"""A user cannot reactivate another user's token."""
from app.main import app
client_a = _make_client(tok_engine, _OWNER)
try:
create_resp = client_a.post("/api/api-tokens/", json={"name": "A Token"})
token_id = create_resp.json()["id"]
client_a.delete(f"/api/api-tokens/{token_id}")
finally:
_cleanup(app)
client_b = _make_client(tok_engine, _OTHER_OWNER)
try:
resp = client_b.post(f"/api/api-tokens/{token_id}/reactivate")
assert resp.status_code == 404
finally:
_cleanup(app)
# ---------------------------------------------------------------------------
# Tests Token lifetime (expires_at)
# ---------------------------------------------------------------------------
class TestTokenExpiry:
"""Tests for token creation with optional lifetime and expiry enforcement."""
@pytest.mark.unit
def test_create_token_without_expiry(self, tok_engine):
"""Creating a token without expires_in_days should leave expires_at as None."""
from app.main import app
client = _make_client(tok_engine)
try:
resp = client.post("/api/api-tokens/", json={"name": "No Expiry"})
assert resp.status_code == 201
data = resp.json()
assert data["expires_at"] is None
finally:
_cleanup(app)
@pytest.mark.unit
def test_create_token_with_expiry(self, tok_engine, tok_session):
"""Creating a token with expires_in_days should set expires_at in the future."""
from datetime import datetime, timezone
from app.main import app
client = _make_client(tok_engine)
try:
resp = client.post("/api/api-tokens/", json={"name": "With Expiry", "expires_in_days": 30})
assert resp.status_code == 201
data = resp.json()
assert data["expires_at"] is not None
# Parse the returned datetime; handle both tz-aware and tz-naive serialisations
expires_str = data["expires_at"].replace("Z", "+00:00")
expires_at = datetime.fromisoformat(expires_str)
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
delta_days = (expires_at - now).days
assert 28 <= delta_days <= 30
finally:
_cleanup(app)
@pytest.mark.unit
def test_expired_token_not_resolved(self, tok_engine, tok_session):
"""A token past its expires_at should not authenticate."""
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
from app.api.api_tokens import generate_api_token, hash_token
from app.auth import _resolve_bearer_user
plaintext = generate_api_token()
token_hash = hash_token(plaintext)
db_token = ApiToken(
owner_id=_OWNER,
name="Expired Token",
token_hash=token_hash,
token_prefix=plaintext[:12],
is_active=True,
expires_at=datetime.now(timezone.utc) - timedelta(days=1), # expired yesterday
)
tok_session.add(db_token)
tok_session.commit()
mock_request = MagicMock()
mock_request.headers = {"authorization": f"Bearer {plaintext}"}
mock_request.client.host = "127.0.0.1"
user = _resolve_bearer_user(mock_request, tok_session)
assert user is None
@pytest.mark.unit
def test_non_expired_token_resolves(self, tok_engine, tok_session):
"""A token before its expires_at should authenticate normally."""
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
from app.api.api_tokens import generate_api_token, hash_token
from app.auth import _resolve_bearer_user
plaintext = generate_api_token()
token_hash = hash_token(plaintext)
db_token = ApiToken(
owner_id=_OWNER,
name="Valid Token",
token_hash=token_hash,
token_prefix=plaintext[:12],
is_active=True,
expires_at=datetime.now(timezone.utc) + timedelta(days=30), # expires in 30 days
)
tok_session.add(db_token)
tok_session.commit()
mock_request = MagicMock()
mock_request.headers = {"authorization": f"Bearer {plaintext}"}
mock_request.client.host = "127.0.0.1"
user = _resolve_bearer_user(mock_request, tok_session)
assert user is not None
assert user["preferred_username"] == _OWNER
@pytest.mark.unit
def test_create_token_expires_in_days_zero_rejected(self, tok_engine):
"""expires_in_days=0 should be rejected with 422 (ge=1)."""
from app.main import app
client = _make_client(tok_engine)
try:
resp = client.post("/api/api-tokens/", json={"name": "Bad Expiry", "expires_in_days": 0})
assert resp.status_code == 422
finally:
_cleanup(app)
@pytest.mark.unit
def test_expires_at_included_in_list_response(self, tok_engine):
"""List endpoint should include expires_at field."""
from app.main import app
client = _make_client(tok_engine)
try:
client.post("/api/api-tokens/", json={"name": "Listed", "expires_in_days": 7})
resp = client.get("/api/api-tokens/")
assert resp.status_code == 200
tokens = resp.json()
assert len(tokens) == 1
assert "expires_at" in tokens[0]
assert tokens[0]["expires_at"] is not None
finally:
_cleanup(app)
+199
View File
@@ -0,0 +1,199 @@
"""Tests for scripts/check_alembic_migrations.py."""
# The script lives outside of the ``app`` package, so we import it by path.
import importlib.util
import textwrap
from pathlib import Path
import pytest
_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "check_alembic_migrations.py"
_spec = importlib.util.spec_from_file_location("check_alembic_migrations", _SCRIPT)
assert _spec and _spec.loader
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod) # type: ignore[union-attr]
check_migrations = _mod.check_migrations
main = _mod.main
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _write_migration(
directory: Path, filename: str, revision: str, down_revision: str | tuple[str, ...] | None
) -> Path:
"""Helper to create a minimal migration file."""
if down_revision is None:
down_rev_str = "None"
elif isinstance(down_revision, tuple):
down_rev_str = repr(down_revision)
else:
down_rev_str = f'"{down_revision}"'
content = textwrap.dedent(f'''\
"""Test migration."""
from typing import Union
revision: str = "{revision}"
down_revision: Union[str, None] = {down_rev_str}
depends_on: Union[str, None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
''')
path = directory / filename
path.write_text(content)
return path
@pytest.fixture
def versions_dir(tmp_path: Path) -> Path:
"""Return a temporary versions directory."""
d = tmp_path / "versions"
d.mkdir()
return d
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestCheckMigrations:
"""Tests for the check_migrations function."""
def test_valid_linear_chain(self, versions_dir: Path) -> None:
"""A simple linear chain should pass with no errors."""
_write_migration(versions_dir, "001_initial.py", "001_initial", None)
_write_migration(versions_dir, "002_add_col.py", "002_add_col", "001_initial")
_write_migration(versions_dir, "003_add_table.py", "003_add_table", "002_add_col")
errors = check_migrations(versions_dir)
assert errors == []
def test_valid_merge_migration(self, versions_dir: Path) -> None:
"""A chain with a merge point should pass."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
_write_migration(versions_dir, "002_a.py", "002_a", "001_base")
_write_migration(versions_dir, "002_b.py", "002_b", "001_base")
# Merge file with tuple down_revision
content = textwrap.dedent('''\
"""Merge."""
from typing import Union
revision: str = "003_merge"
down_revision: Union[str, tuple] = ("002_a", "002_b")
depends_on: Union[str, None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
''')
(versions_dir / "003_merge.py").write_text(content)
errors = check_migrations(versions_dir)
assert errors == []
def test_multiple_heads_detected(self, versions_dir: Path) -> None:
"""Two unmerged branches should report multiple heads."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
_write_migration(versions_dir, "002_a.py", "002_a", "001_base")
_write_migration(versions_dir, "002_b.py", "002_b", "001_base")
errors = check_migrations(versions_dir)
assert len(errors) == 1
assert "Multiple migration heads" in errors[0]
assert "002_a" in errors[0]
assert "002_b" in errors[0]
def test_broken_down_revision(self, versions_dir: Path) -> None:
"""A migration pointing to a non-existent parent should be flagged."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
_write_migration(versions_dir, "002_orphan.py", "002_orphan", "NONEXISTENT")
errors = check_migrations(versions_dir)
assert any("Broken chain" in e for e in errors)
assert any("NONEXISTENT" in e for e in errors)
def test_duplicate_revision(self, versions_dir: Path) -> None:
"""Two files declaring the same revision should be flagged."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
_write_migration(versions_dir, "002_first.py", "002_dup", "001_base")
_write_migration(versions_dir, "002_second.py", "002_dup", "001_base")
errors = check_migrations(versions_dir)
assert any("Duplicate revision" in e for e in errors)
def test_filename_mismatch(self, versions_dir: Path) -> None:
"""A file whose revision doesn't match its filename should be flagged."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
# filename stem is "002_wrong_name" but revision says "002_correct_name"
_write_migration(versions_dir, "002_wrong_name.py", "002_correct_name", "001_base")
errors = check_migrations(versions_dir)
assert any("Filename mismatch" in e for e in errors)
def test_empty_directory(self, versions_dir: Path) -> None:
"""An empty versions directory should report an error."""
errors = check_migrations(versions_dir)
assert len(errors) == 1
assert "No migration files found" in errors[0]
def test_init_py_is_skipped(self, versions_dir: Path) -> None:
"""__init__.py files should be ignored."""
(versions_dir / "__init__.py").write_text("")
_write_migration(versions_dir, "001_base.py", "001_base", None)
errors = check_migrations(versions_dir)
assert errors == []
def test_non_migration_file_skipped(self, versions_dir: Path) -> None:
"""A .py file without a revision variable should be silently skipped."""
(versions_dir / "helper.py").write_text("# just a helper\nx = 1\n")
_write_migration(versions_dir, "001_base.py", "001_base", None)
errors = check_migrations(versions_dir)
assert errors == []
@pytest.mark.unit
class TestMainCLI:
"""Tests for the CLI entry-point."""
def test_success_returns_zero(self, versions_dir: Path) -> None:
"""Valid chain should exit 0."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
rc = main(["--versions-dir", str(versions_dir)])
assert rc == 0
def test_failure_returns_one(self, versions_dir: Path) -> None:
"""Invalid chain should exit 1."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
_write_migration(versions_dir, "002_a.py", "002_a", "001_base")
_write_migration(versions_dir, "002_b.py", "002_b", "001_base")
rc = main(["--versions-dir", str(versions_dir)])
assert rc == 1
def test_missing_directory_returns_two(self, tmp_path: Path) -> None:
"""Non-existent versions directory should exit 2."""
rc = main(["--versions-dir", str(tmp_path / "does_not_exist")])
assert rc == 2
def test_verbose_flag(self, versions_dir: Path) -> None:
"""The --verbose flag should not crash."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
rc = main(["--versions-dir", str(versions_dir), "--verbose"])
assert rc == 0
def test_real_migrations(self) -> None:
"""Smoke test against the actual project migrations."""
real_dir = Path(__file__).resolve().parent.parent / "migrations" / "versions"
if not real_dir.is_dir():
pytest.skip("migrations/versions directory not found in working tree")
rc = main(["--versions-dir", str(real_dir)])
assert rc == 0
+422
View File
@@ -0,0 +1,422 @@
"""Tests for the rule-based document classification engine.
Covers the classification engine logic in ``app/utils/classification_rules.py``:
built-in rules, custom rules, confidence scoring, and edge cases.
"""
import pytest
from app.utils.classification_rules import (
BUILTIN_CATEGORIES,
BUILTIN_RULES,
RULE_TYPE_CONTENT,
RULE_TYPE_FILENAME,
RULE_TYPE_METADATA,
ClassificationResult,
ClassificationRule,
MatchedRule,
classify_document,
db_rule_to_engine_rule,
)
# ---------------------------------------------------------------------------
# Built-in categories & rules smoke tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestBuiltinCategories:
"""Verify the pre-built categories and rules are sane."""
def test_builtin_categories_not_empty(self):
"""There must be at least one built-in category."""
assert len(BUILTIN_CATEGORIES) > 0
def test_unknown_category_exists(self):
"""The 'unknown' fallback category must be present."""
assert "unknown" in BUILTIN_CATEGORIES
def test_core_categories_present(self):
"""Invoice, contract, and receipt categories must exist."""
for cat in ("invoice", "contract", "receipt"):
assert cat in BUILTIN_CATEGORIES, f"Missing built-in category: {cat}"
def test_builtin_rules_not_empty(self):
"""There must be at least one built-in rule."""
assert len(BUILTIN_RULES) > 0
def test_all_builtin_rules_reference_valid_types(self):
"""Every built-in rule must use a valid rule_type."""
valid_types = {RULE_TYPE_FILENAME, RULE_TYPE_CONTENT, RULE_TYPE_METADATA}
for rule in BUILTIN_RULES:
assert rule.rule_type in valid_types, f"Rule {rule.name!r} has invalid type {rule.rule_type!r}"
# ---------------------------------------------------------------------------
# ClassificationRule dataclass validation
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestClassificationRuleValidation:
"""Test ClassificationRule dataclass validation."""
def test_valid_rule_types(self):
"""Valid rule types should not raise."""
for rt in (RULE_TYPE_FILENAME, RULE_TYPE_CONTENT, RULE_TYPE_METADATA):
rule = ClassificationRule(name="test", category="test", rule_type=rt, pattern="test")
assert rule.rule_type == rt
def test_invalid_rule_type_raises(self):
"""An invalid rule_type should raise ValueError."""
with pytest.raises(ValueError, match="Invalid rule_type"):
ClassificationRule(name="test", category="test", rule_type="invalid", pattern="test")
# ---------------------------------------------------------------------------
# Filename pattern matching
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestFilenamePatternMatching:
"""Test classification via filename patterns."""
def test_invoice_filename(self):
"""A filename containing 'invoice' should classify as invoice."""
result = classify_document(filename="2024-03-01_Invoice_Acme.pdf")
assert result.category == "invoice"
assert result.confidence > 0
def test_german_invoice_filename(self):
"""A filename containing 'Rechnung' should classify as invoice."""
result = classify_document(filename="Rechnung_2024.pdf")
assert result.category == "invoice"
assert result.confidence > 0
def test_contract_filename(self):
"""A filename containing 'contract' should classify as contract."""
result = classify_document(filename="Service_Contract_2024.pdf")
assert result.category == "contract"
def test_receipt_filename(self):
"""A filename containing 'receipt' should classify as receipt."""
result = classify_document(filename="Payment_Receipt.pdf")
assert result.category == "receipt"
def test_unrecognized_filename(self):
"""A generic filename with no keywords should return 'unknown'."""
result = classify_document(filename="document_12345.pdf")
assert result.category == "unknown"
assert result.confidence == 0
def test_empty_filename(self):
"""An empty filename should not match any rule."""
result = classify_document(filename="")
assert result.category == "unknown"
# ---------------------------------------------------------------------------
# Content keyword matching
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestContentKeywordMatching:
"""Test classification via content keywords."""
def test_invoice_content(self):
"""Text containing 'invoice number' should classify as invoice."""
result = classify_document(text="Please pay the invoice number 12345. Amount due: $500")
assert result.category == "invoice"
assert result.confidence > 0
def test_contract_content(self):
"""Text containing 'terms and conditions' should classify as contract."""
result = classify_document(text="The parties hereby agree to the following terms and conditions.")
assert result.category == "contract"
def test_receipt_content(self):
"""Text containing 'payment received' should classify as receipt."""
result = classify_document(text="Thank you. Payment received for order #789.")
assert result.category == "receipt"
def test_bank_statement_content(self):
"""Text containing 'account statement' should classify as bank_statement."""
result = classify_document(text="Monthly account statement. Opening balance: $1,000.")
assert result.category == "bank_statement"
def test_empty_text(self):
"""Empty text should not match any content rule."""
result = classify_document(text="")
assert result.category == "unknown"
def test_case_insensitive_matching(self):
"""Content matching should be case-insensitive by default."""
result = classify_document(text="INVOICE NUMBER 12345")
assert result.category == "invoice"
# ---------------------------------------------------------------------------
# Metadata matching
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestMetadataMatching:
"""Test classification via metadata field matching."""
def test_document_type_invoice(self):
"""metadata document_type=Invoice should classify as invoice."""
result = classify_document(metadata={"document_type": "Invoice"})
assert result.category == "invoice"
assert result.confidence >= 90
def test_document_type_contract(self):
"""metadata document_type=Contract should classify as contract."""
result = classify_document(metadata={"document_type": "Contract"})
assert result.category == "contract"
def test_kommunikationsart_rechnung(self):
"""German classification metadata should classify as invoice."""
result = classify_document(metadata={"kommunikationsart": "Rechnung"})
assert result.category == "invoice"
def test_no_metadata(self):
"""None metadata should not match."""
result = classify_document(metadata=None)
assert result.category == "unknown"
def test_empty_metadata(self):
"""Empty metadata dict should not match."""
result = classify_document(metadata={})
assert result.category == "unknown"
def test_metadata_case_insensitive(self):
"""Metadata matching should be case-insensitive by default."""
result = classify_document(metadata={"document_type": "invoice"})
assert result.category == "invoice"
# ---------------------------------------------------------------------------
# Combined matching / confidence boosting
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestCombinedMatching:
"""Test that multiple matching rules boost confidence."""
def test_filename_and_content_boost(self):
"""Filename + content matching should produce higher confidence than either alone."""
filename_only = classify_document(filename="Invoice_2024.pdf")
combined = classify_document(filename="Invoice_2024.pdf", text="Invoice number: 12345. Amount due: $500.")
assert combined.confidence >= filename_only.confidence
assert len(combined.matched_rules) > len(filename_only.matched_rules)
def test_all_three_signals(self):
"""Filename + content + metadata should produce highest confidence."""
result = classify_document(
filename="Invoice_Acme.pdf",
text="Invoice number: 12345. Amount due: $500.",
metadata={"document_type": "Invoice"},
)
assert result.category == "invoice"
assert result.confidence >= 90
def test_conflicting_signals_most_matches_wins(self):
"""When filename says 'invoice' but content says 'contract', most matches wins."""
result = classify_document(
filename="Invoice.pdf",
text="The parties hereby agree to the following terms and conditions. "
"This agreement between Company A and Company B is effective immediately.",
)
# Content has more keyword matches for contract, but filename matches invoice.
# Either is acceptable as long as the result is deterministic.
assert result.category in ("invoice", "contract")
# ---------------------------------------------------------------------------
# Custom rules
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestCustomRules:
"""Test user-defined custom classification rules."""
def test_custom_rule_matches(self):
"""A custom filename rule should match when pattern hits."""
custom = [
ClassificationRule(
name="custom_hr_doc",
category="hr_document",
rule_type=RULE_TYPE_FILENAME,
pattern=r"(?i)employee|hiring|hr",
)
]
result = classify_document(filename="Employee_Handbook.pdf", custom_rules=custom)
assert result.category == "hr_document"
def test_custom_content_rule(self):
"""A custom content keyword rule should match."""
custom = [
ClassificationRule(
name="custom_medical",
category="medical",
rule_type=RULE_TYPE_CONTENT,
pattern="diagnosis|prescription|patient record",
)
]
result = classify_document(text="Patient record for Jane Doe. Diagnosis: common cold.", custom_rules=custom)
assert result.category == "medical"
def test_custom_metadata_rule(self):
"""A custom metadata rule should match."""
custom = [
ClassificationRule(
name="custom_legal",
category="legal",
rule_type=RULE_TYPE_METADATA,
pattern="department=legal",
)
]
result = classify_document(metadata={"department": "legal"}, custom_rules=custom)
assert result.category == "legal"
def test_custom_rule_overrides_builtin(self):
"""Custom rules with more matches should override built-in rules."""
custom = [
ClassificationRule(
name="custom_internal_invoice",
category="internal_invoice",
rule_type=RULE_TYPE_FILENAME,
pattern=r"(?i)invoice",
priority=100,
),
ClassificationRule(
name="custom_internal_invoice_content",
category="internal_invoice",
rule_type=RULE_TYPE_CONTENT,
pattern="invoice number",
priority=100,
),
]
result = classify_document(
filename="Invoice_2024.pdf",
text="Invoice number: 12345",
custom_rules=custom,
)
# Both builtin and custom rules for "invoice" patterns match, but custom
# has "internal_invoice" as category. The category with more total matches wins.
assert result.category in ("invoice", "internal_invoice")
assert result.confidence > 0
# ---------------------------------------------------------------------------
# db_rule_to_engine_rule converter
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDbRuleConversion:
"""Test the database model to engine rule converter."""
def test_converts_basic_fields(self):
"""All basic fields should be mapped correctly."""
class FakeDbRule:
name = "test_rule"
category = "invoice"
rule_type = RULE_TYPE_FILENAME
pattern = r"(?i)invoice"
priority = 10
case_sensitive = True
engine_rule = db_rule_to_engine_rule(FakeDbRule())
assert engine_rule.name == "test_rule"
assert engine_rule.category == "invoice"
assert engine_rule.rule_type == RULE_TYPE_FILENAME
assert engine_rule.pattern == r"(?i)invoice"
assert engine_rule.priority == 10
assert engine_rule.case_sensitive is True
def test_defaults_case_sensitive_to_false(self):
"""When case_sensitive is missing, default to False."""
class FakeDbRule:
name = "test"
category = "test"
rule_type = RULE_TYPE_CONTENT
pattern = "test"
priority = 0
engine_rule = db_rule_to_engine_rule(FakeDbRule())
assert engine_rule.case_sensitive is False
# ---------------------------------------------------------------------------
# ClassificationResult
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestClassificationResult:
"""Test the ClassificationResult dataclass."""
def test_default_matched_rules(self):
"""matched_rules should default to an empty list."""
result = ClassificationResult(category="test", confidence=50)
assert result.matched_rules == []
def test_with_matched_rules(self):
"""matched_rules should be populated when provided."""
match = MatchedRule(rule_name="test", rule_type=RULE_TYPE_FILENAME, category="invoice", confidence=60)
result = ClassificationResult(category="invoice", confidence=60, matched_rules=[match])
assert len(result.matched_rules) == 1
assert result.matched_rules[0].rule_name == "test"
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestEdgeCases:
"""Test edge cases in the classification engine."""
def test_no_inputs_at_all(self):
"""No filename, text, or metadata should return 'unknown'."""
result = classify_document()
assert result.category == "unknown"
assert result.confidence == 0
assert result.matched_rules == []
def test_metadata_pattern_without_equals(self):
"""A metadata pattern without '=' should not match."""
custom = [
ClassificationRule(
name="bad_pattern",
category="test",
rule_type=RULE_TYPE_METADATA,
pattern="no_equals_sign",
)
]
result = classify_document(metadata={"no_equals_sign": "value"}, custom_rules=custom)
assert result.category == "unknown"
def test_confidence_capped_at_100(self):
"""Confidence should never exceed 100."""
# Create many rules that all match to test the cap
custom = [
ClassificationRule(
name=f"flood_{i}",
category="flood",
rule_type=RULE_TYPE_CONTENT,
pattern="test keyword",
)
for i in range(20)
]
result = classify_document(text="test keyword is here", custom_rules=custom)
assert result.confidence <= 100
+232
View File
@@ -0,0 +1,232 @@
"""Tests for the classify_document Celery task.
Covers the ``classify_document_task`` in ``app/tasks/classify_document.py``.
"""
import json
from unittest.mock import MagicMock, patch
import pytest
from app.models import ClassificationRuleModel, FileRecord
from app.tasks.classify_document import _load_custom_rules
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_file_record(db_session, **overrides):
"""Insert a minimal FileRecord and return it."""
defaults = {
"owner_id": "test-user",
"filehash": "abc123",
"original_filename": "Invoice_2024.pdf",
"local_filename": "/tmp/test.pdf",
"file_size": 1024,
"mime_type": "application/pdf",
"ocr_text": "Invoice number: 12345. Amount due: $500.",
"ai_metadata": None,
}
defaults.update(overrides)
fr = FileRecord(**defaults)
db_session.add(fr)
db_session.commit()
db_session.refresh(fr)
return fr
def _make_rule(db_session, **overrides):
"""Insert a ClassificationRuleModel and return it."""
defaults = {
"owner_id": None,
"name": "test_rule",
"category": "test_category",
"rule_type": "filename_pattern",
"pattern": r"(?i)test",
"priority": 0,
"case_sensitive": False,
"enabled": True,
}
defaults.update(overrides)
rule = ClassificationRuleModel(**defaults)
db_session.add(rule)
db_session.commit()
db_session.refresh(rule)
return rule
# ---------------------------------------------------------------------------
# _load_custom_rules
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestLoadCustomRules:
"""Test the custom rule loading helper."""
@patch("app.tasks.classify_document.SessionLocal")
def test_loads_enabled_rules(self, mock_session_local):
"""Should load enabled rules from the database."""
mock_rule = MagicMock()
mock_rule.name = "rule1"
mock_rule.category = "invoice"
mock_rule.rule_type = "filename_pattern"
mock_rule.pattern = r"(?i)invoice"
mock_rule.priority = 10
mock_rule.case_sensitive = False
mock_db = MagicMock()
mock_query = MagicMock()
mock_db.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = [mock_rule]
mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
rules = _load_custom_rules(owner_id="test-user")
assert len(rules) == 1
assert rules[0].name == "rule1"
assert rules[0].category == "invoice"
# ---------------------------------------------------------------------------
# classify_document_task (integration-style with mocked DB and Celery)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestClassifyDocumentTask:
"""Test the Celery classify_document_task."""
@patch("app.tasks.classify_document.log_task_progress")
@patch("app.tasks.classify_document._load_custom_rules", return_value=[])
@patch("app.tasks.classify_document.SessionLocal")
def test_classify_invoice_file(self, mock_session_local, mock_load_rules, mock_log):
"""Should classify a file with invoice filename and text as 'invoice'."""
mock_file = MagicMock(spec=FileRecord)
mock_file.id = 1
mock_file.original_filename = "Invoice_2024.pdf"
mock_file.ocr_text = "Invoice number: 12345. Amount due: $500."
mock_file.ai_metadata = None
mock_file.owner_id = "test-user"
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = mock_file
mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
from app.tasks.classify_document import classify_document_task
# Call the underlying function directly via .run(), bypassing Celery
result = classify_document_task.run(1, owner_id="test-user")
assert result["status"] == "success"
assert result["category"] == "invoice"
assert result["confidence"] > 0
# Verify ai_metadata was updated
assert mock_file.ai_metadata is not None
metadata = json.loads(mock_file.ai_metadata)
assert "classification" in metadata
assert metadata["classification"]["category"] == "invoice"
@patch("app.tasks.classify_document.log_task_progress")
@patch("app.tasks.classify_document.SessionLocal")
def test_classify_file_not_found(self, mock_session_local, mock_log):
"""Should return error when file record is not found."""
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
from app.tasks.classify_document import classify_document_task
result = classify_document_task.run(99999)
assert result["status"] == "error"
@patch("app.tasks.classify_document.log_task_progress")
@patch("app.tasks.classify_document._load_custom_rules", return_value=[])
@patch("app.tasks.classify_document.SessionLocal")
def test_classify_preserves_existing_metadata(self, mock_session_local, mock_load_rules, mock_log):
"""Should preserve existing ai_metadata fields and add classification."""
existing_meta = json.dumps({"document_type": "Invoice", "tags": ["finance"]})
mock_file = MagicMock(spec=FileRecord)
mock_file.id = 2
mock_file.original_filename = "doc.pdf"
mock_file.ocr_text = ""
mock_file.ai_metadata = existing_meta
mock_file.owner_id = "test-user"
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = mock_file
mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
from app.tasks.classify_document import classify_document_task
classify_document_task.run(2)
# Check that existing fields are preserved
metadata = json.loads(mock_file.ai_metadata)
assert metadata["tags"] == ["finance"]
assert metadata["document_type"] == "Invoice"
assert "classification" in metadata
@patch("app.tasks.classify_document.log_task_progress")
@patch("app.tasks.classify_document._load_custom_rules", return_value=[])
@patch("app.tasks.classify_document.SessionLocal")
def test_classify_sets_document_type_when_missing(self, mock_session_local, mock_load_rules, mock_log):
"""Should set document_type from classification when not already present."""
mock_file = MagicMock(spec=FileRecord)
mock_file.id = 3
mock_file.original_filename = "Invoice_2024.pdf"
mock_file.ocr_text = "Invoice number: 12345"
mock_file.ai_metadata = json.dumps({"tags": ["test"]})
mock_file.owner_id = "test-user"
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = mock_file
mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
from app.tasks.classify_document import classify_document_task
classify_document_task.run(3)
metadata = json.loads(mock_file.ai_metadata)
assert metadata["document_type"] == "Invoice"
@patch("app.tasks.classify_document.log_task_progress")
@patch("app.tasks.classify_document._load_custom_rules", return_value=[])
@patch("app.tasks.classify_document.SessionLocal")
def test_classify_unknown_document(self, mock_session_local, mock_load_rules, mock_log):
"""Should classify as 'unknown' when no rules match."""
mock_file = MagicMock(spec=FileRecord)
mock_file.id = 4
mock_file.original_filename = "random_file.pdf"
mock_file.ocr_text = "Lorem ipsum dolor sit amet."
mock_file.ai_metadata = None
mock_file.owner_id = "test-user"
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = mock_file
mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
from app.tasks.classify_document import classify_document_task
result = classify_document_task.run(4)
assert result["category"] == "unknown"
assert result["confidence"] == 0
def test_classify_document_task_is_celery_task(self):
"""Task should be registered as a Celery task."""
from app.tasks.classify_document import classify_document_task
assert hasattr(classify_document_task, "apply_async")
assert hasattr(classify_document_task, "delay")
assert callable(classify_document_task)
@@ -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"),
):
+22
View File
@@ -337,6 +337,27 @@ class TestCSRFMiddlewareDispatch:
call_next.assert_called_once_with(request)
@pytest.mark.asyncio
async def test_qr_auth_claim_is_exempt(self):
"""QR auth claim path is exempt from CSRF validation.
The mobile app calls this endpoint without a browser session and
therefore without a CSRF token. The cryptographically-random,
single-use challenge token provides equivalent protection.
"""
middleware = self._make_middleware()
request = self._make_request(
method="POST",
path="/api/qr-auth/claim",
session={},
)
call_next = AsyncMock(return_value=MagicMock())
with patch.object(CSRFMiddleware, "_get_submitted_token", new=AsyncMock(return_value=None)):
result = await middleware.dispatch(request, call_next)
call_next.assert_called_once_with(request)
# ---------------------------------------------------------------------------
# Integration tests via TestClient
@@ -362,6 +383,7 @@ class TestCSRFIntegration:
assert "PATCH" in CSRF_PROTECTED_METHODS
assert "GET" not in CSRF_PROTECTED_METHODS
assert "/oauth-callback" in CSRF_EXEMPT_PATHS
assert "/api/qr-auth/claim" in CSRF_EXEMPT_PATHS
def test_csrf_middleware_noop_when_auth_disabled(self):
"""When AUTH_ENABLED=False the middleware dispatch is a no-op (no validation)."""
+46
View File
@@ -998,3 +998,49 @@ class TestAlembicUpgrade:
# Verify head is reachable
heads = script.get_heads()
assert len(heads) == 1 # Should be a single linear chain
@pytest.mark.unit
class TestEnginePoolConfiguration:
"""Tests for database engine pool configuration (pool class and options)."""
def test_sqlite_engine_uses_null_pool(self):
"""SQLite engines must use NullPool to prevent QueuePool exhaustion."""
from sqlalchemy.pool import NullPool
from app.database import engine
# The test environment uses SQLite, so NullPool should be in effect.
assert isinstance(engine.pool, NullPool)
def test_create_engine_sqlite_null_pool(self):
"""Explicitly create a SQLite engine to confirm NullPool is applied."""
from sqlalchemy import create_engine
from sqlalchemy.pool import NullPool
test_engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=NullPool,
)
assert isinstance(test_engine.pool, NullPool)
test_engine.dispose()
def test_pool_settings_exist_in_config(self):
"""Verify that pool tuning settings are exposed through config."""
from app.config import settings
assert hasattr(settings, "db_pool_size")
assert hasattr(settings, "db_max_overflow")
assert hasattr(settings, "db_pool_timeout")
assert hasattr(settings, "db_pool_recycle")
def test_pool_settings_have_sensible_defaults(self):
"""Default pool settings should be larger than SQLAlchemy's built-in defaults."""
from app.config import settings
# SQLAlchemy defaults: pool_size=5, max_overflow=10
assert settings.db_pool_size >= 10
assert settings.db_max_overflow >= 20
assert settings.db_pool_timeout >= 30
assert settings.db_pool_recycle >= 1800
+201
View File
@@ -0,0 +1,201 @@
"""Tests for the Devices page and mobile token filtering (app/api/api_tokens.py mobile endpoint).
These tests validate:
- ``GET /api/api-tokens/mobile`` returns only mobile tokens
- ``GET /api/api-tokens/`` excludes mobile tokens
- ``GET /devices`` renders the devices page
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models import ApiToken
# ---------------------------------------------------------------------------
# Test data
# ---------------------------------------------------------------------------
_OWNER = "devices_user@example.com"
_OTHER_OWNER = "other_devices@example.com"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def dev_engine():
"""In-memory SQLite engine."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def dev_session(dev_engine):
"""DB session scoped to one test."""
Session = sessionmaker(bind=dev_engine)
session = Session()
yield session
session.close()
def _make_client(dev_engine, owner_id: str = _OWNER) -> TestClient:
"""Return a TestClient with *owner_id* injected as the authenticated user."""
from app.api.api_tokens import _get_owner_id
from app.main import app
Session = sessionmaker(bind=dev_engine)
def _override_get_db():
session = Session()
try:
yield session
finally:
session.close()
def _override_owner():
return owner_id
app.dependency_overrides[get_db] = _override_get_db
app.dependency_overrides[_get_owner_id] = _override_owner
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
return client
def _cleanup(app):
"""Remove dependency overrides after test."""
app.dependency_overrides.clear()
def _seed_tokens(session, owner_id: str = _OWNER):
"""Create a mix of regular and mobile tokens for testing."""
from app.api.api_tokens import generate_api_token, hash_token
tokens = []
# Regular API tokens
for name in ["CI Pipeline", "Webhook Upload"]:
pt = generate_api_token()
t = ApiToken(owner_id=owner_id, name=name, token_hash=hash_token(pt), token_prefix=pt[:12])
session.add(t)
tokens.append(t)
# Mobile tokens (various naming patterns)
for name in [
"Mobile App iPhone 15 Pro",
"Mobile App (QR) Christian's iPad",
"Mobile App",
]:
pt = generate_api_token()
t = ApiToken(owner_id=owner_id, name=name, token_hash=hash_token(pt), token_prefix=pt[:12])
session.add(t)
tokens.append(t)
session.commit()
return tokens
# ---------------------------------------------------------------------------
# Tests Mobile Token Filtering
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestMobileTokenFiltering:
"""Tests for GET /api/api-tokens/mobile and filtering from GET /api/api-tokens/."""
def test_list_mobile_tokens_returns_only_mobile(self, dev_engine, dev_session):
"""GET /api/api-tokens/mobile should only return tokens starting with 'Mobile App'."""
_seed_tokens(dev_session)
client = _make_client(dev_engine)
try:
res = client.get("/api/api-tokens/mobile")
assert res.status_code == 200
data = res.json()
assert len(data) == 3
for t in data:
assert t["name"].startswith("Mobile App")
finally:
_cleanup(client.app)
def test_list_regular_tokens_excludes_mobile(self, dev_engine, dev_session):
"""GET /api/api-tokens/ should NOT return tokens starting with 'Mobile App'."""
_seed_tokens(dev_session)
client = _make_client(dev_engine)
try:
res = client.get("/api/api-tokens/")
assert res.status_code == 200
data = res.json()
assert len(data) == 2
for t in data:
assert not t["name"].startswith("Mobile App")
finally:
_cleanup(client.app)
def test_list_mobile_tokens_empty(self, dev_engine):
"""GET /api/api-tokens/mobile returns [] when no mobile tokens exist."""
client = _make_client(dev_engine)
try:
res = client.get("/api/api-tokens/mobile")
assert res.status_code == 200
assert res.json() == []
finally:
_cleanup(client.app)
def test_list_mobile_tokens_isolation(self, dev_engine, dev_session):
"""Mobile tokens for other users should not appear."""
_seed_tokens(dev_session, owner_id=_OTHER_OWNER)
client = _make_client(dev_engine, owner_id=_OWNER)
try:
res = client.get("/api/api-tokens/mobile")
assert res.status_code == 200
assert res.json() == []
finally:
_cleanup(client.app)
def test_mobile_token_revoke_via_api_tokens_endpoint(self, dev_engine, dev_session):
"""Mobile tokens can still be revoked via DELETE /api/api-tokens/{id}."""
tokens = _seed_tokens(dev_session)
mobile_token = next(t for t in tokens if t.name.startswith("Mobile App"))
client = _make_client(dev_engine)
try:
res = client.delete(f"/api/api-tokens/{mobile_token.id}")
assert res.status_code == 200
# Verify it's gone from mobile list
res2 = client.get("/api/api-tokens/mobile")
active_names = [t["name"] for t in res2.json() if t["is_active"]]
assert mobile_token.name not in active_names
finally:
_cleanup(client.app)
# ---------------------------------------------------------------------------
# Tests Devices Page View
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDevicesPageView:
"""Tests for GET /devices page rendering."""
def test_devices_page_renders(self, dev_engine):
"""GET /devices should return 200 with the devices template."""
from app.views.devices import router as _ # noqa: F401 ensures route is registered
client = _make_client(dev_engine)
try:
res = client.get("/devices")
assert res.status_code == 200
assert "devices.heading" in res.text or "Mobile Devices" in res.text
finally:
_cleanup(client.app)
+80
View File
@@ -5,6 +5,86 @@ from unittest.mock import MagicMock, patch
import pytest
@pytest.mark.unit
class TestLivenessProbe:
"""Tests for GET /api/diagnostic/healthz/live (unauthenticated)."""
def test_liveness_returns_200(self, client):
"""Liveness probe always returns 200 OK."""
response = client.get("/api/diagnostic/healthz/live")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
@pytest.mark.unit
class TestReadinessProbe:
"""Tests for GET /api/diagnostic/healthz/ready (unauthenticated)."""
def test_readiness_returns_200_when_all_ok(self, client):
"""Readiness probe returns 200 when database and Redis are reachable."""
with (
patch("app.api.diagnostic.engine") as mock_engine,
patch("app.api.diagnostic.redis_lib") as mock_redis,
):
mock_conn = MagicMock()
mock_engine.connect.return_value.__enter__ = MagicMock(return_value=mock_conn)
mock_engine.connect.return_value.__exit__ = MagicMock(return_value=False)
mock_redis_inst = MagicMock()
mock_redis.from_url.return_value = mock_redis_inst
response = client.get("/api/diagnostic/healthz/ready")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ready"
assert data["checks"]["database"]["status"] == "ok"
def test_readiness_returns_503_when_database_fails(self, client):
"""Readiness probe returns 503 when database is unreachable."""
with (
patch("app.api.diagnostic.engine") as mock_engine,
patch("app.api.diagnostic.redis_lib") as mock_redis,
):
mock_engine.connect.side_effect = Exception("DB unavailable")
mock_redis_inst = MagicMock()
mock_redis.from_url.return_value = mock_redis_inst
response = client.get("/api/diagnostic/healthz/ready")
assert response.status_code == 503
data = response.json()
assert data["status"] == "not_ready"
assert data["checks"]["database"]["status"] == "error"
def test_readiness_returns_200_when_redis_fails(self, client):
"""Readiness remains 200 when only Redis is down (non-critical)."""
with (
patch("app.api.diagnostic.engine") as mock_engine,
patch("app.api.diagnostic.redis_lib") as mock_redis,
):
mock_conn = MagicMock()
mock_engine.connect.return_value.__enter__ = MagicMock(return_value=mock_conn)
mock_engine.connect.return_value.__exit__ = MagicMock(return_value=False)
mock_redis.from_url.return_value = MagicMock()
mock_redis.from_url.return_value.ping.side_effect = Exception("Connection refused")
response = client.get("/api/diagnostic/healthz/ready")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ready"
assert data["checks"]["redis"]["status"] == "error"
def test_readiness_contains_checks_keys(self, client):
"""Readiness response always contains database and redis checks."""
response = client.get("/api/diagnostic/healthz/ready")
data = response.json()
assert "checks" in data
assert "database" in data["checks"]
assert "redis" in data["checks"]
@pytest.mark.unit
class TestHealthEndpoint:
"""Tests for GET /api/diagnostic/health endpoint."""
+62 -26
View File
@@ -3,11 +3,12 @@
Covers:
- ``GET /api/duplicates`` — list all exact-duplicate groups
- ``GET /api/files/{id}/duplicates`` — per-file exact + near-duplicate info
- ``POST /api/ui-upload`` — exact-duplicate warning in upload response
- ``POST /api/ui-upload`` — exact-duplicate rejection at upload time
- ``GET /duplicates`` — duplicate management UI page
"""
import json
import os
from unittest.mock import patch
import pytest
@@ -283,17 +284,25 @@ class TestGetFileDuplicates:
# ---------------------------------------------------------------------------
# POST /api/ui-upload — exact-duplicate warning
# POST /api/ui-upload — exact-duplicate rejection
# ---------------------------------------------------------------------------
class TestUploadDuplicateWarning:
"""Tests for duplicate warning injected into the upload response."""
class TestUploadDuplicateRejection:
"""Tests for duplicate rejection at upload time.
When ``ENABLE_DEDUPLICATION`` is ``True`` (the default) and the uploaded
file's SHA-256 hash matches an already-processed document, the upload
endpoint must:
- return ``status: "duplicate"`` instead of ``"queued"``
- **not** enqueue a Celery task
- clean up the temporary file from disk
"""
@pytest.mark.integration
@patch("app.tasks.process_document.process_document.delay")
def test_no_warning_for_unique_file(self, mock_delay, client: TestClient, tmp_path):
"""Uploading a unique file should not produce a duplicate_warning."""
"""Uploading a unique file should not produce a duplicate response."""
mock_delay.return_value.id = "task-unique"
pdf = tmp_path / "unique.pdf"
pdf.write_bytes(b"%PDF-1.4\n%%EOF")
@@ -306,14 +315,12 @@ class TestUploadDuplicateWarning:
assert response.status_code == 200
data = response.json()
assert "duplicate_warning" not in data or data.get("duplicate_warning") is None
assert data["status"] == "queued"
assert "duplicate_of" not in data
@pytest.mark.integration
@patch("app.tasks.process_document.process_document.delay")
def test_warning_for_exact_duplicate(self, mock_delay, client: TestClient, db_session, tmp_path):
"""Uploading a file with the same hash as an existing record returns a warning."""
mock_delay.return_value.id = "task-dup"
def test_exact_duplicate_rejected(self, client: TestClient, db_session, tmp_path):
"""Uploading a file with the same hash as an existing record is rejected."""
# Create a real PDF with known content
pdf_bytes = b"%PDF-1.4\nsome unique content for test\n%%EOF"
pdf = tmp_path / "existing.pdf"
@@ -335,16 +342,14 @@ class TestUploadDuplicateWarning:
assert response.status_code == 200
data = response.json()
assert "duplicate_warning" in data
assert data["duplicate_warning"]["duplicate_type"] == "exact"
assert data["duplicate_warning"]["original_file_id"] == existing.id
assert data["status"] == "duplicate"
assert "duplicate_of" in data
assert data["duplicate_of"]["duplicate_type"] == "exact"
assert data["duplicate_of"]["original_file_id"] == existing.id
@pytest.mark.integration
@patch("app.tasks.process_document.process_document.delay")
def test_upload_still_queued_despite_warning(self, mock_delay, client: TestClient, db_session, tmp_path):
"""Even when a duplicate is detected, the file should still be queued."""
mock_delay.return_value.id = "task-still-queued"
def test_duplicate_not_enqueued(self, client: TestClient, db_session, tmp_path):
"""When a duplicate is detected, no Celery task should be created."""
pdf_bytes = b"%PDF-1.4\nqueue test content\n%%EOF"
pdf = tmp_path / "queue_test.pdf"
pdf.write_bytes(pdf_bytes)
@@ -354,16 +359,47 @@ class TestUploadDuplicateWarning:
filehash = hash_file(str(pdf))
_make_file(db_session, filehash=filehash, filename="queue_orig.pdf")
with open(pdf, "rb") as f:
response = client.post(
"/api/ui-upload",
files={"file": ("queue_test.pdf", f, "application/pdf")},
)
with patch("app.tasks.process_document.process_document.delay") as mock_delay:
with open(pdf, "rb") as f:
response = client.post(
"/api/ui-upload",
files={"file": ("queue_test.pdf", f, "application/pdf")},
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
assert data["status"] == "queued"
assert data["status"] == "duplicate"
assert "task_id" not in data
mock_delay.assert_not_called()
@pytest.mark.integration
def test_duplicate_temp_file_cleaned_up(self, client: TestClient, db_session, tmp_path):
"""The temporary file saved to disk should be removed for a duplicate."""
pdf_bytes = b"%PDF-1.4\ncleanup test content\n%%EOF"
pdf = tmp_path / "cleanup_test.pdf"
pdf.write_bytes(pdf_bytes)
from app.utils.file_operations import hash_file
filehash = hash_file(str(pdf))
_make_file(db_session, filehash=filehash, filename="cleanup_orig.pdf")
with patch("app.tasks.process_document.process_document.delay"):
with open(pdf, "rb") as f:
response = client.post(
"/api/ui-upload",
files={"file": ("cleanup_test.pdf", f, "application/pdf")},
)
assert response.status_code == 200
data = response.json()
# The stored_filename is returned so we can verify cleanup
stored = data.get("stored_filename")
assert stored is not None
from app.config import settings
assert not os.path.exists(os.path.join(settings.workdir, stored))
# ---------------------------------------------------------------------------
+29
View File
@@ -322,6 +322,35 @@ def test_signup_duplicate_username(la_client, active_user):
assert "Username" in resp.json()["detail"]
@pytest.mark.integration
def test_signup_invalid_username_with_dot(la_client):
"""POST /api/auth/signup returns 422 with a list detail when username contains a dot.
This is a regression test for the bug where ``data.detail`` was an array,
causing the frontend to display ``[object Object]`` instead of a message.
"""
with patch("app.api.local_auth.settings") as mock_settings:
mock_settings.allow_local_signup = True
mock_settings.multi_user_enabled = True
mock_settings.email_host = "smtp.example.com"
resp = la_client.post(
"/api/auth/signup",
json={
"email": "a@example.com",
"username": "christian.louis",
"password": "password1",
"password_confirm": "password1",
},
)
assert resp.status_code == 422
detail = resp.json()["detail"]
# FastAPI returns a list of validation errors for Pydantic constraint failures.
# Each entry must be a dict with a "msg" key so the frontend can extract a readable message.
assert isinstance(detail, list), "detail should be a list for Pydantic validation errors"
assert len(detail) > 0
assert "msg" in detail[0]
@pytest.mark.integration
def test_signup_smtp_failure_cleans_up(la_client, la_session):
"""POST /api/auth/signup cleans up user records if email send fails."""
+27
View File
@@ -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
+699
View File
@@ -0,0 +1,699 @@
"""Tests for server-side session management and QR code login.
Covers:
* Session creation, validation, revocation, and cleanup
* "Log off everywhere" (revoke all sessions)
* QR login challenge creation, validation, claiming, and status polling
* Session management API endpoints (list, revoke, revoke-all)
* QR auth API endpoints (challenge, status, claim)
* Device info parsing from User-Agent strings
"""
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.database import Base
from app.models import ApiToken, QRLoginChallenge, UserSession
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def db_session():
"""Provide an in-memory SQLite session with all tables created."""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
TestSession = sessionmaker(bind=engine)
session = TestSession()
yield session
session.close()
Base.metadata.drop_all(engine)
@pytest.fixture()
def sample_user_id():
return "user@example.com"
# ---------------------------------------------------------------------------
# Model Tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestUserSessionModel:
"""Tests for the UserSession ORM model."""
def test_create_user_session(self, db_session: Session, sample_user_id: str):
"""Test creating a UserSession record."""
now = datetime.now(timezone.utc)
session = UserSession(
session_token=secrets.token_urlsafe(64),
user_id=sample_user_id,
ip_address="192.168.1.1",
user_agent="Mozilla/5.0",
device_info="Chrome on macOS",
expires_at=now + timedelta(days=30),
)
db_session.add(session)
db_session.commit()
assert session.id is not None
assert session.user_id == sample_user_id
assert session.is_revoked is False
assert session.device_info == "Chrome on macOS"
def test_session_default_values(self, db_session: Session, sample_user_id: str):
"""Test that default values are set correctly."""
session = UserSession(
session_token="test_token_123",
user_id=sample_user_id,
expires_at=datetime.now(timezone.utc) + timedelta(days=30),
)
db_session.add(session)
db_session.commit()
assert session.is_revoked is False
assert session.revoked_at is None
@pytest.mark.unit
class TestQRLoginChallengeModel:
"""Tests for the QRLoginChallenge ORM model."""
def test_create_challenge(self, db_session: Session, sample_user_id: str):
"""Test creating a QRLoginChallenge record."""
challenge = QRLoginChallenge(
challenge_token=secrets.token_urlsafe(64),
user_id=sample_user_id,
created_by_ip="10.0.0.1",
expires_at=datetime.now(timezone.utc) + timedelta(seconds=120),
)
db_session.add(challenge)
db_session.commit()
assert challenge.id is not None
assert challenge.is_claimed is False
assert challenge.is_cancelled is False
def test_challenge_default_values(self, db_session: Session, sample_user_id: str):
"""Test that QRLoginChallenge defaults are correct."""
challenge = QRLoginChallenge(
challenge_token="challenge_test_123",
user_id=sample_user_id,
expires_at=datetime.now(timezone.utc) + timedelta(seconds=120),
)
db_session.add(challenge)
db_session.commit()
assert challenge.is_claimed is False
assert challenge.is_cancelled is False
assert challenge.claimed_at is None
assert challenge.device_name is None
# ---------------------------------------------------------------------------
# Session Manager Tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSessionManager:
"""Tests for app/utils/session_manager.py functions."""
@patch("app.utils.session_manager.settings")
def test_get_session_lifetime_days_default(self, mock_settings):
"""Test default session lifetime."""
from app.utils.session_manager import get_session_lifetime_days
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
assert get_session_lifetime_days() == 30
@patch("app.utils.session_manager.settings")
def test_get_session_lifetime_days_custom(self, mock_settings):
"""Test custom session lifetime overrides default."""
from app.utils.session_manager import get_session_lifetime_days
mock_settings.session_lifetime_custom_days = 90
mock_settings.session_lifetime_days = 30
assert get_session_lifetime_days() == 90
@patch("app.utils.session_manager.settings")
def test_get_session_lifetime_days_minimum(self, mock_settings):
"""Test session lifetime has a minimum of 1 day."""
from app.utils.session_manager import get_session_lifetime_days
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 0
assert get_session_lifetime_days() == 1
@patch("app.utils.session_manager.settings")
def test_get_session_max_age_seconds(self, mock_settings):
"""Test session max age in seconds."""
from app.utils.session_manager import get_session_max_age_seconds
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
assert get_session_max_age_seconds() == 30 * 86400
@patch("app.utils.session_manager.settings")
def test_create_session(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test creating a server-side session."""
from app.utils.session_manager import create_session
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
mock_settings.qr_login_challenge_ttl_seconds = 120
user_session = create_session(
db_session,
user_id=sample_user_id,
ip_address="10.0.0.1",
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0",
)
assert user_session.id is not None
assert user_session.user_id == sample_user_id
assert user_session.ip_address == "10.0.0.1"
assert user_session.session_token is not None
assert len(user_session.session_token) > 32
assert user_session.is_revoked is False
assert user_session.device_info is not None
@patch("app.utils.session_manager.settings")
def test_validate_session_valid(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test validating a valid session."""
from app.utils.session_manager import create_session, validate_session
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
user_session = create_session(db_session, user_id=sample_user_id)
result = validate_session(db_session, user_session.session_token)
assert result is not None
assert result.id == user_session.id
@patch("app.utils.session_manager.settings")
def test_validate_session_revoked(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that revoked sessions are rejected."""
from app.utils.session_manager import create_session, validate_session
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
user_session = create_session(db_session, user_id=sample_user_id)
user_session.is_revoked = True
db_session.commit()
result = validate_session(db_session, user_session.session_token)
assert result is None
@patch("app.utils.session_manager.settings")
def test_validate_session_expired(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that expired sessions are rejected."""
from app.utils.session_manager import create_session, validate_session
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
user_session = create_session(db_session, user_id=sample_user_id)
user_session.expires_at = datetime.now(timezone.utc) - timedelta(hours=1)
db_session.commit()
result = validate_session(db_session, user_session.session_token)
assert result is None
def test_validate_session_empty_token(self, db_session: Session):
"""Test that empty token returns None."""
from app.utils.session_manager import validate_session
assert validate_session(db_session, "") is None
assert validate_session(db_session, None) is None
def test_validate_session_nonexistent_token(self, db_session: Session):
"""Test that nonexistent token returns None."""
from app.utils.session_manager import validate_session
assert validate_session(db_session, "nonexistent_token_xyz") is None
@patch("app.utils.session_manager.settings")
def test_revoke_session(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test revoking a single session."""
from app.utils.session_manager import create_session, revoke_session, validate_session
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
user_session = create_session(db_session, user_id=sample_user_id)
assert revoke_session(db_session, user_session.id, sample_user_id) is True
# Session should now be invalid
assert validate_session(db_session, user_session.session_token) is None
@patch("app.utils.session_manager.settings")
def test_revoke_session_wrong_user(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that a user cannot revoke another user's session."""
from app.utils.session_manager import create_session, revoke_session
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
user_session = create_session(db_session, user_id=sample_user_id)
assert revoke_session(db_session, user_session.id, "other_user@example.com") is False
@patch("app.utils.session_manager.settings")
def test_revoke_all_sessions(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test revoking all sessions for a user."""
from app.utils.session_manager import create_session, list_user_sessions, revoke_all_sessions
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
s1 = create_session(db_session, user_id=sample_user_id)
s2 = create_session(db_session, user_id=sample_user_id)
s3 = create_session(db_session, user_id=sample_user_id)
count = revoke_all_sessions(db_session, sample_user_id, revoke_api_tokens=False)
assert count == 3
# All sessions should be revoked
active = list_user_sessions(db_session, sample_user_id)
assert len(active) == 0
@patch("app.utils.session_manager.settings")
def test_revoke_all_except_current(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test revoking all sessions except the current one."""
from app.utils.session_manager import create_session, list_user_sessions, revoke_all_sessions
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
s1 = create_session(db_session, user_id=sample_user_id)
s2 = create_session(db_session, user_id=sample_user_id)
s3 = create_session(db_session, user_id=sample_user_id)
count = revoke_all_sessions(
db_session,
sample_user_id,
except_session_id=s1.id,
revoke_api_tokens=False,
)
assert count == 2
active = list_user_sessions(db_session, sample_user_id)
assert len(active) == 1
assert active[0].id == s1.id
@patch("app.utils.session_manager.settings")
def test_revoke_all_includes_api_tokens(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that revoke-all also revokes API tokens."""
from app.utils.session_manager import create_session, revoke_all_sessions
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
create_session(db_session, user_id=sample_user_id)
# Create an API token
token = ApiToken(
owner_id=sample_user_id,
name="Test Token",
token_hash="abc123hash",
token_prefix="de_abc12345",
)
db_session.add(token)
db_session.commit()
revoke_all_sessions(db_session, sample_user_id, revoke_api_tokens=True)
db_session.refresh(token)
assert token.is_active is False
@patch("app.utils.session_manager.settings")
def test_list_user_sessions(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test listing active sessions for a user."""
from app.utils.session_manager import create_session, list_user_sessions
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
create_session(db_session, user_id=sample_user_id)
create_session(db_session, user_id=sample_user_id)
create_session(db_session, user_id="other@example.com")
sessions = list_user_sessions(db_session, sample_user_id)
assert len(sessions) == 2
@patch("app.utils.session_manager.settings")
def test_cleanup_expired_sessions(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test cleaning up expired sessions."""
from app.utils.session_manager import cleanup_expired_sessions, create_session
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
# Create a session that expired 10 days ago
session = create_session(db_session, user_id=sample_user_id)
session.expires_at = datetime.now(timezone.utc) - timedelta(days=10)
db_session.commit()
count = cleanup_expired_sessions(db_session)
assert count == 1
# ---------------------------------------------------------------------------
# QR Login Tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestQRLogin:
"""Tests for QR login challenge/claim flow."""
@patch("app.utils.session_manager.settings")
def test_create_qr_challenge(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test creating a QR login challenge."""
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, ip_address="10.0.0.1")
assert challenge.id is not None
assert challenge.user_id == sample_user_id
assert challenge.challenge_token is not None
assert len(challenge.challenge_token) > 32
assert challenge.is_claimed is False
assert challenge.created_by_ip == "10.0.0.1"
# SQLite returns naive datetimes; normalise before comparison
expires = challenge.expires_at
if expires.tzinfo is None:
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 app.utils.session_manager import create_qr_challenge, validate_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
result = validate_qr_challenge(db_session, challenge.challenge_token)
assert result is not None
assert result.id == challenge.id
@patch("app.utils.session_manager.settings")
def test_validate_qr_challenge_expired(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that expired challenges are rejected."""
from app.utils.session_manager import create_qr_challenge, validate_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
challenge.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
db_session.commit()
result = validate_qr_challenge(db_session, challenge.challenge_token)
assert result is None
@patch("app.utils.session_manager.settings")
def test_validate_qr_challenge_claimed(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that claimed challenges are rejected (replay protection)."""
from app.utils.session_manager import create_qr_challenge, validate_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
challenge.is_claimed = True
db_session.commit()
result = validate_qr_challenge(db_session, challenge.challenge_token)
assert result is None
@patch("app.utils.session_manager.settings")
def test_validate_qr_challenge_cancelled(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that cancelled challenges are rejected."""
from app.utils.session_manager import create_qr_challenge, validate_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
challenge.is_cancelled = True
db_session.commit()
result = validate_qr_challenge(db_session, challenge.challenge_token)
assert result is None
def test_validate_qr_challenge_empty(self, db_session: Session):
"""Test that empty challenge token returns None."""
from app.utils.session_manager import validate_qr_challenge
assert validate_qr_challenge(db_session, "") is None
assert validate_qr_challenge(db_session, None) is None
@patch("app.utils.session_manager.settings")
def test_claim_qr_challenge_success(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test successfully claiming a QR challenge."""
from app.utils.session_manager import claim_qr_challenge, create_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
result = claim_qr_challenge(
db_session,
challenge.challenge_token,
device_name="Christian's iPhone 15 Pro",
ip_address="192.168.1.100",
)
assert result is not None
assert result["token"].startswith("de_")
assert result["token_id"] is not None
assert result["owner_id"] == sample_user_id
assert "QR" in result["name"]
# Challenge should now be claimed
db_session.refresh(challenge)
assert challenge.is_claimed is True
assert challenge.claimed_by_ip == "192.168.1.100"
assert challenge.device_name == "Christian's iPhone 15 Pro"
@patch("app.utils.session_manager.settings")
def test_claim_qr_challenge_replay_protection(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that a claimed challenge cannot be claimed again."""
from app.utils.session_manager import claim_qr_challenge, create_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
# First claim succeeds
result1 = claim_qr_challenge(db_session, challenge.challenge_token)
assert result1 is not None
# Second claim fails (replay protection)
result2 = claim_qr_challenge(db_session, challenge.challenge_token)
assert result2 is None
@patch("app.utils.session_manager.settings")
def test_claim_qr_challenge_expired(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that expired challenges cannot be claimed."""
from app.utils.session_manager import claim_qr_challenge, create_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
challenge.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
db_session.commit()
result = claim_qr_challenge(db_session, challenge.challenge_token)
assert result is None
def test_claim_qr_challenge_invalid_token(self, db_session: Session):
"""Test claiming with an invalid token."""
from app.utils.session_manager import claim_qr_challenge
result = claim_qr_challenge(db_session, "nonexistent_token_xyz")
assert result is None
@patch("app.utils.session_manager.settings")
def test_get_challenge_status_pending(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test getting status of a pending challenge."""
from app.utils.session_manager import create_qr_challenge, get_challenge_status
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
status = get_challenge_status(db_session, challenge.id, sample_user_id)
assert status is not None
assert status["status"] == "pending"
@patch("app.utils.session_manager.settings")
def test_get_challenge_status_claimed(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test getting status of a claimed challenge."""
from app.utils.session_manager import claim_qr_challenge, create_qr_challenge, get_challenge_status
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
claim_qr_challenge(db_session, challenge.challenge_token, device_name="Test Device")
status = get_challenge_status(db_session, challenge.id, sample_user_id)
assert status is not None
assert status["status"] == "claimed"
assert status["device_name"] == "Test Device"
@patch("app.utils.session_manager.settings")
def test_get_challenge_status_expired(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test getting status of an expired challenge."""
from app.utils.session_manager import create_qr_challenge, get_challenge_status
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
challenge.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
db_session.commit()
status = get_challenge_status(db_session, challenge.id, sample_user_id)
assert status["status"] == "expired"
@patch("app.utils.session_manager.settings")
def test_get_challenge_status_wrong_user(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that a user cannot see another user's challenge status."""
from app.utils.session_manager import create_qr_challenge, get_challenge_status
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
status = get_challenge_status(db_session, challenge.id, "other@example.com")
assert status is None
# ---------------------------------------------------------------------------
# Device Info Parsing Tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDeviceInfoParsing:
"""Tests for User-Agent parsing."""
def test_chrome_macos(self):
from app.utils.session_manager import _parse_device_info
ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
result = _parse_device_info(ua)
assert "Chrome" in result
assert "macOS" in result
def test_safari_iphone(self):
from app.utils.session_manager import _parse_device_info
ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"
result = _parse_device_info(ua)
assert "Safari" in result
assert "iPhone" in result
def test_firefox_windows(self):
from app.utils.session_manager import _parse_device_info
ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"
result = _parse_device_info(ua)
assert "Firefox" in result
assert "Windows" in result
def test_edge_windows(self):
from app.utils.session_manager import _parse_device_info
ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0"
result = _parse_device_info(ua)
assert "Edge" in result
assert "Windows" in result
def test_android_chrome(self):
from app.utils.session_manager import _parse_device_info
ua = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.210 Mobile Safari/537.36"
result = _parse_device_info(ua)
assert "Chrome" in result
assert "Android" in result
def test_none_user_agent(self):
from app.utils.session_manager import _parse_device_info
assert _parse_device_info(None) is None
def test_empty_user_agent(self):
from app.utils.session_manager import _parse_device_info
assert _parse_device_info("") is None
# ---------------------------------------------------------------------------
# Config Tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSessionConfig:
"""Tests for session-related configuration fields."""
def test_session_lifetime_days_field_exists(self):
"""Verify session_lifetime_days field is defined in Settings."""
from app.config import Settings
# Check the field exists in the model
assert "session_lifetime_days" in Settings.model_fields
def test_session_lifetime_custom_days_field_exists(self):
"""Verify session_lifetime_custom_days field is defined in Settings."""
from app.config import Settings
assert "session_lifetime_custom_days" in Settings.model_fields
def test_qr_login_challenge_ttl_field_exists(self):
"""Verify qr_login_challenge_ttl_seconds field is defined in Settings."""
from app.config import Settings
assert "qr_login_challenge_ttl_seconds" in Settings.model_fields
+393
View File
@@ -0,0 +1,393 @@
"""Tests for the system reset feature (app/api/system_reset.py, app/utils/system_reset.py, app/views/system_reset.py)."""
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base
from app.models import (
DocumentMetadata,
FileProcessingStep,
FileRecord,
ProcessingLog,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def reset_workdir():
"""Create a temporary workdir populated with sample user data."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create data subdirectories with dummy files
for subdir in ("original", "processed", "tmp", "pdfa", "backups"):
d = Path(tmpdir) / subdir
d.mkdir()
(d / "sample.pdf").write_bytes(b"%PDF-1.4 fake")
# Create cache files
for cache in ("watch_folder_processed.json", "ftp_ingest_processed.json"):
(Path(tmpdir) / cache).write_text("{}")
# Create a per-user watch folder cache
(Path(tmpdir) / "user_wf_42.json").write_text("{}")
# Create a loose PDF in workdir root
(Path(tmpdir) / "abc123.pdf").write_bytes(b"%PDF-1.4 loose")
yield tmpdir
@pytest.fixture
def reset_db_session():
"""Fresh in-memory database with sample user data rows."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
Session = sessionmaker(bind=engine)
session = Session()
# Seed with sample data
fr = FileRecord(
filehash="abc123",
original_filename="test.pdf",
local_filename="uuid.pdf",
file_size=1024,
mime_type="application/pdf",
)
session.add(fr)
session.flush()
session.add(ProcessingLog(file_id=fr.id, task_id="t1", step_name="hash_file", status="success"))
session.add(FileProcessingStep(file_id=fr.id, step_name="hash_file", status="success"))
session.add(DocumentMetadata(filename="test.pdf", sender="Alice", recipient="Bob"))
session.commit()
yield session
session.close()
Base.metadata.drop_all(bind=engine)
# ---------------------------------------------------------------------------
# Unit tests for app/utils/system_reset.py
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestWipeWorkdirData:
"""Tests for _wipe_workdir_data()."""
def test_removes_data_subdirs(self, reset_workdir):
from app.utils.system_reset import _wipe_workdir_data
result = _wipe_workdir_data(reset_workdir)
# All data subdirectories should be gone
for subdir in ("original", "processed", "tmp", "pdfa", "backups"):
assert not (Path(reset_workdir) / subdir).exists()
assert result["deleted_dirs"] == 5
def test_removes_cache_files(self, reset_workdir):
from app.utils.system_reset import _wipe_workdir_data
result = _wipe_workdir_data(reset_workdir)
assert not (Path(reset_workdir) / "watch_folder_processed.json").exists()
assert not (Path(reset_workdir) / "ftp_ingest_processed.json").exists()
assert not (Path(reset_workdir) / "user_wf_42.json").exists()
assert result["deleted_files"] >= 3
def test_removes_loose_document_files(self, reset_workdir):
from app.utils.system_reset import _wipe_workdir_data
_wipe_workdir_data(reset_workdir)
assert not (Path(reset_workdir) / "abc123.pdf").exists()
def test_preserves_workdir_directory(self, reset_workdir):
from app.utils.system_reset import _wipe_workdir_data
_wipe_workdir_data(reset_workdir)
assert Path(reset_workdir).is_dir()
def test_handles_empty_workdir(self):
"""No errors when workdir has no data dirs or caches."""
from app.utils.system_reset import _wipe_workdir_data
with tempfile.TemporaryDirectory() as empty_dir:
result = _wipe_workdir_data(empty_dir)
assert result["deleted_dirs"] == 0
assert result["deleted_files"] == 0
@pytest.mark.unit
class TestWipeDatabase:
"""Tests for _wipe_database()."""
def test_deletes_all_user_data(self, reset_db_session):
from app.utils.system_reset import _wipe_database
result = _wipe_database(reset_db_session)
assert result.get("files", 0) >= 1
assert result.get("processing_logs", 0) >= 1
assert result.get("file_processing_steps", 0) >= 1
assert result.get("document_metadata", 0) >= 1
def test_tables_are_empty_after_wipe(self, reset_db_session):
from app.utils.system_reset import _wipe_database
_wipe_database(reset_db_session)
assert reset_db_session.query(FileRecord).count() == 0
assert reset_db_session.query(ProcessingLog).count() == 0
assert reset_db_session.query(FileProcessingStep).count() == 0
assert reset_db_session.query(DocumentMetadata).count() == 0
@pytest.mark.unit
class TestPerformFullReset:
"""Tests for perform_full_reset()."""
def test_wipes_db_and_filesystem(self, reset_db_session, reset_workdir):
from app.utils.system_reset import perform_full_reset
with patch("app.utils.system_reset.settings") as mock_settings:
mock_settings.workdir = reset_workdir
result = perform_full_reset(reset_db_session)
assert "database" in result
assert "filesystem" in result
assert reset_db_session.query(FileRecord).count() == 0
assert not (Path(reset_workdir) / "original").exists()
@pytest.mark.unit
class TestPerformResetAndReimport:
"""Tests for perform_reset_and_reimport()."""
def test_copies_originals_to_reimport_then_wipes(self, reset_db_session, reset_workdir):
from app.utils.system_reset import perform_reset_and_reimport
with patch("app.utils.system_reset.settings") as mock_settings:
mock_settings.workdir = reset_workdir
mock_settings.watch_folders = ""
mock_settings.watch_folder_delete_after_process = False
result = perform_reset_and_reimport(reset_db_session)
reimport_dir = Path(reset_workdir) / "reimport"
assert reimport_dir.is_dir()
assert result["reimport"]["files_moved"] >= 1
# DB should be wiped
assert reset_db_session.query(FileRecord).count() == 0
# Reimport folder should contain the original file
reimport_files = list(reimport_dir.iterdir())
assert len(reimport_files) >= 1
def test_configures_watch_folder(self, reset_db_session, reset_workdir):
from app.utils.system_reset import perform_reset_and_reimport
with patch("app.utils.system_reset.settings") as mock_settings:
mock_settings.workdir = reset_workdir
mock_settings.watch_folders = "/some/other/folder"
mock_settings.watch_folder_delete_after_process = False
perform_reset_and_reimport(reset_db_session)
reimport_path = str(Path(reset_workdir) / "reimport")
# watch_folders should now include the reimport path
assert reimport_path in mock_settings.watch_folders
@pytest.mark.unit
class TestStartupReset:
"""Tests for perform_startup_reset()."""
def test_startup_reset_calls_full_reset(self):
from app.utils.system_reset import perform_startup_reset
with patch("app.utils.system_reset.perform_full_reset") as mock_reset:
with patch("app.database.SessionLocal") as mock_sl:
mock_db = mock_sl.return_value
perform_startup_reset()
mock_reset.assert_called_once_with(mock_db)
mock_db.close.assert_called_once()
def test_startup_reset_handles_errors(self):
from app.utils.system_reset import perform_startup_reset
with patch("app.utils.system_reset.perform_full_reset", side_effect=RuntimeError("boom")):
with patch("app.database.SessionLocal") as mock_sl:
mock_db = mock_sl.return_value
# Should not raise
perform_startup_reset()
mock_db.rollback.assert_called_once()
mock_db.close.assert_called_once()
# ---------------------------------------------------------------------------
# Integration tests for API endpoints
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestSystemResetApi:
"""Tests for the /api/admin/system-reset/ endpoints."""
def test_full_reset_requires_admin(self, client):
"""Non-admin users get 403."""
response = client.post(
"/api/admin/system-reset/full",
json={"confirmation": "DELETE"},
)
assert response.status_code == 403
def test_full_reset_requires_feature_flag(self, client):
"""Returns 404 when ENABLE_FACTORY_RESET is false."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
with patch("app.api.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = False
response = client.post(
"/api/admin/system-reset/full",
json={"confirmation": "DELETE"},
)
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 404
def test_full_reset_requires_confirmation(self, client):
"""Wrong confirmation string gets 400."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
with patch("app.api.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = True
response = client.post(
"/api/admin/system-reset/full",
json={"confirmation": "WRONG"},
)
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 400
def test_reimport_requires_confirmation(self, client):
"""Wrong confirmation string gets 400."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
with patch("app.api.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = True
response = client.post(
"/api/admin/system-reset/reimport",
json={"confirmation": "WRONG"},
)
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 400
def test_status_endpoint(self, client):
"""The status endpoint returns feature-flag state."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
response = client.get("/api/admin/system-reset/status")
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 200
data = response.json()
assert "enabled" in data
assert "factory_reset_on_startup" in data
def test_full_reset_success(self, client):
"""Full reset succeeds with correct confirmation and feature flag."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
with patch("app.api.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = True
with patch(
"app.utils.system_reset.perform_full_reset", return_value={"database": {}, "filesystem": {}}
):
response = client.post(
"/api/admin/system-reset/full",
json={"confirmation": "DELETE"},
)
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_reimport_success(self, client):
"""Reimport succeeds with correct confirmation."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
with patch("app.api.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = True
with patch(
"app.utils.system_reset.perform_reset_and_reimport",
return_value={"database": {}, "filesystem": {}, "reimport": {"files_moved": 3}},
):
response = client.post(
"/api/admin/system-reset/reimport",
json={"confirmation": "REIMPORT"},
)
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 200
assert response.json()["status"] == "ok"
# ---------------------------------------------------------------------------
# Integration tests for the view
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestSystemResetView:
"""Tests for the /admin/system-reset view."""
def test_view_redirects_when_disabled(self, client):
"""When ENABLE_FACTORY_RESET=False, accessing the page redirects away."""
with client:
client.cookies.set("session", "test")
with patch("app.views.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = False
response = client.get("/admin/system-reset", follow_redirects=False)
# Redirect to /settings (302) when disabled, or to login (302/307) when unauthenticated
assert response.status_code in (302, 307)
def test_view_requires_auth(self, client):
"""Unauthenticated users are redirected away from the page."""
with patch("app.views.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = True
mock_s.factory_reset_on_startup = False
response = client.get("/admin/system-reset", follow_redirects=False)
# Should redirect to login since there's no active session
assert response.status_code in (302, 307)
+265
View File
@@ -0,0 +1,265 @@
"""Tests for per-user health-aware upload rate limiting (app/middleware/upload_rate_limit.py)."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from app.middleware.upload_rate_limit import compute_effective_limit
# ---------------------------------------------------------------------------
# Tests for compute_effective_limit (pure function, no Redis needed)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestComputeEffectiveLimit:
"""Tests for the health-aware effective-limit calculation."""
def test_normal_conditions_return_base_limit(self):
"""Under normal conditions the full base limit should be returned."""
effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=0.0)
assert effective == 20
assert factor == 1.0
assert reason == "normal"
def test_moderate_queue_halves_limit(self):
"""Queue depth > 50 should halve the base limit."""
effective, factor, reason = compute_effective_limit(20, queue_depth=60, cpu_load_ratio=0.0)
assert effective == 10
assert factor == 0.5
assert "moderate_queue" in reason
def test_high_queue_quarters_limit(self):
"""Queue depth > 100 should quarter the base limit."""
effective, factor, reason = compute_effective_limit(20, queue_depth=120, cpu_load_ratio=0.0)
assert effective == 5
assert factor == 0.25
assert "high_queue" in reason
def test_critical_queue_drops_to_ten_percent(self):
"""Queue depth > 200 should drop to 10% of base limit."""
effective, factor, reason = compute_effective_limit(20, queue_depth=250, cpu_load_ratio=0.0)
assert effective == 2
assert factor == 0.10
assert "critical_queue" in reason
def test_moderate_cpu_halves_limit(self):
"""CPU load ratio > 1.5 should halve the base limit."""
effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=1.8)
assert effective == 10
assert factor == 0.5
assert "moderate_cpu" in reason
def test_high_cpu_quarters_limit(self):
"""CPU load ratio > 2.0 should quarter the base limit."""
effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=2.5)
assert effective == 5
assert factor == 0.25
assert "high_cpu" in reason
def test_critical_cpu_drops_to_ten_percent(self):
"""CPU load ratio > 3.0 should drop to 10% of base limit."""
effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=4.0)
assert effective == 2
assert factor == 0.10
assert "critical_cpu" in reason
def test_worst_metric_wins(self):
"""The lowest factor from queue and CPU should be applied."""
# Queue says 0.5, CPU says 0.25 → 0.25 wins
effective, factor, reason = compute_effective_limit(20, queue_depth=60, cpu_load_ratio=2.5)
assert effective == 5
assert factor == 0.25
def test_minimum_effective_limit_is_one(self):
"""Even under extreme load the effective limit must be ≥ 1."""
effective, _factor, _reason = compute_effective_limit(1, queue_depth=999, cpu_load_ratio=10.0)
assert effective >= 1
def test_zero_base_limit_returns_zero(self):
"""A base limit of 0 (disabled) should clamp to at least 1."""
effective, _factor, _reason = compute_effective_limit(0, queue_depth=0, cpu_load_ratio=0.0)
# max(1, int(0 * 1.0)) = max(1, 0) = 1
# A base_limit of 0 means "disabled" and is handled upstream
# (the dependency skips the check entirely), but the pure function
# still clamps to 1 as a safety net.
assert effective == 1
# ---------------------------------------------------------------------------
# Tests for the FastAPI dependency (mocked Redis)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestRequireUploadRateLimit:
"""Tests for the require_upload_rate_limit FastAPI dependency."""
@pytest.mark.asyncio
async def test_allows_request_when_redis_unavailable(self):
"""When Redis is down the dependency should fail open (allow the request)."""
from app.middleware.upload_rate_limit import require_upload_rate_limit
mock_request = MagicMock()
mock_request.session = {}
mock_request.client = MagicMock()
mock_request.client.host = "127.0.0.1"
with patch("app.middleware.upload_rate_limit._get_redis", return_value=None):
# Should NOT raise
result = await require_upload_rate_limit(mock_request)
assert result is None
@pytest.mark.asyncio
async def test_allows_request_under_limit(self):
"""A user below the rate limit should be allowed through."""
from app.middleware.upload_rate_limit import require_upload_rate_limit
mock_request = MagicMock()
mock_request.session = {"user": {"username": "testuser"}}
mock_request.client = MagicMock()
mock_request.client.host = "10.0.0.1"
mock_redis = MagicMock()
mock_pipe = MagicMock()
mock_pipe.execute.return_value = [
0, # zremrangebyscore result
5, # zcard — current count (under limit of 20)
[], # zrange oldest
]
mock_redis.pipeline.return_value = mock_pipe
mock_redis.llen.return_value = 0 # empty queues
mock_pipe2 = MagicMock()
mock_pipe2.execute.return_value = [True, True]
# The second pipeline call (record upload)
mock_redis.pipeline.side_effect = [mock_pipe, mock_pipe2]
with (
patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis),
patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value="testuser"),
patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.1),
):
result = await require_upload_rate_limit(mock_request)
assert result is None
@pytest.mark.asyncio
async def test_rejects_request_over_limit(self):
"""A user at or over the rate limit should receive a 429."""
from fastapi import HTTPException
from app.middleware.upload_rate_limit import require_upload_rate_limit
mock_request = MagicMock()
mock_request.session = {"user": {"username": "spammer"}}
mock_request.client = MagicMock()
mock_request.client.host = "10.0.0.2"
mock_redis = MagicMock()
mock_pipe = MagicMock()
mock_pipe.execute.return_value = [
0, # zremrangebyscore
20, # zcard — at limit
[("oldest_entry", 1000000.0)], # oldest entry for retry_after
]
mock_redis.pipeline.return_value = mock_pipe
mock_redis.llen.return_value = 0
with (
patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis),
patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value="spammer"),
patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.0),
):
with pytest.raises(HTTPException) as exc_info:
await require_upload_rate_limit(mock_request)
assert exc_info.value.status_code == 429
assert "Retry-After" in exc_info.value.headers
@pytest.mark.asyncio
async def test_health_reduces_effective_limit(self):
"""When queues are deep, the effective limit should drop, causing a 429 sooner."""
from fastapi import HTTPException
from app.middleware.upload_rate_limit import require_upload_rate_limit
mock_request = MagicMock()
mock_request.session = {"user": {"username": "normaluser"}}
mock_request.client = MagicMock()
mock_request.client.host = "10.0.0.3"
mock_redis = MagicMock()
mock_pipe = MagicMock()
# 12 uploads already — under normal limit of 20 but over health-reduced limit
mock_pipe.execute.return_value = [
0, # zremrangebyscore
12, # zcard — 12 uploads in window
[("oldest", 1000000.0)],
]
mock_redis.pipeline.return_value = mock_pipe
# Simulate deep queue (>100) → effective limit = 25% of 20 = 5
mock_redis.llen.return_value = 40 # 40 per queue * 3 = 120 total
with (
patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis),
patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value="normaluser"),
patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.0),
):
with pytest.raises(HTTPException) as exc_info:
await require_upload_rate_limit(mock_request)
assert exc_info.value.status_code == 429
@pytest.mark.asyncio
async def test_falls_back_to_ip_when_no_user(self):
"""Unauthenticated requests should use IP-based rate limiting."""
from app.middleware.upload_rate_limit import require_upload_rate_limit
mock_request = MagicMock()
mock_request.session = {}
mock_request.client = MagicMock()
mock_request.client.host = "192.168.1.100"
mock_redis = MagicMock()
mock_pipe = MagicMock()
mock_pipe.execute.return_value = [0, 0, []]
mock_redis.pipeline.return_value = mock_pipe
mock_redis.llen.return_value = 0
mock_pipe2 = MagicMock()
mock_pipe2.execute.return_value = [True, True]
mock_redis.pipeline.side_effect = [mock_pipe, mock_pipe2]
with (
patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis),
patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value=None),
patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.0),
):
result = await require_upload_rate_limit(mock_request)
assert result is None
# ---------------------------------------------------------------------------
# Tests for configuration
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestUploadRateLimitConfig:
"""Tests for upload rate limit configuration settings."""
def test_settings_exist(self):
"""Verify per-user upload rate limit settings are exposed in config."""
from app.config import settings
assert hasattr(settings, "upload_rate_limit_per_user")
assert hasattr(settings, "upload_rate_limit_window")
def test_sensible_defaults(self):
"""Default values should be reasonable for a multi-user system."""
from app.config import settings
assert settings.upload_rate_limit_per_user >= 10
assert settings.upload_rate_limit_per_user <= 100
assert settings.upload_rate_limit_window >= 30
assert settings.upload_rate_limit_window <= 300
+554
View File
@@ -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(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")
@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"
+34
View File
@@ -144,3 +144,37 @@ class TestDropboxViews:
assert response.status_code == 200
assert b"/Documents/Uploads" in response.content
assert b"Back to Integrations" in response.content
@pytest.mark.integration
class TestDropboxCallbackUrl:
"""Tests that the callback_url is correctly passed to templates."""
def test_setup_page_includes_callback_url(self, client):
"""Setup page should include the callback_url variable in its response."""
response = client.get("/dropbox-setup")
assert response.status_code == 200
# callback_url is embedded in the JS as the dropboxCallbackUrl constant
assert b"dropboxCallbackUrl" in response.content
def test_callback_page_includes_callback_url(self, client):
"""Callback page should embed the server-side callback URL."""
response = client.get("/dropbox-callback?code=testcode")
assert response.status_code == 200
# callback_url is used as the redirectUri
assert b"redirectUri" in response.content
def test_setup_page_uses_public_base_url_when_set(self, client):
"""When PUBLIC_BASE_URL is configured, it should appear in the redirect URI hint."""
with patch("app.views.dropbox.settings") as mock_settings:
mock_settings.public_base_url = "https://configured.example.com"
mock_settings.dropbox_app_key = ""
mock_settings.dropbox_app_secret = ""
mock_settings.dropbox_refresh_token = ""
mock_settings.dropbox_folder = ""
mock_settings.dropbox_allow_global_credentials_for_integrations = False
response = client.get("/dropbox-setup")
assert response.status_code == 200
# The configured public_base_url hostname must appear in the page (redirect URI display)
page_text = response.text
assert "configured.example.com/dropbox-callback" in page_text