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:
@@ -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
|
||||
|
||||
@@ -83,6 +83,8 @@ class TestGotenbergCoverageDocuments:
|
||||
".tif",
|
||||
".webp",
|
||||
".svg",
|
||||
".heic",
|
||||
".heif",
|
||||
}
|
||||
_html_extensions = {".html", ".htm"}
|
||||
_markdown_extensions = {".md", ".markdown"}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
):
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user