Merge branch 'main' into copilot/add-conditional-routing

Resolve conflicts in app/api/__init__.py and app/models.py.
Renumber migration 027_add_routing_rules → 035_add_routing_rules.
Fix migration chain: down_revision → 034_add_user_profile_settings.
Add PipelineRoutingRule to migrations/env.py.
This commit is contained in:
copilot-swe-agent[bot]
2026-03-12 22:07:54 +00:00
163 changed files with 45250 additions and 955 deletions
+2
View File
@@ -61,6 +61,8 @@ from app.main import app as fastapi_app # noqa: E402
# Import models to register them with SQLAlchemy Base
from app.models import ( # noqa: F401, E402
ApiToken,
AuditLog,
ComplianceTemplate,
DocumentMetadata,
FileRecord,
Pipeline,
+7 -2
View File
@@ -116,9 +116,13 @@ class TestProtectedAPIEndpoints:
}
}
# Mock DB: no UserProfile found (no custom avatar)
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
import asyncio
result = asyncio.run(whoami_handler(mock_request))
result = asyncio.run(whoami_handler(mock_request, mock_db))
assert result["id"] == "test123"
assert result["email"] == "test@example.com"
@@ -135,9 +139,10 @@ class TestProtectedAPIEndpoints:
mock_request = MagicMock()
mock_request.session = {}
mock_db = MagicMock()
with pytest.raises(HTTPException) as exc_info:
asyncio.run(whoami_handler(mock_request))
asyncio.run(whoami_handler(mock_request, mock_db))
assert exc_info.value.status_code == 401
assert "Not logged in" in exc_info.value.detail
+518
View File
@@ -0,0 +1,518 @@
"""Tests for the mobile API endpoints (app/api/mobile.py)."""
from unittest.mock import MagicMock, patch
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, MobileDevice
# ---------------------------------------------------------------------------
# Test data
# ---------------------------------------------------------------------------
_OWNER = "mobile_user@example.com"
_OTHER_OWNER = "other@example.com"
_EXPO_TOKEN = "ExponentPushToken[test-token-abc123]"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def mob_engine():
"""In-memory SQLite engine for mobile tests."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def mob_session(mob_engine):
"""DB session scoped to one test."""
Session = sessionmaker(bind=mob_engine)
session = Session()
yield session
session.close()
def _make_client(mob_engine, owner_id: str = _OWNER) -> TestClient:
"""Return a TestClient with *owner_id* injected as the authenticated user."""
from app.api.mobile import _get_owner_id
from app.main import app
Session = sessionmaker(bind=mob_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()
# ---------------------------------------------------------------------------
# Tests /mobile/generate-token
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGenerateMobileToken:
"""Tests for POST /api/mobile/generate-token."""
def test_generate_token_success(self, mob_engine):
"""Generating a mobile token returns a token string and metadata."""
from app.main import app
client = _make_client(mob_engine)
try:
resp = client.post(
"/api/mobile/generate-token",
json={"device_name": "John's iPhone"},
)
assert resp.status_code == 201
data = resp.json()
assert data["token"].startswith("de_")
assert data["token_id"] > 0
assert "Mobile App" in data["name"]
assert "John's iPhone" in data["name"]
assert "created_at" in data
finally:
_cleanup(app)
def test_generate_token_default_device_name(self, mob_engine):
"""A default device name is used if none is provided."""
from app.main import app
client = _make_client(mob_engine)
try:
resp = client.post("/api/mobile/generate-token", json={})
assert resp.status_code == 201
data = resp.json()
assert "Mobile App" in data["name"]
finally:
_cleanup(app)
def test_generate_token_persisted_in_db(self, mob_engine, mob_session):
"""The generated token is stored in the api_tokens table."""
from app.main import app
client = _make_client(mob_engine)
try:
resp = client.post(
"/api/mobile/generate-token",
json={"device_name": "Test Device"},
)
assert resp.status_code == 201
token_id = resp.json()["token_id"]
db_token = mob_session.get(ApiToken, token_id)
assert db_token is not None
assert db_token.owner_id == _OWNER
assert "Mobile App" in db_token.name
finally:
_cleanup(app)
def test_generate_token_unauthenticated(self, mob_engine):
"""Unauthenticated requests are rejected with 401."""
from app.api.mobile import _get_owner_id
from app.main import app
Session = sessionmaker(bind=mob_engine)
def _override_get_db():
session = Session()
try:
yield session
finally:
session.close()
def _raise_401():
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
app.dependency_overrides[get_db] = _override_get_db
app.dependency_overrides[_get_owner_id] = _raise_401
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
try:
resp = client.post("/api/mobile/generate-token", json={"device_name": "Test"})
assert resp.status_code == 401
finally:
_cleanup(app)
# ---------------------------------------------------------------------------
# Tests /mobile/register-device
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestRegisterDevice:
"""Tests for POST /api/mobile/register-device."""
def test_register_new_device(self, mob_engine, mob_session):
"""Registering a new device persists it in mobile_devices."""
from app.main import app
client = _make_client(mob_engine)
try:
resp = client.post(
"/api/mobile/register-device",
json={
"push_token": _EXPO_TOKEN,
"device_name": "Test iPhone",
"platform": "ios",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["id"] > 0
assert data["platform"] == "ios"
assert data["is_active"] is True
assert "ExponentPushToken" in data["push_token_preview"]
device = mob_session.get(MobileDevice, data["id"])
assert device is not None
assert device.push_token == _EXPO_TOKEN
assert device.owner_id == _OWNER
finally:
_cleanup(app)
def test_register_same_token_is_idempotent(self, mob_engine, mob_session):
"""Re-registering the same token reactivates the existing record."""
from app.main import app
client = _make_client(mob_engine)
try:
resp1 = client.post(
"/api/mobile/register-device",
json={"push_token": _EXPO_TOKEN, "platform": "ios"},
)
assert resp1.status_code == 201
id1 = resp1.json()["id"]
resp2 = client.post(
"/api/mobile/register-device",
json={"push_token": _EXPO_TOKEN, "device_name": "Updated Name", "platform": "ios"},
)
assert resp2.status_code == 201
id2 = resp2.json()["id"]
assert id1 == id2 # Same record reused
devices = mob_session.query(MobileDevice).filter(MobileDevice.owner_id == _OWNER).all()
assert len(devices) == 1
finally:
_cleanup(app)
def test_register_invalid_platform(self, mob_engine):
"""An invalid platform value is rejected with 422."""
from app.main import app
client = _make_client(mob_engine)
try:
resp = client.post(
"/api/mobile/register-device",
json={"push_token": _EXPO_TOKEN, "platform": "windows"},
)
assert resp.status_code == 422
finally:
_cleanup(app)
def test_register_android_device(self, mob_engine):
"""Android devices can be registered."""
from app.main import app
client = _make_client(mob_engine)
try:
resp = client.post(
"/api/mobile/register-device",
json={
"push_token": "ExponentPushToken[android-token-xyz]",
"device_name": "Pixel 8",
"platform": "android",
},
)
assert resp.status_code == 201
assert resp.json()["platform"] == "android"
finally:
_cleanup(app)
# ---------------------------------------------------------------------------
# Tests /mobile/devices
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestListDevices:
"""Tests for GET /api/mobile/devices."""
def test_list_devices_empty(self, mob_engine):
"""An empty list is returned when no devices are registered."""
from app.main import app
client = _make_client(mob_engine)
try:
resp = client.get("/api/mobile/devices")
assert resp.status_code == 200
assert resp.json() == []
finally:
_cleanup(app)
def test_list_devices_returns_own_devices_only(self, mob_engine, mob_session):
"""Only the current user's devices are returned."""
from app.main import app
# Add devices for two different owners directly
mob_session.add(
MobileDevice(
owner_id=_OWNER,
push_token="ExponentPushToken[owner-token-12345]",
platform="ios",
)
)
mob_session.add(
MobileDevice(
owner_id=_OTHER_OWNER,
push_token="ExponentPushToken[other-token-67890]",
platform="android",
)
)
mob_session.commit()
client = _make_client(mob_engine)
try:
resp = client.get("/api/mobile/devices")
assert resp.status_code == 200
devices = resp.json()
assert len(devices) == 1
# The push_token_preview is the first 20 chars + "…"
assert devices[0]["push_token_preview"].startswith("ExponentPushToken[ow")
finally:
_cleanup(app)
# ---------------------------------------------------------------------------
# Tests DELETE /mobile/devices/{device_id}
# ---------------------------------------------------------------------------
@pytest.mark.unit
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."""
from app.main import app
device = MobileDevice(
owner_id=_OWNER,
push_token=_EXPO_TOKEN,
platform="ios",
is_active=True,
)
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 == 204
mob_session.expire_all()
updated = mob_session.get(MobileDevice, device_id)
assert updated is not None
assert updated.is_active is False
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
device = MobileDevice(
owner_id=_OTHER_OWNER,
push_token="ExponentPushToken[other-token]",
platform="ios",
is_active=True,
)
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 == 404
finally:
_cleanup(app)
def test_deactivate_nonexistent_device_returns_404(self, mob_engine):
"""Deactivating a device that does not exist returns 404."""
from app.main import app
client = _make_client(mob_engine)
try:
resp = client.delete("/api/mobile/devices/99999")
assert resp.status_code == 404
finally:
_cleanup(app)
# ---------------------------------------------------------------------------
# Tests /mobile/whoami
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestWhoAmI:
"""Tests for GET /api/mobile/whoami."""
def test_whoami_with_no_profile(self, mob_engine):
"""Returns owner_id and inferred email even 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["owner_id"] == _OWNER
assert data["display_name"] is None
# _OWNER contains "@" so email is inferred from owner_id
assert data["email"] == _OWNER
assert data["avatar_url"] is not None # Gravatar URL from email
assert data["is_admin"] is False
finally:
_cleanup(app)
def test_whoami_with_profile(self, mob_engine, mob_session):
"""Returns full profile data when a UserProfile record exists."""
from app.main import app
from app.models import UserProfile
profile = UserProfile(
user_id=_OWNER,
display_name="Alice Test",
)
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["owner_id"] == _OWNER
assert data["display_name"] == "Alice Test"
# owner_id contains "@" so email is inferred from it
assert data["email"] == _OWNER
assert data["avatar_url"] is not None # Gravatar URL
assert data["is_admin"] is False
finally:
_cleanup(app)
# ---------------------------------------------------------------------------
# Tests push notification utility
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestPushNotificationUtility:
"""Tests for app/utils/push_notification.py."""
def test_send_expo_push_empty_tokens(self):
"""send_expo_push_notification with no tokens returns empty list."""
from app.utils.push_notification import send_expo_push_notification
result = send_expo_push_notification([], "Title", "Body")
assert result == []
def test_send_expo_push_calls_expo_api(self):
"""send_expo_push_notification POSTs to the Expo push API."""
from app.utils.push_notification import send_expo_push_notification
mock_response = MagicMock()
mock_response.json.return_value = {"data": [{"status": "ok"}]}
mock_response.raise_for_status = MagicMock()
with patch("app.utils.push_notification.httpx.post", return_value=mock_response) as mock_post:
result = send_expo_push_notification(
tokens=["ExponentPushToken[abc]"],
title="Test",
body="Message",
)
mock_post.assert_called_once()
call_kwargs = mock_post.call_args
assert "exp.host" in call_kwargs[0][0]
payload = call_kwargs[1]["json"]
assert len(payload) == 1
assert payload[0]["to"] == "ExponentPushToken[abc]"
assert payload[0]["title"] == "Test"
def test_send_push_to_owner_no_devices(self, mob_engine):
"""send_push_to_owner silently does nothing when no devices are registered."""
from app.utils.push_notification import send_push_to_owner
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.all.return_value = []
mock_session.close = MagicMock()
with patch("app.utils.push_notification.SessionLocal", return_value=mock_session):
with patch("app.utils.push_notification.send_expo_push_notification") as mock_send:
send_push_to_owner("user@example.com", "Title", "Body")
mock_send.assert_not_called()
mock_session.close.assert_called_once()
def test_send_push_to_owner_with_devices(self):
"""send_push_to_owner calls send_expo_push_notification with device tokens."""
from app.utils.push_notification import send_push_to_owner
fake_device = MagicMock()
fake_device.push_token = "ExponentPushToken[device1]"
fake_device.is_active = True
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.all.return_value = [fake_device]
mock_session.close = MagicMock()
with patch("app.utils.push_notification.SessionLocal", return_value=mock_session):
with patch("app.utils.push_notification.send_expo_push_notification") as mock_send:
mock_send.return_value = [{"status": "ok"}]
send_push_to_owner("user@example.com", "Processed!", "Your doc is ready.")
mock_send.assert_called_once()
call_kwargs = mock_send.call_args[1]
assert "ExponentPushToken[device1]" in call_kwargs["tokens"]
+509
View File
@@ -0,0 +1,509 @@
"""Tests for app/api/profile.py — user self-service profile API.
Unit tests call handler functions directly with mock request objects.
Integration tests use a dedicated TestClient with DB override.
"""
from __future__ import annotations
import base64
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import HTTPException
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 LocalUser, UserProfile
# ---------------------------------------------------------------------------
# Test data constants
# ---------------------------------------------------------------------------
# Minimal valid 1×1 PNG image (base64-encoded) used across avatar upload tests
_MINIMAL_VALID_PNG_BASE64 = (
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/Z+hHgAHggJ/PchI6QAAAABJRU5ErkJggg=="
)
_MINIMAL_VALID_PNG_BYTES = base64.b64decode(_MINIMAL_VALID_PNG_BASE64)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def prof_engine():
"""In-memory SQLite engine scoped to one test."""
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 prof_session(prof_engine):
"""DB session for one profile test."""
Session = sessionmaker(bind=prof_engine)
session = Session()
yield session
session.close()
@pytest.fixture()
def prof_client(prof_engine):
"""TestClient with the in-memory DB injected."""
from app.main import app
def override_db():
Session = sessionmaker(bind=prof_engine)
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = override_db
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as c:
yield c
app.dependency_overrides.pop(get_db, None)
# ---------------------------------------------------------------------------
# Unit tests — helper functions
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGravatarUrl:
"""Tests for the _gravatar_url helper."""
def test_returns_gravatar_for_valid_email(self):
from app.api.profile import _gravatar_url
url = _gravatar_url("Test@Example.COM")
assert url.startswith("https://www.gravatar.com/avatar/")
assert url.endswith("?d=identicon")
def test_fallback_for_none_email(self):
from app.api.profile import _gravatar_url
url = _gravatar_url(None)
assert url.startswith("https://www.gravatar.com/avatar/")
assert url.endswith("?d=identicon")
@pytest.mark.unit
class TestGetUserId:
"""Tests for the _get_user_id helper."""
def test_extracts_sub(self):
from app.api.profile import _get_user_id
req = MagicMock()
req.session = {"user": {"sub": "sub-123", "email": "a@b.com"}}
assert _get_user_id(req) == "sub-123"
def test_extracts_preferred_username_fallback(self):
from app.api.profile import _get_user_id
req = MagicMock()
req.session = {"user": {"preferred_username": "alice", "email": "a@b.com"}}
assert _get_user_id(req) == "alice"
def test_extracts_email_fallback(self):
from app.api.profile import _get_user_id
req = MagicMock()
req.session = {"user": {"email": "a@b.com"}}
assert _get_user_id(req) == "a@b.com"
def test_raises_401_when_no_session_user(self):
from app.api.profile import _get_user_id
req = MagicMock()
req.session = {}
with pytest.raises(HTTPException) as exc:
_get_user_id(req)
assert exc.value.status_code == 401
def test_raises_401_when_no_identifier(self):
from app.api.profile import _get_user_id
req = MagicMock()
req.session = {"user": {"name": "Someone"}}
with pytest.raises(HTTPException) as exc:
_get_user_id(req)
assert exc.value.status_code == 401
# ---------------------------------------------------------------------------
# Unit tests — GET /api/profile handler
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetProfileHandler:
"""Unit tests for the get_profile endpoint handler."""
@pytest.mark.asyncio
async def test_returns_profile_from_db(self, prof_session):
"""get_profile reads from DB and returns correct data."""
from app.api.profile import get_profile
profile = UserProfile(
user_id="alice",
display_name="Alice",
preferred_language="fr",
preferred_theme="dark",
)
prof_session.add(profile)
prof_session.commit()
req = MagicMock()
req.session = {"user": {"preferred_username": "alice", "email": "alice@example.com"}}
result = await get_profile(req, prof_session)
assert result.user_id == "alice"
assert result.display_name == "Alice"
assert result.preferred_language == "fr"
assert result.preferred_theme == "dark"
assert result.avatar_url.startswith("https://www.gravatar.com/avatar/")
@pytest.mark.asyncio
async def test_returns_custom_avatar_when_stored(self, prof_session):
"""get_profile returns the data: URI when avatar_data is set."""
from app.api.profile import get_profile
profile = UserProfile(
user_id="bob",
avatar_data="data:image/png;base64,abc",
)
prof_session.add(profile)
prof_session.commit()
req = MagicMock()
req.session = {"user": {"preferred_username": "bob", "email": "bob@example.com"}}
result = await get_profile(req, prof_session)
assert result.avatar_url == "data:image/png;base64,abc"
@pytest.mark.asyncio
async def test_creates_profile_if_missing(self, prof_session):
"""get_profile creates a stub profile row when none exists."""
from app.api.profile import get_profile
req = MagicMock()
req.session = {"user": {"preferred_username": "newbie", "email": "newbie@example.com"}}
result = await get_profile(req, prof_session)
assert result.user_id == "newbie"
row = prof_session.query(UserProfile).filter_by(user_id="newbie").first()
assert row is not None
# ---------------------------------------------------------------------------
# Unit tests — PATCH /api/profile handler
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestUpdateProfileHandler:
"""Unit tests for the update_profile endpoint handler."""
@pytest.mark.asyncio
async def test_updates_display_name(self, prof_session):
"""update_profile updates display_name."""
from app.api.profile import ProfileUpdateRequest, update_profile
req = MagicMock()
req.session = {"user": {"preferred_username": "carol", "email": "carol@example.com"}}
body = ProfileUpdateRequest(display_name="Carol Smith")
result = await update_profile(body, req, prof_session)
assert result.display_name == "Carol Smith"
@pytest.mark.asyncio
async def test_updates_language(self, prof_session):
"""update_profile updates preferred_language."""
from app.api.profile import ProfileUpdateRequest, update_profile
req = MagicMock()
req.session = {"user": {"preferred_username": "dave", "email": "dave@example.com"}}
body = ProfileUpdateRequest(preferred_language="de")
result = await update_profile(body, req, prof_session)
assert result.preferred_language == "de"
@pytest.mark.asyncio
async def test_updates_theme(self, prof_session):
"""update_profile updates preferred_theme."""
from app.api.profile import ProfileUpdateRequest, update_profile
req = MagicMock()
req.session = {"user": {"preferred_username": "eve", "email": "eve@example.com"}}
body = ProfileUpdateRequest(preferred_theme="light")
result = await update_profile(body, req, prof_session)
assert result.preferred_theme == "light"
@pytest.mark.asyncio
async def test_rejects_invalid_language(self, prof_session):
"""update_profile raises 422 for unsupported language code."""
from app.api.profile import ProfileUpdateRequest, update_profile
req = MagicMock()
req.session = {"user": {"preferred_username": "frank", "email": "frank@example.com"}}
body = ProfileUpdateRequest(preferred_language="xx")
with pytest.raises(HTTPException) as exc:
await update_profile(body, req, prof_session)
assert exc.value.status_code == 422
@pytest.mark.asyncio
async def test_rejects_invalid_theme(self, prof_session):
"""update_profile raises 422 for invalid theme value."""
from app.api.profile import ProfileUpdateRequest, update_profile
req = MagicMock()
req.session = {"user": {"preferred_username": "grace", "email": "grace@example.com"}}
body = ProfileUpdateRequest(preferred_theme="rainbow")
with pytest.raises(HTTPException) as exc:
await update_profile(body, req, prof_session)
assert exc.value.status_code == 422
# ---------------------------------------------------------------------------
# Unit tests — POST /api/profile/avatar handler
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestUploadAvatarHandler:
"""Unit tests for the upload_avatar endpoint handler."""
@pytest.mark.asyncio
async def test_stores_base64_data_url(self, prof_session):
"""upload_avatar stores the image as a data: URI."""
from app.api.profile import upload_avatar
upload = MagicMock()
upload.content_type = "image/png"
upload.size = len(_MINIMAL_VALID_PNG_BYTES)
upload.read = AsyncMock(return_value=_MINIMAL_VALID_PNG_BYTES)
req = MagicMock()
req.session = {"user": {"preferred_username": "avataruser", "email": "av@example.com"}}
result = await upload_avatar(req, prof_session, upload)
assert result["avatar_url"].startswith("data:image/png;base64,")
@pytest.mark.asyncio
async def test_rejects_unsupported_mime(self, prof_session):
"""upload_avatar raises 415 for non-image content types."""
from app.api.profile import upload_avatar
upload = MagicMock()
upload.content_type = "application/pdf"
upload.size = 4
upload.read = AsyncMock(return_value=b"%PDF")
req = MagicMock()
req.session = {"user": {"preferred_username": "pdfuser", "email": "pdf@example.com"}}
with pytest.raises(HTTPException) as exc:
await upload_avatar(req, prof_session, upload)
assert exc.value.status_code == 415
@pytest.mark.asyncio
async def test_rejects_oversized_image(self, prof_session):
"""upload_avatar raises 413 when image exceeds 2 MB."""
from app.api.profile import upload_avatar
big_size = 2 * 1024 * 1024 + 1
upload = MagicMock()
upload.content_type = "image/png"
upload.size = big_size # triggers early size check
upload.read = AsyncMock(return_value=b"x" * big_size)
req = MagicMock()
req.session = {"user": {"preferred_username": "biguser", "email": "big@example.com"}}
with pytest.raises(HTTPException) as exc:
await upload_avatar(req, prof_session, upload)
assert exc.value.status_code == 413
# ---------------------------------------------------------------------------
# Unit tests — DELETE /api/profile/avatar handler
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDeleteAvatarHandler:
"""Unit tests for the delete_avatar endpoint handler."""
@pytest.mark.asyncio
async def test_clears_avatar_data(self, prof_session):
"""delete_avatar removes avatar_data and returns a Gravatar URL."""
from app.api.profile import delete_avatar
profile = UserProfile(
user_id="delavatar",
avatar_data="data:image/png;base64,abc",
)
prof_session.add(profile)
prof_session.commit()
req = MagicMock()
req.session = {"user": {"preferred_username": "delavatar", "email": "del@example.com"}}
result = await delete_avatar(req, prof_session)
assert result["avatar_url"].startswith("https://www.gravatar.com/avatar/")
row = prof_session.query(UserProfile).filter_by(user_id="delavatar").first()
assert row.avatar_data is None
# ---------------------------------------------------------------------------
# Unit tests — POST /api/profile/change-password handler
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestChangePasswordHandler:
"""Unit tests for the change_password endpoint handler."""
@pytest.mark.asyncio
async def test_rejects_non_local_user(self, prof_session):
"""change_password raises 403 for OAuth-only accounts."""
from app.api.profile import ChangePasswordRequest, change_password
req = MagicMock()
req.session = {"user": {"preferred_username": "oauthonly", "email": "oauth@example.com"}}
body = ChangePasswordRequest(
current_password="old",
new_password="newpassword1",
new_password_confirm="newpassword1",
)
with pytest.raises(HTTPException) as exc:
await change_password(body, req, prof_session)
assert exc.value.status_code == 403
@pytest.mark.asyncio
async def test_rejects_wrong_current_password(self, prof_session):
"""change_password raises 403 when current password is wrong."""
from app.api.profile import ChangePasswordRequest, change_password
from app.utils.local_auth import hash_password
local_user = LocalUser(
email="local@example.com",
username="localwrong",
hashed_password=hash_password("correctpassword"),
is_active=True,
)
prof_session.add(local_user)
prof_session.commit()
req = MagicMock()
req.session = {"user": {"preferred_username": "localwrong", "email": "local@example.com"}}
body = ChangePasswordRequest(
current_password="wrongpassword",
new_password="newpassword1",
new_password_confirm="newpassword1",
)
with pytest.raises(HTTPException) as exc:
await change_password(body, req, prof_session)
assert exc.value.status_code == 403
@pytest.mark.asyncio
async def test_rejects_password_mismatch(self, prof_session):
"""change_password raises 422 when new passwords do not match."""
from app.api.profile import ChangePasswordRequest, change_password
from app.utils.local_auth import hash_password
local_user = LocalUser(
email="mismatch@example.com",
username="mismatchpw",
hashed_password=hash_password("currentpw"),
is_active=True,
)
prof_session.add(local_user)
prof_session.commit()
req = MagicMock()
req.session = {"user": {"preferred_username": "mismatchpw", "email": "mismatch@example.com"}}
body = ChangePasswordRequest(
current_password="currentpw",
new_password="newpassword1",
new_password_confirm="differentpassword",
)
with pytest.raises(HTTPException) as exc:
await change_password(body, req, prof_session)
assert exc.value.status_code == 422
@pytest.mark.asyncio
async def test_changes_password_successfully(self, prof_session):
"""change_password updates hashed_password for correct input."""
from app.api.profile import ChangePasswordRequest, change_password
from app.utils.local_auth import hash_password, verify_password
local_user = LocalUser(
email="success@example.com",
username="successpw",
hashed_password=hash_password("oldpassword"),
is_active=True,
)
prof_session.add(local_user)
prof_session.commit()
req = MagicMock()
req.session = {"user": {"preferred_username": "successpw", "email": "success@example.com"}}
body = ChangePasswordRequest(
current_password="oldpassword",
new_password="newpassword1",
new_password_confirm="newpassword1",
)
result = await change_password(body, req, prof_session)
assert "successfully" in result["detail"].lower()
updated_user = prof_session.query(LocalUser).filter_by(username="successpw").first()
assert verify_password("newpassword1", updated_user.hashed_password)
# ---------------------------------------------------------------------------
# Integration tests — HTTP endpoint registration
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestProfileEndpoints:
"""Verify profile endpoints are registered and reachable."""
def test_get_profile_without_session_returns_401(self, prof_client):
"""GET /api/profile returns 401 when no user in session."""
response = prof_client.get("/api/profile")
assert response.status_code == 401
def test_patch_profile_without_session_returns_401(self, prof_client):
"""PATCH /api/profile returns 401 when no user in session."""
response = prof_client.patch("/api/profile", json={"display_name": "Test"})
assert response.status_code == 401
def test_profile_page_accessible(self, prof_client):
"""GET /profile page renders successfully (auth disabled in tests)."""
response = prof_client.get("/profile", follow_redirects=False)
# AUTH_ENABLED=False in tests so no redirect; page should render
assert response.status_code in (200, 302)
+41 -5
View File
@@ -14,18 +14,38 @@ class TestWhoamiHandler:
@pytest.mark.asyncio
async def test_returns_user_with_gravatar(self):
"""Test that handler returns user data with gravatar URL."""
"""Test that handler returns user data with gravatar URL when no custom avatar."""
mock_request = MagicMock()
email = "test@example.com"
mock_request.session = {"user": {"id": "1", "email": email, "name": "Test"}}
result = await whoami_handler(mock_request)
# Mock DB: no UserProfile found (no custom avatar)
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
result = await whoami_handler(mock_request, mock_db)
assert result["id"] == "1"
assert result["name"] == "Test"
# Should have gravatar URL
# Should have gravatar URL since no custom avatar
expected_hash = md5(email.encode(), usedforsecurity=False).hexdigest()
assert result["picture"] == f"https://www.gravatar.com/avatar/{expected_hash}?d=identicon"
@pytest.mark.asyncio
async def test_returns_custom_avatar_when_set(self):
"""Test that handler returns custom avatar URL when profile has avatar_data."""
mock_request = MagicMock()
email = "test@example.com"
mock_request.session = {"user": {"id": "1", "email": email, "name": "Test"}}
# Mock DB: UserProfile with avatar_data
mock_profile = MagicMock()
mock_profile.avatar_data = "data:image/png;base64,abc123"
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = mock_profile
result = await whoami_handler(mock_request, mock_db)
assert result["picture"] == "data:image/png;base64,abc123"
@pytest.mark.asyncio
async def test_raises_401_when_no_user(self):
"""Test that 401 is raised when no user in session."""
@@ -33,9 +53,10 @@ class TestWhoamiHandler:
mock_request = MagicMock()
mock_request.session = {}
mock_db = MagicMock()
with pytest.raises(HTTPException) as exc_info:
await whoami_handler(mock_request)
await whoami_handler(mock_request, mock_db)
assert exc_info.value.status_code == 401
@pytest.mark.asyncio
@@ -45,11 +66,26 @@ class TestWhoamiHandler:
mock_request = MagicMock()
mock_request.session = {"user": {"id": "1", "name": "Test"}}
mock_db = MagicMock()
with pytest.raises(HTTPException) as exc_info:
await whoami_handler(mock_request)
await whoami_handler(mock_request, mock_db)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_falls_back_to_gravatar_on_db_error(self):
"""Test that gravatar is used when DB lookup raises an exception."""
mock_request = MagicMock()
email = "test@example.com"
mock_request.session = {"user": {"id": "1", "email": email, "name": "Test"}}
mock_db = MagicMock()
mock_db.query.side_effect = Exception("DB error")
result = await whoami_handler(mock_request, mock_db)
expected_hash = md5(email.encode(), usedforsecurity=False).hexdigest()
assert result["picture"] == f"https://www.gravatar.com/avatar/{expected_hash}?d=identicon"
@pytest.mark.integration
class TestWhoamiEndpoints:
+657
View File
@@ -0,0 +1,657 @@
"""
Tests for the comprehensive audit logging feature.
Covers the audit service (recording, querying, SIEM forwarding),
the REST API endpoints, and the admin viewer page.
"""
import json
import socket
from datetime import datetime, timezone
from unittest.mock import MagicMock, 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 AuditLog
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def audit_db():
"""Fresh in-memory database with all tables created."""
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()
yield session
session.close()
Base.metadata.drop_all(bind=engine)
# ---------------------------------------------------------------------------
# Model tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestAuditLogModel:
"""Verify the AuditLog ORM model."""
def test_create_minimal_entry(self, audit_db):
"""Minimal required fields can be persisted."""
entry = AuditLog(user="alice", action="login")
audit_db.add(entry)
audit_db.commit()
audit_db.refresh(entry)
assert entry.id is not None
assert entry.user == "alice"
assert entry.action == "login"
assert entry.severity == "info" # server default
def test_create_full_entry(self, audit_db):
"""All columns persist correctly."""
entry = AuditLog(
user="bob",
action="document.create",
resource_type="document",
resource_id="42",
ip_address="10.0.0.1",
details='{"filename": "invoice.pdf"}',
severity="warning",
)
audit_db.add(entry)
audit_db.commit()
audit_db.refresh(entry)
assert entry.resource_type == "document"
assert entry.resource_id == "42"
assert entry.ip_address == "10.0.0.1"
assert json.loads(entry.details) == {"filename": "invoice.pdf"}
assert entry.severity == "warning"
# ---------------------------------------------------------------------------
# Service tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestAuditService:
"""Verify the audit_service helper functions."""
@patch("app.utils.audit_service.settings")
def test_record_event(self, mock_settings, audit_db):
"""record_event persists a row and returns the entry."""
mock_settings.audit_siem_enabled = False
from app.utils.audit_service import record_event
entry = record_event(
audit_db,
action="settings.update",
user="admin",
resource_type="settings",
resource_id="openai_model",
details={"old": "gpt-4", "new": "gpt-4o"},
)
assert entry.id is not None
assert entry.action == "settings.update"
assert entry.user == "admin"
@patch("app.utils.audit_service.settings")
def test_query_events_no_filter(self, mock_settings, audit_db):
"""query_events returns all events when no filter is supplied."""
mock_settings.audit_siem_enabled = False
from app.utils.audit_service import query_events, record_event
for i in range(5):
record_event(audit_db, action=f"action_{i}", user="sys")
results = query_events(audit_db)
assert len(results) == 5
@patch("app.utils.audit_service.settings")
def test_query_events_filter_action(self, mock_settings, audit_db):
"""query_events filters by action."""
mock_settings.audit_siem_enabled = False
from app.utils.audit_service import query_events, record_event
record_event(audit_db, action="login", user="alice")
record_event(audit_db, action="logout", user="alice")
results = query_events(audit_db, action="login")
assert len(results) == 1
assert results[0].action == "login"
@patch("app.utils.audit_service.settings")
def test_query_events_filter_user(self, mock_settings, audit_db):
"""query_events filters by user."""
mock_settings.audit_siem_enabled = False
from app.utils.audit_service import query_events, record_event
record_event(audit_db, action="login", user="alice")
record_event(audit_db, action="login", user="bob")
results = query_events(audit_db, user="bob")
assert len(results) == 1
@patch("app.utils.audit_service.settings")
def test_query_events_filter_severity(self, mock_settings, audit_db):
"""query_events filters by severity."""
mock_settings.audit_siem_enabled = False
from app.utils.audit_service import query_events, record_event
record_event(audit_db, action="fail", user="sys", severity="error")
record_event(audit_db, action="ok", user="sys", severity="info")
results = query_events(audit_db, severity="error")
assert len(results) == 1
assert results[0].severity == "error"
@patch("app.utils.audit_service.settings")
def test_count_events(self, mock_settings, audit_db):
"""count_events returns the correct total."""
mock_settings.audit_siem_enabled = False
from app.utils.audit_service import count_events, record_event
for _ in range(3):
record_event(audit_db, action="ping", user="sys")
assert count_events(audit_db) == 3
assert count_events(audit_db, action="ping") == 3
assert count_events(audit_db, action="pong") == 0
@patch("app.utils.audit_service.settings")
def test_query_events_pagination(self, mock_settings, audit_db):
"""query_events respects limit and offset."""
mock_settings.audit_siem_enabled = False
from app.utils.audit_service import query_events, record_event
for i in range(10):
record_event(audit_db, action=f"a{i}", user="sys")
page1 = query_events(audit_db, limit=3, offset=0)
page2 = query_events(audit_db, limit=3, offset=3)
assert len(page1) == 3
assert len(page2) == 3
assert page1[0].id != page2[0].id
@patch("app.utils.audit_service.settings")
def test_query_events_filter_resource_type(self, mock_settings, audit_db):
"""query_events filters by resource_type."""
mock_settings.audit_siem_enabled = False
from app.utils.audit_service import query_events, record_event
record_event(audit_db, action="create", user="sys", resource_type="document")
record_event(audit_db, action="create", user="sys", resource_type="user")
results = query_events(audit_db, resource_type="document")
assert len(results) == 1
assert results[0].resource_type == "document"
@patch("app.utils.audit_service.settings")
def test_query_events_filter_since_and_until(self, mock_settings, audit_db):
"""query_events filters by since and until timestamps."""
mock_settings.audit_siem_enabled = False
from app.utils.audit_service import query_events
# Insert two events directly with distinct timestamps (naive, as SQLite stores them)
early = AuditLog(user="sys", action="early", severity="info", timestamp=datetime(2020, 1, 1))
late = AuditLog(user="sys", action="late", severity="info", timestamp=datetime(2025, 1, 1))
audit_db.add(early)
audit_db.add(late)
audit_db.commit()
since_ts = datetime(2022, 1, 1)
results = query_events(audit_db, since=since_ts)
assert all(r.timestamp >= since_ts for r in results)
assert any(r.action == "late" for r in results)
assert not any(r.action == "early" for r in results)
until_ts = datetime(2022, 1, 1)
results = query_events(audit_db, until=until_ts)
assert all(r.timestamp <= until_ts for r in results)
assert any(r.action == "early" for r in results)
@patch("app.utils.audit_service.settings")
def test_count_events_filters(self, mock_settings, audit_db):
"""count_events filters by user, resource_type, severity, since, and until."""
mock_settings.audit_siem_enabled = False
from app.utils.audit_service import count_events, record_event
record_event(audit_db, action="a", user="alice", resource_type="doc", severity="info")
record_event(audit_db, action="b", user="bob", resource_type="user", severity="error")
assert count_events(audit_db, user="alice") == 1
assert count_events(audit_db, resource_type="doc") == 1
assert count_events(audit_db, severity="error") == 1
early = AuditLog(user="sys", action="early", severity="info", timestamp=datetime(2020, 1, 1))
late = AuditLog(user="sys", action="late", severity="info", timestamp=datetime(2025, 1, 1))
audit_db.add(early)
audit_db.add(late)
audit_db.commit()
since_ts = datetime(2022, 1, 1)
assert count_events(audit_db, since=since_ts) >= 1
until_ts = datetime(2022, 1, 1)
assert count_events(audit_db, until=until_ts) >= 1
@patch("app.utils.audit_service._forward_to_siem")
@patch("app.utils.audit_service.settings")
def test_record_event_siem_enabled(self, mock_settings, mock_forward, audit_db):
"""record_event starts SIEM forwarding thread when siem is enabled."""
mock_settings.audit_siem_enabled = True
from app.utils.audit_service import record_event
entry = record_event(audit_db, action="login", user="alice")
assert entry.id is not None
mock_forward.assert_called_once()
@patch("app.utils.audit_service.settings")
def test_record_event_from_request(self, mock_settings, audit_db):
"""record_event_from_request extracts user and IP from the request."""
mock_settings.audit_siem_enabled = False
from app.utils.audit_service import record_event_from_request
mock_request = MagicMock()
mock_request.session = {"user": {"preferred_username": "carol"}}
mock_request.headers = {"X-Forwarded-For": "192.168.1.1"}
mock_request.client = MagicMock()
mock_request.client.host = "192.168.1.1"
with (
patch("app.utils.audit_service.get_username", return_value="carol"),
patch("app.utils.audit_service.get_client_ip", return_value="192.168.1.1"),
):
entry = record_event_from_request(
audit_db,
mock_request,
action="document.view",
resource_type="document",
resource_id="99",
)
assert entry.user == "carol"
assert entry.ip_address == "192.168.1.1"
assert entry.action == "document.view"
# ---------------------------------------------------------------------------
# SIEM forwarding tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSIEMForwarding:
"""Verify SIEM transport helpers."""
@patch("app.utils.audit_service.settings")
def test_build_siem_payload(self, mock_settings):
"""_build_siem_payload returns a dict with all expected keys."""
from app.utils.audit_service import _build_siem_payload
entry = AuditLog(
id=1,
user="admin",
action="login",
resource_type="session",
timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc),
severity="info",
)
payload = _build_siem_payload(entry)
assert payload["user"] == "admin"
assert payload["action"] == "login"
assert payload["source"] == "docuelevate"
assert "timestamp" in payload
@patch("app.utils.audit_service.settings")
@patch("app.utils.audit_service.socket")
def test_send_syslog_udp(self, mock_socket_mod, mock_settings):
"""_send_syslog sends a UDP datagram to the configured host."""
mock_settings.audit_siem_syslog_protocol = "udp"
mock_settings.audit_siem_syslog_host = "127.0.0.1"
mock_settings.audit_siem_syslog_port = 5140
mock_sock = MagicMock()
mock_socket_mod.AF_INET = socket.AF_INET
mock_socket_mod.SOCK_DGRAM = socket.SOCK_DGRAM
mock_socket_mod.gethostname.return_value = "test-host"
mock_socket_mod.socket.return_value.__enter__ = MagicMock(return_value=mock_sock)
mock_socket_mod.socket.return_value.__exit__ = MagicMock(return_value=False)
from app.utils.audit_service import _send_syslog
_send_syslog({"user": "test", "action": "login", "severity": "info", "timestamp": "2026-01-01T00:00:00"})
mock_sock.sendto.assert_called_once()
@patch("app.utils.audit_service.settings")
@patch("app.utils.audit_service.httpx")
def test_send_http_generic(self, mock_httpx, mock_settings):
"""_send_http POSTs JSON to a generic endpoint."""
mock_settings.audit_siem_http_url = "https://siem.example.com/ingest"
mock_settings.audit_siem_http_token = "my-token"
mock_settings.audit_siem_http_custom_headers = ""
mock_client = MagicMock()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_client.post.return_value = mock_resp
mock_httpx.Client.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_httpx.Client.return_value.__exit__ = MagicMock(return_value=False)
from app.utils.audit_service import _send_http
_send_http({"user": "test", "action": "login"})
mock_client.post.assert_called_once()
call_kwargs = mock_client.post.call_args
assert call_kwargs.kwargs["headers"]["Authorization"] == "Bearer my-token"
@patch("app.utils.audit_service.settings")
@patch("app.utils.audit_service.httpx")
def test_send_http_splunk_hec(self, mock_httpx, mock_settings):
"""_send_http wraps payload in Splunk HEC envelope when URL contains /services/collector."""
mock_settings.audit_siem_http_url = "https://splunk:8088/services/collector/event"
mock_settings.audit_siem_http_token = "hec-token"
mock_settings.audit_siem_http_custom_headers = ""
mock_client = MagicMock()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_client.post.return_value = mock_resp
mock_httpx.Client.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_httpx.Client.return_value.__exit__ = MagicMock(return_value=False)
from app.utils.audit_service import _send_http
_send_http({"user": "test", "action": "login"})
call_kwargs = mock_client.post.call_args
body = call_kwargs.kwargs["json"]
assert "event" in body
assert body["sourcetype"] == "docuelevate:audit"
@patch("app.utils.audit_service.settings")
@patch("app.utils.audit_service._send_syslog")
def test_forward_to_siem_syslog(self, mock_send_syslog, mock_settings):
"""_forward_to_siem routes to _send_syslog when transport is syslog."""
mock_settings.audit_siem_transport = "syslog"
from app.utils.audit_service import _forward_to_siem
payload = {"user": "test", "action": "login", "severity": "info"}
_forward_to_siem(payload)
mock_send_syslog.assert_called_once_with(payload)
@patch("app.utils.audit_service.settings")
@patch("app.utils.audit_service._send_http")
def test_forward_to_siem_http(self, mock_send_http, mock_settings):
"""_forward_to_siem routes to _send_http when transport is http."""
mock_settings.audit_siem_transport = "http"
from app.utils.audit_service import _forward_to_siem
payload = {"user": "test", "action": "login", "severity": "info"}
_forward_to_siem(payload)
mock_send_http.assert_called_once_with(payload)
@patch("app.utils.audit_service.settings")
def test_forward_to_siem_unknown_transport(self, mock_settings):
"""_forward_to_siem logs a warning for an unknown transport."""
mock_settings.audit_siem_transport = "unknown_proto"
from app.utils.audit_service import _forward_to_siem
# Should not raise; just log a warning
_forward_to_siem({"user": "test", "action": "login"})
@patch("app.utils.audit_service.settings")
def test_forward_to_siem_exception_is_caught(self, mock_settings):
"""_forward_to_siem catches exceptions from transports and logs them."""
mock_settings.audit_siem_transport = "syslog"
from app.utils.audit_service import _forward_to_siem
with patch("app.utils.audit_service._send_syslog", side_effect=OSError("network error")):
# Must not propagate
_forward_to_siem({"user": "test", "action": "login"})
@patch("app.utils.audit_service.settings")
@patch("app.utils.audit_service.socket")
def test_send_syslog_tcp(self, mock_socket_mod, mock_settings):
"""_send_syslog opens a TCP stream socket when protocol is tcp."""
mock_settings.audit_siem_syslog_protocol = "tcp"
mock_settings.audit_siem_syslog_host = "127.0.0.1"
mock_settings.audit_siem_syslog_port = 601
mock_sock = MagicMock()
mock_socket_mod.AF_INET = socket.AF_INET
mock_socket_mod.SOCK_STREAM = socket.SOCK_STREAM
mock_socket_mod.gethostname.return_value = "test-host"
mock_socket_mod.socket.return_value.__enter__ = MagicMock(return_value=mock_sock)
mock_socket_mod.socket.return_value.__exit__ = MagicMock(return_value=False)
from app.utils.audit_service import _send_syslog
_send_syslog({"user": "test", "action": "login", "severity": "info", "timestamp": "2026-01-01T00:00:00"})
mock_sock.connect.assert_called_once()
mock_sock.sendall.assert_called_once()
@patch("app.utils.audit_service.settings")
@patch("app.utils.audit_service.httpx")
def test_send_http_no_url(self, mock_httpx, mock_settings):
"""_send_http returns early and logs a warning when no URL is configured."""
mock_settings.audit_siem_http_url = ""
from app.utils.audit_service import _send_http
_send_http({"user": "test", "action": "login"})
mock_httpx.Client.assert_not_called()
@patch("app.utils.audit_service.settings")
@patch("app.utils.audit_service.httpx")
def test_send_http_no_token(self, mock_httpx, mock_settings):
"""_send_http omits Authorization header when no token is configured."""
mock_settings.audit_siem_http_url = "https://siem.example.com/ingest"
mock_settings.audit_siem_http_token = ""
mock_settings.audit_siem_http_custom_headers = ""
mock_client = MagicMock()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_client.post.return_value = mock_resp
mock_httpx.Client.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_httpx.Client.return_value.__exit__ = MagicMock(return_value=False)
from app.utils.audit_service import _send_http
_send_http({"user": "test", "action": "login"})
call_kwargs = mock_client.post.call_args
assert "Authorization" not in call_kwargs.kwargs["headers"]
@patch("app.utils.audit_service.settings")
@patch("app.utils.audit_service.httpx")
def test_send_http_custom_headers_valid(self, mock_httpx, mock_settings):
"""_send_http adds valid custom headers."""
mock_settings.audit_siem_http_url = "https://siem.example.com/ingest"
mock_settings.audit_siem_http_token = ""
mock_settings.audit_siem_http_custom_headers = "X-Tenant-ID: acme, X-Source: audit"
mock_client = MagicMock()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_client.post.return_value = mock_resp
mock_httpx.Client.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_httpx.Client.return_value.__exit__ = MagicMock(return_value=False)
from app.utils.audit_service import _send_http
_send_http({"user": "test", "action": "login"})
call_kwargs = mock_client.post.call_args
headers = call_kwargs.kwargs["headers"]
assert headers.get("X-Tenant-ID") == "acme"
assert headers.get("X-Source") == "audit"
@patch("app.utils.audit_service.settings")
@patch("app.utils.audit_service.httpx")
def test_send_http_custom_headers_invalid_name(self, mock_httpx, mock_settings):
"""_send_http skips custom headers with invalid names."""
mock_settings.audit_siem_http_url = "https://siem.example.com/ingest"
mock_settings.audit_siem_http_token = ""
mock_settings.audit_siem_http_custom_headers = "Bad Header!: value"
mock_client = MagicMock()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_client.post.return_value = mock_resp
mock_httpx.Client.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_httpx.Client.return_value.__exit__ = MagicMock(return_value=False)
from app.utils.audit_service import _send_http
_send_http({"user": "test", "action": "login"})
call_kwargs = mock_client.post.call_args
headers = call_kwargs.kwargs["headers"]
assert "Bad Header!" not in headers
@patch("app.utils.audit_service.settings")
@patch("app.utils.audit_service.httpx")
def test_send_http_custom_headers_protected_name(self, mock_httpx, mock_settings):
"""_send_http skips custom headers that match protected names."""
mock_settings.audit_siem_http_url = "https://siem.example.com/ingest"
mock_settings.audit_siem_http_token = ""
mock_settings.audit_siem_http_custom_headers = "Authorization: evil-token"
mock_client = MagicMock()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_client.post.return_value = mock_resp
mock_httpx.Client.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_httpx.Client.return_value.__exit__ = MagicMock(return_value=False)
from app.utils.audit_service import _send_http
_send_http({"user": "test", "action": "login"})
call_kwargs = mock_client.post.call_args
headers = call_kwargs.kwargs["headers"]
# Authorization should not have been overwritten by the custom header
assert headers.get("Authorization") != "evil-token"
@patch("app.utils.audit_service.settings")
@patch("app.utils.audit_service.httpx")
def test_send_http_custom_headers_no_colon(self, mock_httpx, mock_settings):
"""_send_http ignores custom header entries that contain no colon separator."""
mock_settings.audit_siem_http_url = "https://siem.example.com/ingest"
mock_settings.audit_siem_http_token = ""
mock_settings.audit_siem_http_custom_headers = "MalformedHeader"
mock_client = MagicMock()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_client.post.return_value = mock_resp
mock_httpx.Client.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_httpx.Client.return_value.__exit__ = MagicMock(return_value=False)
from app.utils.audit_service import _send_http
# Should not raise; malformed entry is silently skipped
_send_http({"user": "test", "action": "login"})
mock_client.post.assert_called_once()
@patch("app.utils.audit_service.settings")
def test_build_siem_payload_no_timestamp(self, mock_settings):
"""_build_siem_payload uses current UTC time when entry.timestamp is None."""
from app.utils.audit_service import _build_siem_payload
entry = AuditLog(user="admin", action="login", severity="info")
entry.timestamp = None # type: ignore[assignment]
payload = _build_siem_payload(entry)
assert "timestamp" in payload
# Should be a valid ISO timestamp string
datetime.fromisoformat(payload["timestamp"])
# ---------------------------------------------------------------------------
# API endpoint tests
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestAuditLogAPI:
"""Test /api/audit-logs REST endpoints."""
def test_list_audit_logs_empty(self, client):
"""GET /api/audit-logs returns empty list when no events exist."""
resp = client.get("/api/audit-logs")
assert resp.status_code == 200
data = resp.json()
assert data["items"] == []
assert data["total"] == 0
def test_list_audit_logs_with_data(self, client, db_session):
"""GET /api/audit-logs returns recorded events."""
entry = AuditLog(user="tester", action="test.action", severity="info")
db_session.add(entry)
db_session.commit()
resp = client.get("/api/audit-logs")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["action"] == "test.action"
def test_list_audit_logs_filter_by_action(self, client, db_session):
"""GET /api/audit-logs?action=x filters correctly."""
db_session.add(AuditLog(user="a", action="login", severity="info"))
db_session.add(AuditLog(user="a", action="logout", severity="info"))
db_session.commit()
resp = client.get("/api/audit-logs?action=login")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
def test_list_distinct_actions(self, client, db_session):
"""GET /api/audit-logs/actions returns distinct action values."""
db_session.add(AuditLog(user="a", action="login", severity="info"))
db_session.add(AuditLog(user="b", action="login", severity="info"))
db_session.add(AuditLog(user="a", action="logout", severity="info"))
db_session.commit()
resp = client.get("/api/audit-logs/actions")
assert resp.status_code == 200
actions = resp.json()
assert set(actions) == {"login", "logout"}
def test_list_distinct_users(self, client, db_session):
"""GET /api/audit-logs/users returns distinct user values."""
db_session.add(AuditLog(user="alice", action="x", severity="info"))
db_session.add(AuditLog(user="bob", action="x", severity="info"))
db_session.commit()
resp = client.get("/api/audit-logs/users")
assert resp.status_code == 200
users = resp.json()
assert set(users) == {"alice", "bob"}
def test_list_audit_logs_pagination(self, client, db_session):
"""GET /api/audit-logs supports limit/offset pagination."""
for i in range(5):
db_session.add(AuditLog(user="u", action=f"a{i}", severity="info"))
db_session.commit()
resp = client.get("/api/audit-logs?limit=2&offset=0")
data = resp.json()
assert len(data["items"]) == 2
assert data["total"] == 5
# ---------------------------------------------------------------------------
# View tests
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestAuditLogView:
"""Test the admin audit-log viewer page."""
def test_audit_logs_page_loads(self, client):
"""GET /admin/audit-logs returns 200 and renders the template."""
resp = client.get("/admin/audit-logs")
assert resp.status_code == 200
assert "Audit Logs" in resp.text
+502
View File
@@ -0,0 +1,502 @@
"""
Tests for the compliance templates feature.
Covers:
- app/models.py ComplianceTemplate model
- app/utils/compliance_service.py service functions (seed, evaluate, apply)
- app/api/compliance.py REST API endpoints
- app/views/compliance.py admin view route
"""
from unittest.mock import Mock, patch
import pytest
from fastapi import status
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models import ComplianceTemplate
from app.utils.compliance_service import (
COMPLIANCE_TEMPLATES,
apply_template,
evaluate_template_status,
get_all_templates,
get_compliance_summary,
get_template_by_name,
seed_compliance_templates,
)
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def ct_engine():
"""In-memory SQLite engine for compliance template tests."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def ct_session(ct_engine):
"""DB session scoped to one test."""
Session = sessionmaker(bind=ct_engine)
session = Session()
yield session
session.close()
@pytest.fixture()
def ct_client(ct_engine):
"""TestClient with in-memory DB and admin override."""
from app.api.compliance import _require_admin
from app.main import app
def override_db():
Session = sessionmaker(bind=ct_engine)
session = Session()
try:
yield session
finally:
session.close()
def override_admin():
return {"email": "admin@test.com", "is_admin": True}
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[_require_admin] = override_admin
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
yield client
app.dependency_overrides.clear()
@pytest.fixture()
def seeded_session(ct_session):
"""Session with compliance templates already seeded."""
seed_compliance_templates(ct_session)
return ct_session
@pytest.fixture()
def seeded_client(ct_engine):
"""TestClient with seeded compliance templates."""
from app.api.compliance import _require_admin
from app.main import app
Session = sessionmaker(bind=ct_engine)
session = Session()
seed_compliance_templates(session)
session.close()
def override_db():
session = Session()
try:
yield session
finally:
session.close()
def override_admin():
return {"email": "admin@test.com", "is_admin": True}
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[_require_admin] = override_admin
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
yield client
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Model tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestComplianceTemplateModel:
"""Tests for the ComplianceTemplate database model."""
def test_create_template(self, ct_session):
"""Test creating a compliance template."""
template = ComplianceTemplate(
name="test_template",
display_name="Test Template",
description="A test compliance template",
settings_json='{"auth_enabled": "True"}',
enabled=False,
status="not_applied",
)
ct_session.add(template)
ct_session.commit()
assert template.id is not None
assert template.name == "test_template"
assert template.display_name == "Test Template"
assert template.enabled is False
assert template.status == "not_applied"
def test_unique_name_constraint(self, ct_session):
"""Test that template names must be unique."""
t1 = ComplianceTemplate(
name="unique_test",
display_name="First",
settings_json="{}",
)
ct_session.add(t1)
ct_session.commit()
t2 = ComplianceTemplate(
name="unique_test",
display_name="Second",
settings_json="{}",
)
ct_session.add(t2)
with pytest.raises(IntegrityError):
ct_session.commit()
ct_session.rollback()
def test_default_values(self, ct_session):
"""Test default column values."""
template = ComplianceTemplate(
name="defaults_test",
display_name="Defaults",
settings_json="{}",
)
ct_session.add(template)
ct_session.commit()
assert template.enabled is False
assert template.status == "not_applied"
assert template.applied_at is None
assert template.applied_by is None
# ---------------------------------------------------------------------------
# Service tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestComplianceService:
"""Tests for compliance_service utility functions."""
def test_seed_creates_templates(self, ct_session):
"""Test that seeding creates all three compliance templates."""
seed_compliance_templates(ct_session)
templates = ct_session.query(ComplianceTemplate).all()
names = {t.name for t in templates}
assert "gdpr" in names
assert "hipaa" in names
assert "soc2" in names
assert len(templates) == 3
def test_seed_is_idempotent(self, ct_session):
"""Test that seeding twice does not create duplicates."""
seed_compliance_templates(ct_session)
seed_compliance_templates(ct_session)
templates = ct_session.query(ComplianceTemplate).all()
assert len(templates) == 3
def test_seed_updates_display_name(self, ct_session):
"""Test that re-seeding updates display_name but preserves state."""
seed_compliance_templates(ct_session)
gdpr = ct_session.query(ComplianceTemplate).filter_by(name="gdpr").first()
gdpr.enabled = True
ct_session.commit()
seed_compliance_templates(ct_session)
gdpr = ct_session.query(ComplianceTemplate).filter_by(name="gdpr").first()
assert gdpr.enabled is True # User state preserved
def test_get_all_templates(self, seeded_session):
"""Test getting all templates."""
result = get_all_templates(seeded_session)
assert len(result) == 3
for t in result:
assert "id" in t
assert "name" in t
assert "display_name" in t
assert "checks" in t
assert "check_count" in t
def test_get_template_by_name_exists(self, seeded_session):
"""Test retrieving an existing template by name."""
result = get_template_by_name(seeded_session, "gdpr")
assert result is not None
assert result.name == "gdpr"
def test_get_template_by_name_missing(self, seeded_session):
"""Test retrieving a non-existent template."""
result = get_template_by_name(seeded_session, "nonexistent")
assert result is None
@patch("app.utils.settings_service.get_all_settings_from_db")
def test_evaluate_template_compliant(self, mock_settings, seeded_session):
"""Test evaluation when all checks pass."""
mock_settings.return_value = {
"auth_enabled": "True",
"sentry_send_default_pii": "False",
"security_headers_enabled": "True",
"security_header_hsts_enabled": "True",
"security_header_csp_enabled": "True",
"security_header_x_frame_options_enabled": "True",
"enable_deduplication": "True",
}
result = evaluate_template_status(seeded_session, "gdpr")
assert result["status"] == "compliant"
assert result["passed"] == result["total"]
assert result["failed"] == 0
@patch("app.utils.settings_service.get_all_settings_from_db")
def test_evaluate_template_non_compliant(self, mock_settings, seeded_session):
"""Test evaluation when no checks pass."""
mock_settings.return_value = {}
with patch("app.config.settings") as mock_app:
mock_app.auth_enabled = False
mock_app.sentry_send_default_pii = True
mock_app.security_headers_enabled = False
mock_app.security_header_hsts_enabled = False
mock_app.security_header_csp_enabled = False
mock_app.security_header_x_frame_options_enabled = False
mock_app.enable_deduplication = False
result = evaluate_template_status(seeded_session, "gdpr")
assert result["status"] in ("non_compliant", "partial")
assert result["failed"] > 0
def test_evaluate_unknown_template(self, seeded_session):
"""Test evaluation of a non-existent template name."""
result = evaluate_template_status(seeded_session, "unknown")
assert result["status"] == "unknown"
assert result["total"] == 0
@patch("app.utils.settings_service.save_setting_to_db")
@patch("app.utils.settings_service.get_all_settings_from_db")
def test_apply_template_success(self, mock_get_settings, mock_save, seeded_session):
"""Test successfully applying a template."""
mock_save.return_value = True
mock_get_settings.return_value = {
"auth_enabled": "True",
"sentry_send_default_pii": "False",
"security_headers_enabled": "True",
"security_header_hsts_enabled": "True",
"security_header_csp_enabled": "True",
"security_header_x_frame_options_enabled": "True",
"enable_deduplication": "True",
}
result = apply_template(seeded_session, "gdpr", applied_by="test@admin.com")
assert result["success"] is True
assert result["template"] == "gdpr"
assert "applied_settings" in result
# Verify template record updated
gdpr = seeded_session.query(ComplianceTemplate).filter_by(name="gdpr").first()
assert gdpr.enabled is True
assert gdpr.applied_by == "test@admin.com"
assert gdpr.applied_at is not None
def test_apply_unknown_template(self, seeded_session):
"""Test applying a non-existent template."""
result = apply_template(seeded_session, "nonexistent")
assert result["success"] is False
assert "error" in result
@patch("app.utils.settings_service.get_all_settings_from_db")
def test_get_compliance_summary(self, mock_settings, seeded_session):
"""Test compliance summary across all templates."""
mock_settings.return_value = {}
result = get_compliance_summary(seeded_session)
assert "overall_status" in result
assert "total_checks" in result
assert "total_passed" in result
assert "total_failed" in result
assert "templates" in result
assert len(result["templates"]) == 3
def test_compliance_templates_have_checks(self):
"""Test that all built-in templates have compliance checks."""
for name, defn in COMPLIANCE_TEMPLATES.items():
assert "checks" in defn, f"Template {name} missing checks"
assert len(defn["checks"]) > 0, f"Template {name} has no checks"
for check in defn["checks"]:
assert "key" in check
assert "expected" in check
assert "label" in check
assert "description" in check
def test_compliance_templates_have_settings(self):
"""Test that all built-in templates have settings to apply."""
for name, defn in COMPLIANCE_TEMPLATES.items():
assert "settings" in defn, f"Template {name} missing settings"
assert len(defn["settings"]) > 0, f"Template {name} has no settings"
# ---------------------------------------------------------------------------
# API tests
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestComplianceAPI:
"""Tests for compliance API endpoints."""
def test_list_templates(self, seeded_client):
"""Test GET /api/compliance/templates."""
resp = seeded_client.get("/api/compliance/templates")
assert resp.status_code == status.HTTP_200_OK
data = resp.json()
assert isinstance(data, list)
assert len(data) == 3
names = {t["name"] for t in data}
assert names == {"gdpr", "hipaa", "soc2"}
def test_get_single_template(self, seeded_client):
"""Test GET /api/compliance/templates/gdpr."""
resp = seeded_client.get("/api/compliance/templates/gdpr")
assert resp.status_code == status.HTTP_200_OK
data = resp.json()
assert data["name"] == "gdpr"
assert "display_name" in data
assert "checks" in data
def test_get_nonexistent_template(self, seeded_client):
"""Test GET /api/compliance/templates/unknown returns 404."""
resp = seeded_client.get("/api/compliance/templates/unknown")
assert resp.status_code == status.HTTP_404_NOT_FOUND
def test_get_template_status(self, seeded_client):
"""Test GET /api/compliance/templates/gdpr/status."""
resp = seeded_client.get("/api/compliance/templates/gdpr/status")
assert resp.status_code == status.HTTP_200_OK
data = resp.json()
assert "status" in data
assert "total" in data
assert "passed" in data
assert "failed" in data
assert "check_results" in data
def test_apply_template(self, seeded_client):
"""Test POST /api/compliance/templates/gdpr/apply."""
resp = seeded_client.post("/api/compliance/templates/gdpr/apply")
assert resp.status_code == status.HTTP_200_OK
data = resp.json()
assert data["success"] is True
assert data["template"] == "gdpr"
assert "applied_settings" in data
def test_apply_nonexistent_template(self, seeded_client):
"""Test POST /api/compliance/templates/unknown/apply returns 404."""
resp = seeded_client.post("/api/compliance/templates/unknown/apply")
assert resp.status_code == status.HTTP_404_NOT_FOUND
def test_compliance_summary(self, seeded_client):
"""Test GET /api/compliance/summary."""
resp = seeded_client.get("/api/compliance/summary")
assert resp.status_code == status.HTTP_200_OK
data = resp.json()
assert "overall_status" in data
assert "total_checks" in data
assert "templates" in data
assert len(data["templates"]) == 3
def test_templates_require_admin(self, ct_engine):
"""Test that endpoints require admin access."""
from app.main import app
def override_db():
Session = sessionmaker(bind=ct_engine)
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = override_db
# Do NOT override _require_admin so it checks session
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
resp = client.get("/api/compliance/templates")
assert resp.status_code == status.HTTP_403_FORBIDDEN
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# View tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestComplianceView:
"""Tests for the compliance view route."""
@patch("app.views.compliance.templates")
@patch("app.views.compliance.settings")
@pytest.mark.asyncio
async def test_compliance_page_admin(self, mock_settings, mock_templates):
"""Test compliance page renders for admin users."""
from app.views.compliance import compliance_page
mock_settings.version = "1.0.0"
mock_request = Mock()
mock_request.session = {"user": {"id": "admin", "is_admin": True}}
await compliance_page(mock_request)
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
assert call_args[0][0] == "compliance.html"
@pytest.mark.asyncio
async def test_compliance_page_non_admin_redirects(self):
"""Test compliance page redirects non-admin users."""
from app.views.compliance import compliance_page
mock_request = Mock()
mock_request.session = {"user": {"id": "user1", "is_admin": False}}
result = await compliance_page(mock_request)
assert result.status_code == 302
# ---------------------------------------------------------------------------
# Config / settings metadata tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestComplianceConfig:
"""Tests for compliance configuration settings."""
def test_compliance_enabled_default(self):
"""Test that compliance_enabled defaults to True."""
from app.config import settings
assert hasattr(settings, "compliance_enabled")
assert settings.compliance_enabled is True
def test_compliance_enabled_in_setting_metadata(self):
"""Test that compliance_enabled has SETTING_METADATA entry."""
from app.utils.settings_service import SETTING_METADATA
assert "compliance_enabled" in SETTING_METADATA
meta = SETTING_METADATA["compliance_enabled"]
assert meta["category"] == "Feature Flags"
assert meta["type"] == "boolean"
+17 -1
View File
@@ -196,8 +196,12 @@ class TestValidateAuthConfig:
mock_settings.authentik_client_id = None
mock_settings.authentik_client_secret = None
mock_settings.authentik_config_url = None
mock_settings.social_auth_google_enabled = False
mock_settings.social_auth_microsoft_enabled = False
mock_settings.social_auth_apple_enabled = False
mock_settings.social_auth_dropbox_enabled = False
result = validate_auth_config()
assert "Neither simple authentication nor OIDC are properly configured" in result
assert "Neither simple authentication, OIDC, nor social login are properly configured" in result
def test_auth_enabled_oidc_missing_provider_name(self):
"""Test validation when OIDC is configured but provider name is missing."""
@@ -210,6 +214,10 @@ class TestValidateAuthConfig:
mock_settings.authentik_client_secret = "client_secret"
mock_settings.authentik_config_url = "https://example.com"
mock_settings.oauth_provider_name = None
mock_settings.social_auth_google_enabled = False
mock_settings.social_auth_microsoft_enabled = False
mock_settings.social_auth_apple_enabled = False
mock_settings.social_auth_dropbox_enabled = False
result = validate_auth_config()
assert "OAUTH_PROVIDER_NAME is not configured but OIDC is enabled" in result
@@ -223,6 +231,10 @@ class TestValidateAuthConfig:
mock_settings.authentik_client_id = None
mock_settings.authentik_client_secret = None
mock_settings.authentik_config_url = None
mock_settings.social_auth_google_enabled = False
mock_settings.social_auth_microsoft_enabled = False
mock_settings.social_auth_apple_enabled = False
mock_settings.social_auth_dropbox_enabled = False
result = validate_auth_config()
assert len(result) == 0
@@ -237,6 +249,10 @@ class TestValidateAuthConfig:
mock_settings.authentik_client_secret = "client_secret"
mock_settings.authentik_config_url = "https://example.com"
mock_settings.oauth_provider_name = "Authentik"
mock_settings.social_auth_google_enabled = False
mock_settings.social_auth_microsoft_enabled = False
mock_settings.social_auth_apple_enabled = False
mock_settings.social_auth_dropbox_enabled = False
result = validate_auth_config()
assert len(result) == 0
@@ -665,6 +665,7 @@ def _all_should_upload_false():
"email",
"onedrive",
"s3",
"icloud",
]
return [patch(f"app.tasks.send_to_all._should_upload_to_{s}", return_value=False) for s in services]
@@ -693,6 +694,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_icloud", return_value=False),
patch("app.tasks.send_to_all.SessionLocal") as mock_session_cls,
):
ms.workdir = str(tmp_path)
@@ -804,6 +806,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_icloud", return_value=False),
patch("app.tasks.send_to_all.SessionLocal"),
):
ms.workdir = str(tmp_path)
@@ -863,6 +866,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_icloud", return_value=False),
patch("app.tasks.send_to_all.SessionLocal"),
):
ms.workdir = str(tmp_path)
+95
View File
@@ -124,6 +124,101 @@ class TestInitDb:
test_engine.dispose()
def test_init_db_creates_shared_links_for_database_missing_table(self, tmp_path):
"""Regression test: databases at revision 026 that skipped 025_add_shared_links.
Migration 025_add_shared_links was inserted into the chain between
024_add_api_tokens and 025_add_user_notifications after some databases
had already been migrated past that point. Migration 027 creates the
table idempotently so those databases are repaired.
"""
from sqlalchemy import create_engine, text
from sqlalchemy import inspect as sa_inspect
db_path = str(tmp_path / "regression_shared_links.db")
test_engine = create_engine(f"sqlite:///{db_path}")
# Set up a database at revision 026 but WITHOUT the shared_links table.
# This simulates a DB that was migrated before 025_add_shared_links
# was inserted into the chain.
with test_engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE files ("
"id INTEGER PRIMARY KEY, filehash VARCHAR NOT NULL, "
"original_filename VARCHAR, local_filename VARCHAR NOT NULL, "
"original_file_path VARCHAR, processed_file_path VARCHAR, "
"file_size INTEGER NOT NULL, mime_type VARCHAR, "
"is_duplicate BOOLEAN DEFAULT 0 NOT NULL, duplicate_of_id INTEGER, "
"ocr_text TEXT, ai_metadata TEXT, document_title VARCHAR, "
"ocr_quality_score INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)"
)
)
conn.execute(
text(
"CREATE TABLE processing_logs ("
"id INTEGER PRIMARY KEY, file_id INTEGER, task_id VARCHAR, "
"step_name VARCHAR, status VARCHAR, message VARCHAR, detail TEXT, "
"timestamp DATETIME DEFAULT CURRENT_TIMESTAMP)"
)
)
conn.execute(
text(
"CREATE TABLE file_processing_steps ("
"id INTEGER PRIMARY KEY, file_id INTEGER NOT NULL, "
"step_name VARCHAR NOT NULL, status VARCHAR NOT NULL, "
"started_at DATETIME, completed_at DATETIME, error_message TEXT, "
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)"
)
)
conn.execute(
text(
"CREATE TABLE saved_searches ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, user_id VARCHAR NOT NULL, "
"name VARCHAR NOT NULL, filters TEXT NOT NULL, "
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
"UNIQUE (user_id, name))"
)
)
# user_imap_accounts was created by migration 022 and must exist
# before migration 032 (ALTER TABLE ... ADD COLUMN) can run.
conn.execute(
text(
"CREATE TABLE user_imap_accounts ("
"id INTEGER PRIMARY KEY, owner_id VARCHAR NOT NULL, "
"name VARCHAR(255) NOT NULL, host VARCHAR(255) NOT NULL, "
"port INTEGER NOT NULL DEFAULT 993, username VARCHAR(255) NOT NULL, "
"password VARCHAR(1024) NOT NULL, use_ssl BOOLEAN NOT NULL DEFAULT 1, "
"delete_after_process BOOLEAN NOT NULL DEFAULT 0, "
"is_active BOOLEAN NOT NULL DEFAULT 1, "
"last_checked_at DATETIME, last_error TEXT, "
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)"
)
)
conn.execute(text("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)"))
conn.execute(text("INSERT INTO alembic_version VALUES ('026_add_scheduled_jobs')"))
with patch("app.database.engine", test_engine), patch("app.database.DB_URL", f"sqlite:///{db_path}"):
init_db()
inspector = sa_inspect(test_engine)
table_names = inspector.get_table_names()
assert "shared_links" in table_names
# Verify the shared_links table has the expected columns.
columns = {col["name"] for col in inspector.get_columns("shared_links")}
assert "id" in columns
assert "token" in columns
assert "file_id" in columns
assert "owner_id" in columns
assert "expires_at" in columns
assert "is_active" in columns
test_engine.dispose()
@pytest.mark.unit
class TestGetDb:
+1 -1
View File
@@ -345,7 +345,7 @@ class TestSettingsPageWizardLink:
template_path = Path(__file__).resolve().parent.parent / "frontend" / "templates" / "settings.html"
content = template_path.read_text()
assert "/database-wizard" in content
assert "DB Wizard" in content
assert "settings.db_wizard_btn" in content # i18n key (resolves to "DB Wizard")
def test_settings_template_has_help_link_rendering(self):
"""Test that the settings template renders help_link metadata."""
+419
View File
@@ -0,0 +1,419 @@
"""
Tests for the GraphQL API endpoint at /graphql.
Covers:
- Schema introspection (endpoint availability + GraphiQL)
- Query: documents (list, single, auth-gated)
- Query: pipelines (list, single)
- Query: settings (admin-only)
- Query: users (admin-only)
- Pagination and limit clamping
- Sensitive setting keys are excluded
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from app.models import ApplicationSettings, FileRecord, Pipeline, PipelineStep, UserProfile
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def gql(client: TestClient, query: str, variables: dict | None = None) -> dict:
"""Execute a GraphQL POST request and return the parsed JSON body."""
payload: dict = {"query": query}
if variables:
payload["variables"] = variables
response = client.post("/graphql", json=payload)
assert response.status_code == 200, f"Unexpected status {response.status_code}: {response.text}"
return response.json()
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def file_record(db_session) -> FileRecord:
rec = FileRecord(
owner_id="user1",
original_filename="invoice.pdf",
local_filename="/workdir/tmp/invoice.pdf",
file_size=1024,
mime_type="application/pdf",
filehash="abc123",
)
db_session.add(rec)
db_session.commit()
db_session.refresh(rec)
return rec
@pytest.fixture()
def pipeline_record(db_session) -> Pipeline:
p = Pipeline(
owner_id="user1",
name="Test Pipeline",
description="A pipeline for tests",
is_default=False,
is_active=True,
)
db_session.add(p)
db_session.commit()
db_session.refresh(p)
step = PipelineStep(
pipeline_id=p.id,
position=0,
step_type="ocr",
label="Run OCR",
enabled=True,
)
db_session.add(step)
db_session.commit()
return p
@pytest.fixture()
def setting_record(db_session) -> ApplicationSettings:
s = ApplicationSettings(key="max_upload_size", value="104857600")
db_session.add(s)
db_session.commit()
db_session.refresh(s)
return s
@pytest.fixture()
def user_profile(db_session) -> UserProfile:
profile = UserProfile(
user_id="user1",
display_name="Test User",
is_blocked=False,
subscription_tier="free",
onboarding_completed=False,
)
db_session.add(profile)
db_session.commit()
db_session.refresh(profile)
return profile
# ---------------------------------------------------------------------------
# Tests: endpoint availability
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGraphQLEndpoint:
"""Verify the /graphql endpoint is reachable and introspectable."""
def test_graphql_post_exists(self, client: TestClient):
"""POST /graphql returns 200 for a valid introspection query."""
result = gql(client, "{ __schema { queryType { name } } }")
assert "data" in result
assert result["data"]["__schema"]["queryType"]["name"] == "Query"
def test_graphql_get_returns_graphiql(self, client: TestClient):
"""GET /graphql returns the GraphiQL playground HTML."""
response = client.get("/graphql", headers={"Accept": "text/html"})
assert response.status_code == 200
assert "graphiql" in response.text.lower() or "graphql" in response.text.lower()
def test_graphql_schema_has_expected_types(self, client: TestClient):
"""Schema exposes DocumentType, PipelineType, SettingType, UserType."""
result = gql(
client,
"""
{
__schema {
types { name }
}
}
""",
)
type_names = {t["name"] for t in result["data"]["__schema"]["types"]}
for expected in ("DocumentType", "PipelineType", "SettingType", "UserType"):
assert expected in type_names, f"{expected} not found in schema"
def test_graphql_query_fields(self, client: TestClient):
"""Root Query has documents, document, pipelines, pipeline, settings, users, user fields."""
result = gql(
client,
"""
{
__type(name: "Query") {
fields { name }
}
}
""",
)
field_names = {f["name"] for f in result["data"]["__type"]["fields"]}
for expected in ("documents", "document", "pipelines", "pipeline", "settings", "users", "user"):
assert expected in field_names, f"Query field '{expected}' missing from schema"
# ---------------------------------------------------------------------------
# Tests: documents queries
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestDocumentsQuery:
"""Tests for the documents and document queries."""
def test_list_documents_empty(self, client: TestClient):
result = gql(client, "{ documents { id originalFilename } }")
assert "errors" not in result
assert result["data"]["documents"] == []
def test_list_documents_returns_records(self, client: TestClient, file_record: FileRecord):
result = gql(client, "{ documents { id originalFilename mimeType fileSize } }")
assert "errors" not in result
docs = result["data"]["documents"]
assert len(docs) == 1
assert docs[0]["id"] == file_record.id
assert docs[0]["originalFilename"] == "invoice.pdf"
assert docs[0]["mimeType"] == "application/pdf"
assert docs[0]["fileSize"] == 1024
def test_get_single_document(self, client: TestClient, file_record: FileRecord):
result = gql(
client,
"query($id: Int!) { document(id: $id) { id originalFilename } }",
variables={"id": file_record.id},
)
assert "errors" not in result
assert result["data"]["document"]["id"] == file_record.id
def test_get_nonexistent_document_returns_null(self, client: TestClient):
result = gql(client, "{ document(id: 99999) { id } }")
assert "errors" not in result
assert result["data"]["document"] is None
def test_documents_pagination(self, client: TestClient, db_session):
for i in range(5):
db_session.add(
FileRecord(
owner_id="user1",
original_filename=f"doc{i}.pdf",
local_filename=f"/workdir/tmp/doc{i}.pdf",
file_size=100,
filehash=f"hash{i}",
)
)
db_session.commit()
result_page1 = gql(client, "{ documents(limit: 2, offset: 0) { id } }")
result_page2 = gql(client, "{ documents(limit: 2, offset: 2) { id } }")
assert "errors" not in result_page1
assert "errors" not in result_page2
assert len(result_page1["data"]["documents"]) == 2
assert len(result_page2["data"]["documents"]) == 2
def test_documents_limit_clamped_to_100(self, client: TestClient, db_session):
# Requesting more than 100 should be silently clamped to 100
for i in range(5):
db_session.add(
FileRecord(
owner_id="user1",
original_filename=f"big{i}.pdf",
local_filename=f"/workdir/tmp/big{i}.pdf",
file_size=100,
filehash=f"bighash{i}",
)
)
db_session.commit()
result = gql(client, "{ documents(limit: 999) { id } }")
assert "errors" not in result
# Just verify it doesn't error and returns something
assert isinstance(result["data"]["documents"], list)
def test_documents_filter_by_owner(self, client: TestClient, db_session):
db_session.add(
FileRecord(
owner_id="alice",
original_filename="alice.pdf",
local_filename="/workdir/tmp/alice.pdf",
file_size=100,
filehash="alicehash",
)
)
db_session.add(
FileRecord(
owner_id="bob",
original_filename="bob.pdf",
local_filename="/workdir/tmp/bob.pdf",
file_size=200,
filehash="bobhash",
)
)
db_session.commit()
result = gql(client, '{ documents(ownerId: "alice") { id originalFilename } }')
assert "errors" not in result
docs = result["data"]["documents"]
assert all(d["originalFilename"] == "alice.pdf" for d in docs)
# ---------------------------------------------------------------------------
# Tests: pipelines queries
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestPipelinesQuery:
"""Tests for the pipelines and pipeline queries."""
def test_list_pipelines_empty(self, client: TestClient):
result = gql(client, "{ pipelines { id name } }")
assert "errors" not in result
assert result["data"]["pipelines"] == []
def test_list_pipelines_with_steps(self, client: TestClient, pipeline_record: Pipeline):
result = gql(
client,
"""
{
pipelines {
id name description isDefault isActive
steps { id stepType position enabled }
}
}
""",
)
assert "errors" not in result
pipelines = result["data"]["pipelines"]
assert len(pipelines) == 1
assert pipelines[0]["name"] == "Test Pipeline"
assert len(pipelines[0]["steps"]) == 1
assert pipelines[0]["steps"][0]["stepType"] == "ocr"
def test_get_single_pipeline(self, client: TestClient, pipeline_record: Pipeline):
result = gql(
client,
"query($id: Int!) { pipeline(id: $id) { id name steps { stepType } } }",
variables={"id": pipeline_record.id},
)
assert "errors" not in result
assert result["data"]["pipeline"]["id"] == pipeline_record.id
assert result["data"]["pipeline"]["steps"][0]["stepType"] == "ocr"
def test_get_nonexistent_pipeline_returns_null(self, client: TestClient):
result = gql(client, "{ pipeline(id: 99999) { id } }")
assert "errors" not in result
assert result["data"]["pipeline"] is None
# ---------------------------------------------------------------------------
# Tests: settings query (admin-only)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSettingsQuery:
"""Tests for the settings query."""
def test_settings_returns_data_when_no_auth(self, client: TestClient, setting_record: ApplicationSettings):
"""When AUTH_ENABLED=False, settings are accessible (no auth required)."""
result = gql(client, "{ settings { key value } }")
assert "errors" not in result
keys = [s["key"] for s in result["data"]["settings"]]
assert "max_upload_size" in keys
def test_sensitive_settings_excluded(self, client: TestClient, db_session):
"""Sensitive setting keys must never appear in the response."""
sensitive_keys = [
"openai_api_key",
"session_secret",
"azure_ai_key",
"smtp_password",
]
for key in sensitive_keys:
db_session.add(ApplicationSettings(key=key, value="super-secret"))
db_session.commit()
result = gql(client, "{ settings { key value } }")
assert "errors" not in result
returned_keys = {s["key"] for s in result["data"]["settings"]}
for key in sensitive_keys:
assert key not in returned_keys, f"Sensitive key '{key}' was returned by GraphQL settings query"
def test_settings_auth_required_when_auth_enabled(self, client: TestClient):
"""When AUTH_ENABLED=True and no user, settings query must return an error."""
from app.config import settings as app_settings
with patch.object(app_settings, "auth_enabled", True):
result = gql(client, "{ settings { key } }")
# Should have errors because no user is authenticated
assert "errors" in result
# ---------------------------------------------------------------------------
# Tests: users query (admin-only)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestUsersQuery:
"""Tests for the users and user queries."""
def test_users_returns_profiles_when_no_auth(self, client: TestClient, user_profile: UserProfile):
"""When AUTH_ENABLED=False, users are accessible."""
result = gql(client, "{ users { userId displayName subscriptionTier } }")
assert "errors" not in result
users = result["data"]["users"]
assert any(u["userId"] == "user1" for u in users)
def test_get_user_by_id(self, client: TestClient, user_profile: UserProfile):
result = gql(
client,
'query { user(userId: "user1") { userId displayName isBlocked } }',
)
assert "errors" not in result
assert result["data"]["user"]["userId"] == "user1"
assert result["data"]["user"]["displayName"] == "Test User"
assert result["data"]["user"]["isBlocked"] is False
def test_get_nonexistent_user_returns_null(self, client: TestClient):
result = gql(client, '{ user(userId: "nobody") { userId } }')
assert "errors" not in result
assert result["data"]["user"] is None
def test_users_auth_required_when_auth_enabled(self, client: TestClient):
"""When AUTH_ENABLED=True and no user, users query must return an error."""
from app.config import settings as app_settings
with patch.object(app_settings, "auth_enabled", True):
result = gql(client, "{ users { userId } }")
assert "errors" in result
# ---------------------------------------------------------------------------
# Tests: auth enforcement
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGraphQLAuth:
"""Verify auth is enforced for all queries when AUTH_ENABLED=True."""
def test_documents_auth_required_when_auth_enabled(self, client: TestClient):
from app.config import settings as app_settings
with patch.object(app_settings, "auth_enabled", True):
result = gql(client, "{ documents { id } }")
assert "errors" in result
def test_pipelines_auth_required_when_auth_enabled(self, client: TestClient):
from app.config import settings as app_settings
with patch.object(app_settings, "auth_enabled", True):
result = gql(client, "{ pipelines { id } }")
assert "errors" in result
+447
View File
@@ -0,0 +1,447 @@
"""Tests for the i18n (internationalization) and l10n (localization) utilities."""
from __future__ import annotations
import json
from datetime import date, datetime
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from fastapi.testclient import TestClient
from app.utils.i18n import (
DEFAULT_LANGUAGE,
SUPPORTED_LANGUAGE_CODES,
SUPPORTED_LANGUAGES,
_parse_accept_language,
detect_language,
format_date,
format_datetime,
format_number,
get_language_info,
reload_translations,
translate,
)
# ---------------------------------------------------------------------------
# Translation file integrity
# ---------------------------------------------------------------------------
class TestTranslationFiles:
"""Verify that all translation JSON files are valid and complete."""
@pytest.fixture(autouse=True)
def _clear_cache(self) -> None:
"""Clear translation cache before each test."""
reload_translations()
@pytest.mark.unit
def test_all_translation_files_exist(self) -> None:
"""Every supported language must have a corresponding JSON file."""
translations_dir = Path(__file__).resolve().parent.parent / "frontend" / "translations"
for lang in SUPPORTED_LANGUAGES:
filepath = translations_dir / f"{lang['code']}.json"
assert filepath.is_file(), f"Missing translation file for {lang['code']}"
@pytest.mark.unit
def test_all_translation_files_are_valid_json(self) -> None:
"""All translation files must be parseable JSON."""
translations_dir = Path(__file__).resolve().parent.parent / "frontend" / "translations"
for lang in SUPPORTED_LANGUAGES:
filepath = translations_dir / f"{lang['code']}.json"
data = json.loads(filepath.read_text(encoding="utf-8"))
assert isinstance(data, dict), f"{lang['code']}.json must be a dict"
assert len(data) > 0, f"{lang['code']}.json must not be empty"
@pytest.mark.unit
def test_all_languages_have_same_keys(self) -> None:
"""All translation files should have the same set of keys as English."""
translations_dir = Path(__file__).resolve().parent.parent / "frontend" / "translations"
en_path = translations_dir / "en.json"
en_keys = set(json.loads(en_path.read_text(encoding="utf-8")).keys())
for lang in SUPPORTED_LANGUAGES:
if lang["code"] == "en":
continue
filepath = translations_dir / f"{lang['code']}.json"
lang_keys = set(json.loads(filepath.read_text(encoding="utf-8")).keys())
missing = en_keys - lang_keys
assert not missing, f"{lang['code']}.json missing keys: {missing}"
# ---------------------------------------------------------------------------
# Core translate() function
# ---------------------------------------------------------------------------
class TestTranslate:
"""Tests for the translate() function."""
@pytest.fixture(autouse=True)
def _clear_cache(self) -> None:
reload_translations()
@pytest.mark.unit
def test_translate_english_key(self) -> None:
"""English keys should resolve to English text."""
result = translate("nav.dashboard", "en")
assert result == "Dashboard"
@pytest.mark.unit
def test_translate_german_key(self) -> None:
"""German locale should return German text."""
result = translate("nav.dashboard", "de")
assert result == "Übersicht"
@pytest.mark.unit
def test_translate_french_key(self) -> None:
"""French locale should return French text."""
result = translate("nav.dashboard", "fr")
assert result == "Tableau de bord"
@pytest.mark.unit
def test_translate_chinese_key(self) -> None:
"""Chinese locale should return Chinese text."""
result = translate("nav.dashboard", "zh")
assert result == "仪表盘"
@pytest.mark.unit
def test_translate_fallback_to_english(self) -> None:
"""Unknown locale falls back to English."""
result = translate("nav.dashboard", "xx")
assert result == "Dashboard"
@pytest.mark.unit
def test_translate_missing_key_returns_key(self) -> None:
"""Missing key falls back to the key itself."""
result = translate("nonexistent.key", "en")
assert result == "nonexistent.key"
@pytest.mark.unit
def test_translate_none_locale_uses_default(self) -> None:
"""None locale defaults to English."""
result = translate("nav.dashboard", None)
assert result == "Dashboard"
@pytest.mark.unit
def test_translate_with_kwargs(self) -> None:
"""Placeholders should be interpolated via kwargs."""
result = translate("footer.copyright", "en", year="2025")
assert result == "DocuElevate 2025"
@pytest.mark.unit
def test_translate_with_kwargs_german(self) -> None:
"""Placeholder interpolation in German."""
result = translate("language.changed", "de", language="English")
assert result == "Sprache geändert zu English"
# ---------------------------------------------------------------------------
# Accept-Language header parsing
# ---------------------------------------------------------------------------
class TestParseAcceptLanguage:
"""Tests for parsing the Accept-Language HTTP header."""
@pytest.mark.unit
def test_simple_language(self) -> None:
assert _parse_accept_language("de") == "de"
@pytest.mark.unit
def test_language_with_region(self) -> None:
assert _parse_accept_language("de-DE") == "de"
@pytest.mark.unit
def test_multiple_languages_quality(self) -> None:
result = _parse_accept_language("fr;q=0.9, de;q=1.0, en;q=0.8")
assert result == "de"
@pytest.mark.unit
def test_unsupported_language_fallback(self) -> None:
result = _parse_accept_language("ja, ko")
assert result is None
@pytest.mark.unit
def test_empty_header(self) -> None:
assert _parse_accept_language("") is None
@pytest.mark.unit
def test_complex_accept_language(self) -> None:
header = "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7"
result = _parse_accept_language(header)
assert result == "zh"
# ---------------------------------------------------------------------------
# Language detection
# ---------------------------------------------------------------------------
class TestDetectLanguage:
"""Tests for detecting language from request context."""
@pytest.mark.unit
def test_session_preference_takes_priority(self) -> None:
request = MagicMock()
request.session = {"preferred_language": "de"}
request.cookies = {}
request.headers = {}
assert detect_language(request) == "de"
@pytest.mark.unit
def test_cookie_fallback(self) -> None:
request = MagicMock()
request.session = {}
request.cookies = {"docuelevate_lang": "fr"}
request.headers = {}
assert detect_language(request) == "fr"
@pytest.mark.unit
def test_accept_language_fallback(self) -> None:
request = MagicMock()
request.session = {}
request.cookies = {}
request.headers = {"accept-language": "es-ES,es;q=0.9"}
assert detect_language(request) == "es"
@pytest.mark.unit
def test_default_fallback(self) -> None:
request = MagicMock()
request.session = {}
request.cookies = {}
request.headers = {}
assert detect_language(request) == DEFAULT_LANGUAGE
@pytest.mark.unit
def test_invalid_session_language_ignored(self) -> None:
request = MagicMock()
request.session = {"preferred_language": "invalid"}
request.cookies = {"docuelevate_lang": "it"}
request.headers = {}
assert detect_language(request) == "it"
# ---------------------------------------------------------------------------
# Localization helpers
# ---------------------------------------------------------------------------
class TestL10nFormatters:
"""Tests for locale-aware formatting functions."""
@pytest.mark.unit
def test_format_date_english(self) -> None:
d = date(2025, 3, 15)
result = format_date(d, "en")
assert "March" in result
assert "15" in result
assert "2025" in result
@pytest.mark.unit
def test_format_date_german(self) -> None:
d = date(2025, 3, 15)
result = format_date(d, "de")
assert "15." in result
assert "2025" in result
@pytest.mark.unit
def test_format_date_short(self) -> None:
d = date(2025, 3, 15)
result = format_date(d, "en", short=True)
assert result == "03/15/2025"
@pytest.mark.unit
def test_format_date_short_german(self) -> None:
d = date(2025, 3, 15)
result = format_date(d, "de", short=True)
assert result == "15.03.2025"
@pytest.mark.unit
def test_format_date_none(self) -> None:
assert format_date(None) == ""
@pytest.mark.unit
def test_format_datetime_none(self) -> None:
assert format_datetime(None) == ""
@pytest.mark.unit
def test_format_number_english(self) -> None:
result = format_number(1234567, "en")
assert result == "1,234,567"
@pytest.mark.unit
def test_format_number_german(self) -> None:
result = format_number(1234567, "de")
assert result == "1.234.567"
@pytest.mark.unit
def test_format_number_float_english(self) -> None:
result = format_number(1234.56, "en")
assert result == "1,234.56"
@pytest.mark.unit
def test_format_number_float_german(self) -> None:
result = format_number(1234.56, "de")
assert result == "1.234,56"
@pytest.mark.unit
def test_format_datetime_chinese(self) -> None:
dt = datetime(2025, 3, 15, 14, 30)
result = format_datetime(dt, "zh")
assert "2025" in result
assert "03" in result
assert "15" in result
# ---------------------------------------------------------------------------
# get_language_info()
# ---------------------------------------------------------------------------
class TestGetLanguageInfo:
"""Tests for get_language_info() utility."""
@pytest.mark.unit
def test_known_language(self) -> None:
info = get_language_info("de")
assert info is not None
assert info["name"] == "German"
assert info["native"] == "Deutsch"
@pytest.mark.unit
def test_unknown_language(self) -> None:
assert get_language_info("xx") is None
# ---------------------------------------------------------------------------
# SUPPORTED_LANGUAGES metadata
# ---------------------------------------------------------------------------
class TestSupportedLanguages:
"""Tests for language metadata constants."""
@pytest.mark.unit
def test_ten_languages_supported(self) -> None:
assert len(SUPPORTED_LANGUAGES) == 31
@pytest.mark.unit
def test_supported_codes_set(self) -> None:
expected = {
"en",
"de",
"fr",
"es",
"it",
"pt",
"nl",
"pl",
"zh",
"ru",
"nb",
"da",
"sv",
"fi",
"is",
"ga",
"lb",
"ca",
"cs",
"sk",
"hu",
"sl",
"hr",
"ro",
"bg",
"el",
"et",
"lv",
"lt",
"tr",
"uk",
}
assert SUPPORTED_LANGUAGE_CODES == expected
@pytest.mark.unit
def test_default_language_is_english(self) -> None:
assert DEFAULT_LANGUAGE == "en"
# ---------------------------------------------------------------------------
# API endpoint tests
# ---------------------------------------------------------------------------
class TestI18nAPI:
"""Tests for the i18n API endpoints."""
@pytest.mark.integration
def test_list_languages(self, client: TestClient) -> None:
"""GET /api/i18n/languages should return all supported languages."""
response = client.get("/api/i18n/languages")
assert response.status_code == 200
data = response.json()
assert "languages" in data
assert len(data["languages"]) == 31
assert data["default"] == "en"
# Verify each language has required fields
for lang in data["languages"]:
assert "code" in lang
assert "name" in lang
assert "native" in lang
assert "flag" in lang
@pytest.mark.integration
def test_set_language(self, client: TestClient) -> None:
"""POST /api/i18n/language should set language preference."""
response = client.post(
"/api/i18n/language",
json={"language": "de"},
)
assert response.status_code == 200
data = response.json()
assert data["language"] == "de"
# Verify cookie was set
assert "docuelevate_lang" in response.cookies
@pytest.mark.integration
def test_set_language_invalid_falls_back_to_default(self, client: TestClient) -> None:
"""Invalid language code should fall back to default."""
response = client.post(
"/api/i18n/language",
json={"language": "invalid"},
)
assert response.status_code == 200
data = response.json()
assert data["language"] == "en"
@pytest.mark.integration
def test_set_language_persists_in_cookie(self, client: TestClient) -> None:
"""Language setting should be persisted in a cookie."""
client.post("/api/i18n/language", json={"language": "fr"})
# Subsequent requests should detect the language from cookie
response = client.get("/api/i18n/languages")
data = response.json()
assert data["current"] == "fr"
@pytest.mark.integration
def test_base_html_uses_current_locale(self, client: TestClient) -> None:
"""The base template should set lang attribute to current locale."""
# Set language to German
client.post("/api/i18n/language", json={"language": "de"})
# Load homepage
response = client.get("/", follow_redirects=True)
assert response.status_code == 200
# The lang attribute should reflect the locale
assert 'lang="de"' in response.text or 'lang="en"' in response.text
@pytest.mark.integration
def test_language_selector_in_nav(self, client: TestClient) -> None:
"""The navigation should contain the language selector globe icon."""
response = client.get("/", follow_redirects=True)
if response.status_code == 200:
assert "fa-globe" in response.text
assert "setLanguage" in response.text
+158
View File
@@ -0,0 +1,158 @@
"""Tests for app/api/imap_profiles.py and app/utils/allowed_types category helpers."""
import pytest
from app.utils.allowed_types import (
ALL_CATEGORIES,
DEFAULT_CATEGORIES,
FILE_TYPE_CATEGORIES,
get_allowed_types_for_categories,
)
@pytest.mark.unit
class TestFileTypeCategories:
"""Tests for FILE_TYPE_CATEGORIES and get_allowed_types_for_categories."""
def test_all_category_keys_present(self):
"""Test that the six expected categories exist."""
assert set(FILE_TYPE_CATEGORIES.keys()) == {"pdf", "office", "opendocument", "text", "web", "images"}
def test_each_category_has_required_fields(self):
"""Test that every category entry has label, description, mime_types, extensions."""
for key, info in FILE_TYPE_CATEGORIES.items():
assert "label" in info, f"Category '{key}' missing 'label'"
assert "description" in info, f"Category '{key}' missing 'description'"
assert "mime_types" in info, f"Category '{key}' missing 'mime_types'"
assert "extensions" in info, f"Category '{key}' missing 'extensions'"
def test_pdf_category_contains_pdf_mime(self):
"""Test that the pdf category includes application/pdf."""
assert "application/pdf" in FILE_TYPE_CATEGORIES["pdf"]["mime_types"]
assert ".pdf" in FILE_TYPE_CATEGORIES["pdf"]["extensions"]
def test_images_category_contains_jpeg(self):
"""Test that the images category includes image/jpeg."""
assert "image/jpeg" in FILE_TYPE_CATEGORIES["images"]["mime_types"]
assert ".jpg" in FILE_TYPE_CATEGORIES["images"]["extensions"]
assert ".png" in FILE_TYPE_CATEGORIES["images"]["extensions"]
def test_get_allowed_types_for_default_categories(self):
"""Test that DEFAULT_CATEGORIES excludes image MIME types."""
mime_types, extensions = get_allowed_types_for_categories(DEFAULT_CATEGORIES)
assert "application/pdf" in mime_types
assert "application/msword" in mime_types
# images should NOT be in the default
assert "image/jpeg" not in mime_types
assert ".jpg" not in extensions
def test_get_allowed_types_for_all_categories(self):
"""Test that ALL_CATEGORIES includes image MIME types."""
mime_types, extensions = get_allowed_types_for_categories(ALL_CATEGORIES)
assert "image/jpeg" in mime_types
assert ".jpg" in extensions
assert "application/pdf" in mime_types
def test_get_allowed_types_returns_frozensets(self):
"""Test that returned sets are frozensets."""
mime_types, extensions = get_allowed_types_for_categories(["pdf"])
assert isinstance(mime_types, frozenset)
assert isinstance(extensions, frozenset)
def test_get_allowed_types_unknown_category_ignored(self):
"""Test that unknown category keys are silently ignored."""
mime_types, extensions = get_allowed_types_for_categories(["pdf", "nonexistent_category"])
assert "application/pdf" in mime_types # 'pdf' still works
# No crash for unknown key
def test_get_allowed_types_empty_list(self):
"""Test empty category list returns empty sets."""
mime_types, extensions = get_allowed_types_for_categories([])
assert mime_types == frozenset()
assert extensions == frozenset()
def test_default_categories_excludes_images(self):
"""Test that DEFAULT_CATEGORIES does not include 'images'."""
assert "images" not in DEFAULT_CATEGORIES
def test_all_categories_includes_images(self):
"""Test that ALL_CATEGORIES includes 'images'."""
assert "images" in ALL_CATEGORIES
def test_all_categories_is_superset_of_default(self):
"""Test that ALL_CATEGORIES contains all DEFAULT_CATEGORIES."""
for cat in DEFAULT_CATEGORIES:
assert cat in ALL_CATEGORIES
@pytest.mark.unit
class TestImapProfilesApiLogic:
"""Tests for ingestion profile validation helpers."""
def test_validate_categories_accepts_valid_keys(self):
"""Test that valid category keys pass validation."""
from app.api.imap_profiles import _validate_categories
result = _validate_categories(["pdf", "office", "images"])
assert set(result) == {"pdf", "office", "images"}
def test_validate_categories_rejects_unknown_key(self):
"""Test that unknown category keys raise 422."""
from fastapi import HTTPException
from app.api.imap_profiles import _validate_categories
with pytest.raises(HTTPException) as exc_info:
_validate_categories(["pdf", "nonexistent"])
assert exc_info.value.status_code == 422
assert "nonexistent" in str(exc_info.value.detail)
def test_validate_categories_deduplicates(self):
"""Test that duplicate category keys are de-duplicated while preserving order."""
from app.api.imap_profiles import _validate_categories
result = _validate_categories(["pdf", "pdf", "office", "pdf"])
assert result == ["pdf", "office"]
def test_to_response_serializes_profile(self, tmp_path):
"""Test _to_response produces expected dict shape."""
from unittest.mock import MagicMock
from app.api.imap_profiles import _to_response
profile = MagicMock()
profile.id = 42
profile.name = "My Profile"
profile.description = "Test description"
profile.owner_id = "user@example.com"
profile.allowed_categories = '["pdf","office"]'
profile.is_builtin = False
profile.created_at = None
profile.updated_at = None
result = _to_response(profile)
assert result["id"] == 42
assert result["name"] == "My Profile"
assert result["allowed_categories"] == ["pdf", "office"]
assert len(result["categories_detail"]) == 2
assert result["categories_detail"][0]["key"] == "pdf"
assert result["is_builtin"] is False
def test_to_response_handles_invalid_categories_json(self):
"""Test _to_response gracefully handles invalid JSON in allowed_categories."""
from unittest.mock import MagicMock
from app.api.imap_profiles import _to_response
profile = MagicMock()
profile.id = 1
profile.name = "Broken"
profile.description = None
profile.owner_id = None
profile.allowed_categories = "this is not valid json {"
profile.is_builtin = True
profile.created_at = None
profile.updated_at = None
result = _to_response(profile)
assert result["allowed_categories"] == []
+76
View File
@@ -24,6 +24,7 @@ from app.tasks.imap_tasks import (
release_lock,
save_processed_emails,
)
from app.utils.allowed_types import ALL_CATEGORIES, DEFAULT_CATEGORIES, get_allowed_types_for_categories
_TEST_CREDENTIAL = "pass" # noqa: S105
@@ -179,6 +180,81 @@ class TestFetchAttachmentsAndEnqueue:
assert result is True
mock_convert.delay.assert_called_once()
@patch("app.tasks.imap_tasks.process_document")
@patch("app.tasks.imap_tasks.convert_to_pdf")
def test_skips_image_when_documents_only(self, mock_convert, mock_process):
"""Test that image attachments are skipped with the default (documents-only) categories."""
msg = EmailMessage()
msg["Subject"] = "Photo"
msg.add_attachment(b"\xff\xd8\xff", maintype="image", subtype="jpeg", filename="photo.jpg")
doc_mime, doc_ext = get_allowed_types_for_categories(DEFAULT_CATEGORIES)
result = fetch_attachments_and_enqueue(msg, effective_mime_types=doc_mime, effective_extensions=doc_ext)
assert result is False
mock_process.delay.assert_not_called()
mock_convert.delay.assert_not_called()
@patch("app.tasks.imap_tasks.process_document")
@patch("app.tasks.imap_tasks.convert_to_pdf")
def test_processes_image_when_all_categories(self, mock_convert, mock_process, tmp_path):
"""Test that image attachments are processed when images category is included."""
msg = EmailMessage()
msg["Subject"] = "Photo"
msg.add_attachment(b"\xff\xd8\xff", maintype="image", subtype="jpeg", filename="photo.jpg")
all_mime, all_ext = get_allowed_types_for_categories(ALL_CATEGORIES)
with patch("app.tasks.imap_tasks.settings") as mock_settings:
mock_settings.workdir = str(tmp_path)
result = fetch_attachments_and_enqueue(msg, effective_mime_types=all_mime, effective_extensions=all_ext)
assert result is True
mock_convert.delay.assert_called_once()
@patch("app.tasks.imap_tasks.process_document")
@patch("app.tasks.imap_tasks.convert_to_pdf")
def test_skips_image_with_image_extension_documents_only(self, mock_convert, mock_process):
"""Test image files identified by extension are skipped with documents-only categories."""
msg = EmailMessage()
msg["Subject"] = "Screenshot"
msg.add_attachment(b"\x89PNG", maintype="application", subtype="octet-stream", filename="screenshot.png")
doc_mime, doc_ext = get_allowed_types_for_categories(DEFAULT_CATEGORIES)
result = fetch_attachments_and_enqueue(msg, effective_mime_types=doc_mime, effective_extensions=doc_ext)
assert result is False
mock_process.delay.assert_not_called()
mock_convert.delay.assert_not_called()
@patch("app.tasks.imap_tasks.process_document")
@patch("app.tasks.imap_tasks.convert_to_pdf")
def test_processes_pdf_regardless_of_categories(self, mock_convert, mock_process, tmp_path):
"""Test that PDFs are always processed (pdf category always included in DEFAULT_CATEGORIES)."""
msg = EmailMessage()
msg["Subject"] = "Invoice"
msg.add_attachment(b"%PDF-1.4", maintype="application", subtype="pdf", filename="invoice.pdf")
doc_mime, doc_ext = get_allowed_types_for_categories(DEFAULT_CATEGORIES)
with patch("app.tasks.imap_tasks.settings") as mock_settings:
mock_settings.workdir = str(tmp_path)
result = fetch_attachments_and_enqueue(msg, effective_mime_types=doc_mime, effective_extensions=doc_ext)
assert result is True
mock_process.delay.assert_called_once()
@patch("app.tasks.imap_tasks.process_document")
@patch("app.tasks.imap_tasks.convert_to_pdf")
def test_uses_default_categories_when_no_types_provided(self, mock_convert, mock_process):
"""Test that images are skipped when no effective_mime_types / extensions are passed (defaults to DEFAULT_CATEGORIES)."""
msg = EmailMessage()
msg["Subject"] = "Photo"
msg.add_attachment(b"\xff\xd8\xff", maintype="image", subtype="jpeg", filename="photo.jpg")
# No effective_mime_types passed → function defaults to DEFAULT_CATEGORIES (no images)
result = fetch_attachments_and_enqueue(msg)
assert result is False
mock_process.delay.assert_not_called()
mock_convert.delay.assert_not_called()
@pytest.mark.unit
class TestEmailAlreadyHasLabel:
+4
View File
@@ -613,6 +613,8 @@ async def test_local_login_success(la_session, active_user):
mock_request = MagicMock(spec=Request)
mock_request.form = AsyncMock(return_value={"username": "activeuser", "password": "password123"})
mock_request.session = {}
mock_request.headers.get.return_value = None
mock_request.client = None
result = await auth(mock_request, db=la_session)
assert result.status_code == 302
@@ -634,6 +636,8 @@ async def test_local_login_by_email(la_session, active_user):
mock_request = MagicMock(spec=Request)
mock_request.form = AsyncMock(return_value={"username": "active@example.com", "password": "password123"})
mock_request.session = {}
mock_request.headers.get.return_value = None
mock_request.client = None
result = await auth(mock_request, db=la_session)
assert result.status_code == 302
+187 -4
View File
@@ -9,6 +9,7 @@ from app.tasks.send_to_all import (
_should_upload_to_email,
_should_upload_to_ftp,
_should_upload_to_google_drive,
_should_upload_to_icloud,
_should_upload_to_nextcloud,
_should_upload_to_onedrive,
_should_upload_to_paperless,
@@ -145,6 +146,137 @@ class TestShouldUploadFunctions:
assert _should_upload_to_s3() is True
@patch("app.tasks.send_to_all.settings")
def test_should_upload_to_icloud_configured(self, mock_settings):
"""Test iCloud upload check."""
mock_settings.icloud_username = "user@example.com"
mock_settings.icloud_password = "app-specific-password"
assert _should_upload_to_icloud() is True
@patch("app.tasks.send_to_all.settings")
def test_should_upload_to_icloud_not_configured(self, mock_settings):
"""Test iCloud upload check when not configured."""
mock_settings.icloud_username = None
mock_settings.icloud_password = None
assert _should_upload_to_icloud() is False
@pytest.mark.unit
class TestShouldUploadEnabledFlag:
"""Test that the _should_upload_to_* functions respect the explicit enabled flag."""
@patch("app.tasks.send_to_all.settings")
def test_dropbox_disabled_with_credentials(self, mock_settings):
"""Test Dropbox upload is blocked when disabled even with valid credentials."""
mock_settings.dropbox_enabled = False
mock_settings.dropbox_app_key = "key"
mock_settings.dropbox_app_secret = "secret"
mock_settings.dropbox_refresh_token = "token"
assert _should_upload_to_dropbox() is False
@patch("app.tasks.send_to_all.settings")
def test_nextcloud_disabled_with_credentials(self, mock_settings):
"""Test Nextcloud upload is blocked when disabled even with valid credentials."""
mock_settings.nextcloud_enabled = False
mock_settings.nextcloud_upload_url = "https://nextcloud.example.com"
mock_settings.nextcloud_username = "user"
mock_settings.nextcloud_password = "pass"
assert _should_upload_to_nextcloud() is False
@patch("app.tasks.send_to_all.settings")
def test_paperless_disabled_with_credentials(self, mock_settings):
"""Test Paperless upload is blocked when disabled even with valid credentials."""
mock_settings.paperless_enabled = False
mock_settings.paperless_ngx_api_token = "token"
mock_settings.paperless_host = "https://paperless.example.com"
assert _should_upload_to_paperless() is False
@patch("app.tasks.send_to_all.settings")
def test_google_drive_disabled_with_credentials(self, mock_settings):
"""Test Google Drive upload is blocked when disabled even with valid credentials."""
mock_settings.google_drive_enabled = False
mock_settings.google_drive_use_oauth = False
mock_settings.google_drive_credentials_json = '{"type": "service_account"}'
mock_settings.google_drive_folder_id = "folder_id"
assert _should_upload_to_google_drive() is False
@patch("app.tasks.send_to_all.settings")
def test_webdav_disabled_with_credentials(self, mock_settings):
"""Test WebDAV upload is blocked when disabled even with valid credentials."""
mock_settings.webdav_enabled = False
mock_settings.webdav_url = "https://webdav.example.com"
mock_settings.webdav_username = "user"
mock_settings.webdav_password = "pass"
assert _should_upload_to_webdav() is False
@patch("app.tasks.send_to_all.settings")
def test_ftp_disabled_with_credentials(self, mock_settings):
"""Test FTP upload is blocked when disabled even with valid credentials."""
mock_settings.ftp_enabled = False
mock_settings.ftp_host = "ftp.example.com"
mock_settings.ftp_username = "user"
mock_settings.ftp_password = "pass"
assert _should_upload_to_ftp() is False
@patch("app.tasks.send_to_all.settings")
def test_sftp_disabled_with_credentials(self, mock_settings):
"""Test SFTP upload is blocked when disabled even with valid credentials."""
mock_settings.sftp_enabled = False
mock_settings.sftp_host = "sftp.example.com"
mock_settings.sftp_username = "user"
mock_settings.sftp_password = "pass"
mock_settings.sftp_private_key = None
assert _should_upload_to_sftp() is False
@patch("app.tasks.send_to_all.settings")
def test_email_disabled_with_credentials(self, mock_settings):
"""Test email upload is blocked when disabled even with valid credentials."""
mock_settings.dest_email_enabled = False
mock_settings.dest_email_host = "smtp.example.com"
mock_settings.dest_email_username = "user"
mock_settings.dest_email_password = "pass"
mock_settings.dest_email_default_recipient = "recipient@example.com"
assert _should_upload_to_email() is False
@patch("app.tasks.send_to_all.settings")
def test_onedrive_disabled_with_credentials(self, mock_settings):
"""Test OneDrive upload is blocked when disabled even with valid credentials."""
mock_settings.onedrive_enabled = False
mock_settings.onedrive_client_id = "client_id"
mock_settings.onedrive_client_secret = "client_secret"
mock_settings.onedrive_refresh_token = "refresh_token"
assert _should_upload_to_onedrive() is False
@patch("app.tasks.send_to_all.settings")
def test_s3_disabled_with_credentials(self, mock_settings):
"""Test S3 upload is blocked when disabled even with valid credentials."""
mock_settings.s3_enabled = False
mock_settings.s3_bucket_name = "my-bucket"
mock_settings.aws_access_key_id = "key_id"
mock_settings.aws_secret_access_key = "secret_key"
assert _should_upload_to_s3() is False
@patch("app.tasks.send_to_all.settings")
def test_icloud_disabled_with_credentials(self, mock_settings):
"""Test iCloud upload is blocked when disabled even with valid credentials."""
mock_settings.icloud_enabled = False
mock_settings.icloud_username = "user@example.com"
mock_settings.icloud_password = "app-specific-password"
assert _should_upload_to_icloud() is False
@pytest.mark.unit
class TestGetConfiguredServicesFromValidator:
@@ -154,9 +286,9 @@ class TestGetConfiguredServicesFromValidator:
def test_returns_configured_services(self, mock_get_status):
"""Test that configured services are returned correctly."""
mock_get_status.return_value = {
"Dropbox": {"configured": True},
"NextCloud": {"configured": False},
"S3 Storage": {"configured": True},
"Dropbox": {"configured": True, "enabled": True},
"NextCloud": {"configured": False, "enabled": True},
"S3 Storage": {"configured": True, "enabled": True},
}
result = get_configured_services_from_validator()
@@ -169,7 +301,7 @@ class TestGetConfiguredServicesFromValidator:
def test_handles_missing_providers(self, mock_get_status):
"""Test handling when some providers are not in status."""
mock_get_status.return_value = {
"Dropbox": {"configured": True},
"Dropbox": {"configured": True, "enabled": True},
}
result = get_configured_services_from_validator()
@@ -177,6 +309,30 @@ class TestGetConfiguredServicesFromValidator:
assert result["dropbox"] is True
# Other services not in result
@patch("app.tasks.send_to_all.get_provider_status")
def test_configured_but_disabled_service_not_active(self, mock_get_status):
"""Test that a configured but disabled service is not returned as active."""
mock_get_status.return_value = {
"Dropbox": {"configured": True, "enabled": False},
"S3 Storage": {"configured": True, "enabled": True},
}
result = get_configured_services_from_validator()
assert result["dropbox"] is False
assert result["s3"] is True
@patch("app.tasks.send_to_all.get_provider_status")
def test_missing_enabled_field_defaults_to_true_for_backward_compatibility(self, mock_get_status):
"""Test that missing 'enabled' key defaults to True (backward compatible)."""
mock_get_status.return_value = {
"Dropbox": {"configured": True},
}
result = get_configured_services_from_validator()
assert result["dropbox"] is True
@pytest.mark.unit
class TestSendToAllDestinations:
@@ -204,12 +360,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_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all.upload_to_dropbox")
def test_queues_single_configured_service(
self,
mock_upload,
mock_s3,
mock_icloud,
mock_onedrive,
mock_email,
mock_sftp,
@@ -239,6 +397,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
mock_icloud.return_value = False
mock_upload.delay.return_value = MagicMock(id="task-123")
result = send_to_all_destinations.apply(args=[str(test_file), False, 1])
@@ -250,6 +409,7 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all.log_task_progress")
@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_s3")
@patch("app.tasks.send_to_all._should_upload_to_nextcloud")
@patch("app.tasks.send_to_all._should_upload_to_paperless")
@@ -274,6 +434,7 @@ class TestSendToAllDestinations:
mock_paperless,
mock_nextcloud,
mock_should_s3,
mock_icloud,
mock_should_dropbox,
mock_settings,
mock_log,
@@ -295,6 +456,7 @@ class TestSendToAllDestinations:
mock_sftp.return_value = False
mock_email.return_value = False
mock_onedrive.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")
@@ -316,10 +478,12 @@ 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_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
def test_skips_unconfigured_services(
self,
mock_s3,
mock_icloud,
mock_onedrive,
mock_email,
mock_sftp,
@@ -349,6 +513,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
mock_icloud.return_value = False
result = send_to_all_destinations.apply(args=[str(test_file), False, 1])
@@ -369,12 +534,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_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all.upload_to_dropbox")
def test_with_file_id_parameter(
self,
mock_upload,
mock_s3,
mock_icloud,
mock_onedrive,
mock_email,
mock_sftp,
@@ -404,6 +571,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
mock_icloud.return_value = False
mock_upload.delay.return_value = MagicMock(id="task-123")
result = send_to_all_destinations.apply(args=[str(test_file), False, 42])
@@ -425,6 +593,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_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all.get_configured_services_from_validator")
@patch("app.tasks.send_to_all.upload_to_dropbox")
@@ -433,6 +602,7 @@ class TestSendToAllDestinations:
mock_upload,
mock_validator,
mock_s3,
mock_icloud,
mock_onedrive,
mock_email,
mock_sftp,
@@ -463,6 +633,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
mock_icloud.return_value = False
mock_upload.delay.return_value = MagicMock(id="task-123")
result = send_to_all_destinations.apply(args=[str(test_file), True, 1])
@@ -482,12 +653,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_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all.get_configured_services_from_validator")
def test_validator_exception_fallback(
self,
mock_validator,
mock_s3,
mock_icloud,
mock_onedrive,
mock_email,
mock_sftp,
@@ -518,6 +691,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
mock_icloud.return_value = False
# Should not raise, should fall back to individual checks
result = send_to_all_destinations.apply(args=[str(test_file), True, 1])
@@ -536,12 +710,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_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all.upload_to_dropbox")
def test_handles_upload_task_queue_error(
self,
mock_upload,
mock_s3,
mock_icloud,
mock_onedrive,
mock_email,
mock_sftp,
@@ -571,6 +747,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
mock_icloud.return_value = False
mock_upload.delay.side_effect = Exception("Queue error")
# Should not raise, should log error
@@ -580,6 +757,7 @@ class TestSendToAllDestinations:
# Error should be recorded in results
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_s3")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
@patch("app.tasks.send_to_all._should_upload_to_email")
@@ -608,6 +786,7 @@ class TestSendToAllDestinations:
mock_email,
mock_onedrive,
mock_s3,
mock_icloud,
tmp_path,
):
"""Test file_id lookup fallback when not provided."""
@@ -629,6 +808,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
mock_icloud.return_value = False
# Mock database session
mock_db = MagicMock()
@@ -656,10 +836,12 @@ 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_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
def test_should_upload_check_exception_handling(
self,
mock_s3,
mock_icloud,
mock_onedrive,
mock_email,
mock_sftp,
@@ -689,6 +871,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
mock_icloud.return_value = False
# Should not raise, should treat as not configured
result = send_to_all_destinations.apply(args=[str(test_file), False, 1])
+505
View File
@@ -0,0 +1,505 @@
"""Tests for social login functionality in app/auth.py."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import Request, status
from starlette.responses import RedirectResponse
@pytest.mark.unit
class TestSocialProviders:
"""Tests for SOCIAL_PROVIDERS dictionary population."""
def test_social_providers_is_dict(self):
"""Test that SOCIAL_PROVIDERS is a dict."""
from app.auth import SOCIAL_PROVIDERS
assert isinstance(SOCIAL_PROVIDERS, dict)
def test_social_providers_empty_by_default(self):
"""Test that no social providers are enabled by default (settings have enabled=False)."""
# In test environment, social login settings are not set, so the dict should be empty
from app.auth import SOCIAL_PROVIDERS
# Since tests run with default settings (all social providers disabled),
# SOCIAL_PROVIDERS should be empty
assert isinstance(SOCIAL_PROVIDERS, dict)
@pytest.mark.unit
class TestSocialLogin:
"""Tests for social_login() function."""
@pytest.mark.asyncio
async def test_social_login_unknown_provider(self):
"""Test social_login redirects when provider is unknown."""
from app.auth import social_login
mock_request = MagicMock(spec=Request)
with patch("app.auth.SOCIAL_PROVIDERS", {}):
result = await social_login(mock_request, "unknown_provider")
assert isinstance(result, RedirectResponse)
assert result.status_code == status.HTTP_302_FOUND
assert "/login?error=Unknown+social+provider" in result.headers["location"]
@pytest.mark.asyncio
async def test_social_login_provider_not_in_oauth(self):
"""Test social_login redirects when provider is registered but OAuth client is missing."""
from app.auth import social_login
mock_request = MagicMock(spec=Request)
with (
patch("app.auth.SOCIAL_PROVIDERS", {"google": {"name": "Google", "icon": "fab fa-google", "color": "red"}}),
patch("app.auth.oauth") as mock_oauth,
):
mock_oauth.google = None
result = await social_login(mock_request, "google")
assert isinstance(result, RedirectResponse)
assert "/login?error=Provider+not+configured" in result.headers["location"]
@pytest.mark.asyncio
async def test_social_login_initiates_redirect(self):
"""Test social_login initiates OAuth redirect for a valid provider."""
from app.auth import social_login
mock_request = MagicMock(spec=Request)
mock_request.url_for = MagicMock(return_value="http://localhost/social-callback/google")
mock_google = MagicMock()
mock_google.authorize_redirect = AsyncMock(return_value="google_redirect")
with (
patch("app.auth.SOCIAL_PROVIDERS", {"google": {"name": "Google", "icon": "fab fa-google", "color": "red"}}),
patch("app.auth.oauth") as mock_oauth,
):
mock_oauth.google = mock_google
result = await social_login(mock_request, "google")
assert result == "google_redirect"
mock_google.authorize_redirect.assert_called_once_with(
mock_request, "http://localhost/social-callback/google"
)
@pytest.mark.unit
class TestNormalizeSocialUserinfo:
"""Tests for _normalize_social_userinfo()."""
def test_normalize_google_userinfo(self):
"""Test normalizing Google OIDC userinfo."""
from app.auth import _normalize_social_userinfo
raw = {
"sub": "123456789",
"email": "user@gmail.com",
"name": "Test User",
"picture": "https://lh3.googleusercontent.com/photo.jpg",
}
result = _normalize_social_userinfo("google", {}, raw)
assert result["sub"] == "123456789"
assert result["email"] == "user@gmail.com"
assert result["name"] == "Test User"
assert result["preferred_username"] == "user@gmail.com"
assert result["picture"] == "https://lh3.googleusercontent.com/photo.jpg"
def test_normalize_microsoft_userinfo(self):
"""Test normalizing Microsoft OIDC userinfo."""
from app.auth import _normalize_social_userinfo
raw = {
"sub": "ms-sub-123",
"email": "user@outlook.com",
"name": "MS User",
}
result = _normalize_social_userinfo("microsoft", {}, raw)
assert result["sub"] == "ms-sub-123"
assert result["email"] == "user@outlook.com"
assert result["name"] == "MS User"
assert result["preferred_username"] == "user@outlook.com"
def test_normalize_apple_userinfo(self):
"""Test normalizing Apple OIDC userinfo."""
from app.auth import _normalize_social_userinfo
raw = {
"sub": "apple-sub-456",
"email": "user@privaterelay.appleid.com",
}
result = _normalize_social_userinfo("apple", {}, raw)
assert result["sub"] == "apple-sub-456"
assert result["email"] == "user@privaterelay.appleid.com"
def test_normalize_dropbox_userinfo(self):
"""Test normalizing Dropbox non-standard userinfo."""
from app.auth import _normalize_social_userinfo
raw = {
"account_id": "dbid:AABcDEfGhIjKlMnOpQr",
"email": "user@example.com",
"name": {"display_name": "Dropbox User"},
"profile_photo_url": "https://dropbox.com/photo.jpg",
}
result = _normalize_social_userinfo("dropbox", {}, raw)
assert result["sub"] == "dbid:AABcDEfGhIjKlMnOpQr"
assert result["email"] == "user@example.com"
assert result["name"] == "Dropbox User"
assert result["picture"] == "https://dropbox.com/photo.jpg"
def test_normalize_dropbox_missing_fields(self):
"""Test normalizing Dropbox userinfo with missing fields."""
from app.auth import _normalize_social_userinfo
raw = {"email": "user@example.com"}
result = _normalize_social_userinfo("dropbox", {}, raw)
assert result["sub"] == "user@example.com" # Falls back to email
assert result["email"] == "user@example.com"
assert result["name"] == ""
def test_normalize_with_none_userinfo(self):
"""Test normalizing when userinfo is None."""
from app.auth import _normalize_social_userinfo
result = _normalize_social_userinfo("google", {}, None)
assert result["sub"] == ""
assert result["email"] == ""
assert result["name"] == ""
@pytest.mark.unit
class TestSocialCallback:
"""Tests for social_callback() function."""
@pytest.mark.asyncio
async def test_social_callback_unknown_provider(self):
"""Test social_callback redirects when provider is unknown."""
from app.auth import social_callback
mock_request = MagicMock(spec=Request)
mock_db = MagicMock()
with patch("app.auth.SOCIAL_PROVIDERS", {}):
result = await social_callback(mock_request, "unknown_provider", db=mock_db)
assert isinstance(result, RedirectResponse)
assert "/login?error=Unknown+social+provider" in result.headers["location"]
@pytest.mark.asyncio
async def test_social_callback_provider_not_configured(self):
"""Test social_callback redirects when OAuth client is missing."""
from app.auth import social_callback
mock_request = MagicMock(spec=Request)
mock_db = MagicMock()
with (
patch("app.auth.SOCIAL_PROVIDERS", {"google": {"name": "Google", "icon": "fab fa-google", "color": "red"}}),
patch("app.auth.oauth") as mock_oauth,
):
mock_oauth.google = None
result = await social_callback(mock_request, "google", db=mock_db)
assert isinstance(result, RedirectResponse)
assert "/login?error=Provider+not+configured" in result.headers["location"]
@pytest.mark.asyncio
async def test_social_callback_success_google(self):
"""Test successful Google social callback flow."""
from app.auth import social_callback
mock_request = MagicMock(spec=Request)
mock_request.session = {}
mock_db = MagicMock()
mock_google = MagicMock()
mock_google.authorize_access_token = AsyncMock(
return_value={
"userinfo": {
"sub": "google-123",
"email": "testuser@gmail.com",
"name": "Test User",
"picture": "https://example.com/photo.jpg",
}
}
)
mock_profile = MagicMock()
mock_profile.onboarding_completed = True
with (
patch("app.auth.SOCIAL_PROVIDERS", {"google": {"name": "Google", "icon": "fab fa-google", "color": "red"}}),
patch("app.auth.oauth") as mock_oauth,
patch("app.auth._ensure_user_profile"),
patch("app.auth._UserProfile") as mock_user_profile_cls,
):
mock_oauth.google = mock_google
mock_db.query.return_value.filter.return_value.first.return_value = mock_profile
result = await social_callback(mock_request, "google", db=mock_db)
assert isinstance(result, RedirectResponse)
# Verify session was set
assert mock_request.session["user"]["email"] == "testuser@gmail.com"
assert mock_request.session["user"]["auth_provider"] == "google"
assert mock_request.session["user"]["is_admin"] is False
@pytest.mark.asyncio
async def test_social_callback_no_email(self):
"""Test social callback when provider doesn't return email."""
from app.auth import social_callback
mock_request = MagicMock(spec=Request)
mock_request.session = {}
mock_db = MagicMock()
mock_google = MagicMock()
mock_google.authorize_access_token = AsyncMock(
return_value={
"userinfo": {
"sub": "google-123",
# No email!
"name": "Test User",
}
}
)
with (
patch("app.auth.SOCIAL_PROVIDERS", {"google": {"name": "Google", "icon": "fab fa-google", "color": "red"}}),
patch("app.auth.oauth") as mock_oauth,
):
mock_oauth.google = mock_google
result = await social_callback(mock_request, "google", db=mock_db)
assert isinstance(result, RedirectResponse)
assert "/login?error=Could+not+retrieve+email+from+provider" in result.headers["location"]
@pytest.mark.asyncio
async def test_social_callback_exception_handling(self):
"""Test social callback handles exceptions gracefully."""
from app.auth import social_callback
mock_request = MagicMock(spec=Request)
mock_request.session = {}
mock_db = MagicMock()
mock_google = MagicMock()
mock_google.authorize_access_token = AsyncMock(side_effect=Exception("Token exchange failed"))
with (
patch("app.auth.SOCIAL_PROVIDERS", {"google": {"name": "Google", "icon": "fab fa-google", "color": "red"}}),
patch("app.auth.oauth") as mock_oauth,
):
mock_oauth.google = mock_google
result = await social_callback(mock_request, "google", db=mock_db)
assert isinstance(result, RedirectResponse)
assert "/login?error=Social+login+failed" in result.headers["location"]
# Ensure internal exception details are not exposed to the user
assert "Exception" not in result.headers["location"]
@pytest.mark.unit
class TestLoginPageSocialProviders:
"""Tests for login page rendering with social providers."""
@pytest.mark.asyncio
async def test_login_page_includes_social_providers(self):
"""Test login page passes social_providers to template."""
mock_providers = {
"google": {"name": "Google", "icon": "fab fa-google", "color": "red"},
"microsoft": {"name": "Microsoft", "icon": "fab fa-microsoft", "color": "blue"},
}
with (
patch("app.auth.AUTH_ENABLED", True),
patch("app.auth.OAUTH_CONFIGURED", False),
patch("app.auth.SOCIAL_PROVIDERS", mock_providers),
patch("app.auth.templates") as mock_templates,
patch("app.auth.settings") as mock_settings,
):
mock_settings.version = "1.0.0"
mock_settings.multi_user_enabled = False
mock_settings.allow_local_signup = False
from app.auth import login
mock_request = MagicMock()
mock_request.query_params.get.return_value = None
await login(mock_request)
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
context = call_args[0][1]
assert context["social_providers"] == mock_providers
@pytest.mark.asyncio
async def test_login_page_empty_social_providers(self):
"""Test login page with no social providers configured."""
with (
patch("app.auth.AUTH_ENABLED", True),
patch("app.auth.OAUTH_CONFIGURED", False),
patch("app.auth.SOCIAL_PROVIDERS", {}),
patch("app.auth.templates") as mock_templates,
patch("app.auth.settings") as mock_settings,
):
mock_settings.version = "1.0.0"
mock_settings.multi_user_enabled = False
mock_settings.allow_local_signup = False
from app.auth import login
mock_request = MagicMock()
mock_request.query_params.get.return_value = None
await login(mock_request)
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
context = call_args[0][1]
assert context["social_providers"] == {}
@pytest.mark.unit
class TestConfigValidatorSocialLogin:
"""Tests for config validator social login checks."""
def test_social_login_counts_as_valid_auth(self):
"""Test that enabled social login prevents 'neither auth configured' warning."""
from app.utils.config_validator.validators import validate_auth_config
with patch("app.utils.config_validator.validators.settings") as mock_settings:
mock_settings.auth_enabled = True
mock_settings.session_secret = "a" * 32
mock_settings.admin_username = None
mock_settings.admin_password = None
mock_settings.authentik_client_id = None
mock_settings.authentik_client_secret = None
mock_settings.authentik_config_url = None
mock_settings.oauth_provider_name = None
mock_settings.social_auth_google_enabled = True
mock_settings.social_auth_google_client_id = "test-id"
mock_settings.social_auth_google_client_secret = "test-secret"
mock_settings.social_auth_microsoft_enabled = False
mock_settings.social_auth_apple_enabled = False
mock_settings.social_auth_dropbox_enabled = False
issues = validate_auth_config()
# Should NOT contain the "neither...configured" message
assert not any("Neither" in issue for issue in issues)
def test_social_login_missing_credentials_reported(self):
"""Test that enabled social login without credentials is reported."""
from app.utils.config_validator.validators import validate_auth_config
with patch("app.utils.config_validator.validators.settings") as mock_settings:
mock_settings.auth_enabled = True
mock_settings.session_secret = "a" * 32
mock_settings.admin_username = "admin"
mock_settings.admin_password = "pass"
mock_settings.authentik_client_id = None
mock_settings.authentik_client_secret = None
mock_settings.authentik_config_url = None
mock_settings.oauth_provider_name = None
mock_settings.social_auth_google_enabled = True
mock_settings.social_auth_google_client_id = None # Missing!
mock_settings.social_auth_google_client_secret = None # Missing!
mock_settings.social_auth_microsoft_enabled = False
mock_settings.social_auth_apple_enabled = False
mock_settings.social_auth_dropbox_enabled = False
issues = validate_auth_config()
assert any("SOCIAL_AUTH_GOOGLE_CLIENT_ID" in issue for issue in issues)
assert any("SOCIAL_AUTH_GOOGLE_CLIENT_SECRET" in issue for issue in issues)
def test_microsoft_missing_credentials(self):
"""Test Microsoft login validation when credentials are missing."""
from app.utils.config_validator.validators import validate_auth_config
with patch("app.utils.config_validator.validators.settings") as mock_settings:
mock_settings.auth_enabled = True
mock_settings.session_secret = "a" * 32
mock_settings.admin_username = "admin"
mock_settings.admin_password = "pass"
mock_settings.authentik_client_id = None
mock_settings.authentik_client_secret = None
mock_settings.authentik_config_url = None
mock_settings.oauth_provider_name = None
mock_settings.social_auth_google_enabled = False
mock_settings.social_auth_microsoft_enabled = True
mock_settings.social_auth_microsoft_client_id = None
mock_settings.social_auth_microsoft_client_secret = None
mock_settings.social_auth_apple_enabled = False
mock_settings.social_auth_dropbox_enabled = False
issues = validate_auth_config()
assert any("SOCIAL_AUTH_MICROSOFT_CLIENT_ID" in issue for issue in issues)
assert any("SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET" in issue for issue in issues)
def test_apple_missing_credentials(self):
"""Test Apple login validation when credentials are missing."""
from app.utils.config_validator.validators import validate_auth_config
with patch("app.utils.config_validator.validators.settings") as mock_settings:
mock_settings.auth_enabled = True
mock_settings.session_secret = "a" * 32
mock_settings.admin_username = "admin"
mock_settings.admin_password = "pass"
mock_settings.authentik_client_id = None
mock_settings.authentik_client_secret = None
mock_settings.authentik_config_url = None
mock_settings.oauth_provider_name = None
mock_settings.social_auth_google_enabled = False
mock_settings.social_auth_microsoft_enabled = False
mock_settings.social_auth_apple_enabled = True
mock_settings.social_auth_apple_client_id = None
mock_settings.social_auth_apple_team_id = None
mock_settings.social_auth_dropbox_enabled = False
issues = validate_auth_config()
assert any("SOCIAL_AUTH_APPLE_CLIENT_ID" in issue for issue in issues)
assert any("SOCIAL_AUTH_APPLE_TEAM_ID" in issue for issue in issues)
def test_dropbox_missing_credentials(self):
"""Test Dropbox login validation when credentials are missing."""
from app.utils.config_validator.validators import validate_auth_config
with patch("app.utils.config_validator.validators.settings") as mock_settings:
mock_settings.auth_enabled = True
mock_settings.session_secret = "a" * 32
mock_settings.admin_username = "admin"
mock_settings.admin_password = "pass"
mock_settings.authentik_client_id = None
mock_settings.authentik_client_secret = None
mock_settings.authentik_config_url = None
mock_settings.oauth_provider_name = None
mock_settings.social_auth_google_enabled = False
mock_settings.social_auth_microsoft_enabled = False
mock_settings.social_auth_apple_enabled = False
mock_settings.social_auth_dropbox_enabled = True
mock_settings.social_auth_dropbox_client_id = None
mock_settings.social_auth_dropbox_client_secret = None
issues = validate_auth_config()
assert any("SOCIAL_AUTH_DROPBOX_CLIENT_ID" in issue for issue in issues)
assert any("SOCIAL_AUTH_DROPBOX_CLIENT_SECRET" in issue for issue in issues)
+205
View File
@@ -0,0 +1,205 @@
"""Unit tests for the iCloud Drive upload task and helper functions.
Tests cover the global upload task (``upload_to_icloud``) as well as the
per-user integration handler (``_upload_icloud`` in
``upload_to_user_integration``). All external calls to ``pyicloud`` are
mocked so tests are fast, hermetic, and free of network access.
"""
import os
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
TASK_ID = "test-icloud-task-id"
def _write_file(path, content: bytes = b"PDF content") -> None:
"""Write *content* to *path*, creating parent dirs as needed."""
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as fh:
fh.write(content)
def _mock_pyicloud_module(mock_api):
"""Return a mock ``pyicloud`` module whose ``PyiCloudService`` returns *mock_api*."""
mock_mod = MagicMock()
mock_mod.PyiCloudService.return_value = mock_api
return mock_mod
# ---------------------------------------------------------------------------
# _get_icloud_api
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetIcloudApi:
"""Tests for the _get_icloud_api helper."""
def test_returns_authenticated_api(self):
mock_api = MagicMock()
mock_api.requires_2sa = False
mock_api.requires_2fa = False
with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}):
from app.tasks.upload_to_icloud import _get_icloud_api
result = _get_icloud_api("user@example.com", "secret")
assert result is mock_api
def test_passes_cookie_directory(self):
mock_api = MagicMock()
mock_api.requires_2sa = False
mock_api.requires_2fa = False
mock_mod = _mock_pyicloud_module(mock_api)
with patch.dict("sys.modules", {"pyicloud": mock_mod}):
from app.tasks.upload_to_icloud import _get_icloud_api
_get_icloud_api("user@example.com", "secret", "/tmp/cookies")
mock_mod.PyiCloudService.assert_called_once_with("user@example.com", "secret", cookie_directory="/tmp/cookies")
def test_raises_on_2fa_required(self):
mock_api = MagicMock()
mock_api.requires_2sa = False
mock_api.requires_2fa = True
with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}):
from app.tasks.upload_to_icloud import _get_icloud_api
with pytest.raises(ValueError, match="two-factor authentication"):
_get_icloud_api("user@example.com", "secret")
def test_raises_on_2sa_required(self):
mock_api = MagicMock()
mock_api.requires_2sa = True
mock_api.requires_2fa = False
with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}):
from app.tasks.upload_to_icloud import _get_icloud_api
with pytest.raises(ValueError, match="two-factor authentication"):
_get_icloud_api("user@example.com", "secret")
# ---------------------------------------------------------------------------
# _navigate_to_folder
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestNavigateToFolder:
"""Tests for the _navigate_to_folder helper."""
def test_empty_path_returns_root(self):
from app.tasks.upload_to_icloud import _navigate_to_folder
root = MagicMock()
result = _navigate_to_folder(root, "")
assert result is root
def test_navigates_existing_folders(self):
from app.tasks.upload_to_icloud import _navigate_to_folder
# Build a mock folder tree: root -> Documents -> Uploads
uploads_node = MagicMock()
uploads_node.name = "Uploads"
docs_node = MagicMock()
docs_node.name = "Documents"
docs_node.dir.return_value = [uploads_node]
root = MagicMock()
root.dir.return_value = [docs_node]
result = _navigate_to_folder(root, "Documents/Uploads")
assert result is uploads_node
def test_creates_missing_folder(self):
from app.tasks.upload_to_icloud import _navigate_to_folder
new_folder = MagicMock()
root = MagicMock()
root.dir.return_value = [] # No children
root.mkdir.return_value = new_folder
result = _navigate_to_folder(root, "NewFolder")
root.mkdir.assert_called_once_with("NewFolder")
assert result is new_folder
# ---------------------------------------------------------------------------
# _upload_icloud (user integration handler)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestUploadIcloudHandler:
"""Tests for _upload_icloud handler in upload_to_user_integration."""
def _call(self, file_path: str, cfg: dict, creds: dict) -> dict:
from app.tasks.upload_to_user_integration import _upload_icloud
return _upload_icloud(file_path, cfg, creds, TASK_ID)
def test_raises_when_credentials_missing(self, tmp_path):
fp = str(tmp_path / "doc.pdf")
_write_file(fp)
with pytest.raises(ValueError, match="username or password"):
self._call(fp, {}, {})
def test_raises_when_password_missing(self, tmp_path):
fp = str(tmp_path / "doc.pdf")
_write_file(fp)
with pytest.raises(ValueError, match="username or password"):
self._call(fp, {}, {"username": "user@example.com"})
def test_successful_upload(self, tmp_path):
fp = str(tmp_path / "doc.pdf")
_write_file(fp)
mock_api = MagicMock()
mock_api.requires_2sa = False
mock_api.requires_2fa = False
# drive.dir() returns nothing -> mkdir will be called
mock_folder = MagicMock()
mock_api.drive.dir.return_value = []
mock_api.drive.mkdir.return_value = mock_folder
with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}):
result = self._call(
fp,
{"folder": "Documents"},
{"username": "user@example.com", "password": "secret"},
)
assert result["status"] == "Completed"
assert result["icloud_folder"] == "Documents"
mock_folder.upload.assert_called_once()
def test_upload_to_root_when_no_folder(self, tmp_path):
fp = str(tmp_path / "doc.pdf")
_write_file(fp)
mock_api = MagicMock()
mock_api.requires_2sa = False
mock_api.requires_2fa = False
with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}):
result = self._call(
fp,
{},
{"username": "user@example.com", "password": "secret"},
)
assert result["status"] == "Completed"
assert result["icloud_folder"] == "/"
mock_api.drive.upload.assert_called_once()
+635
View File
@@ -0,0 +1,635 @@
"""Tests for app/utils/user_notification.py.
Covers all previously-uncovered branches:
- create_in_app_notification: exception/rollback path
- _send_email_notification: full SMTP success path, TLS disabled, no credentials
- _send_webhook_notification: success path with/without secret header
- dispatch_user_notification: preference loop (email, webhook), no target_id,
inactive target, invalid/empty JSON config, JSON decode error, outer exception
- dispatch_user_notification: push notification path (success and exception)
- notify_user_document_processed / notify_user_document_failed: happy-path smoke tests
"""
import json
from unittest.mock import MagicMock, 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 InAppNotification, UserNotificationPreference, UserNotificationTarget
_OWNER = "dispatch-test-user@example.com"
# ---------------------------------------------------------------------------
# Shared fixture helpers
# ---------------------------------------------------------------------------
@pytest.fixture()
def mem_engine():
"""In-memory SQLite engine for user_notification tests."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def Session(mem_engine): # noqa: N802
"""Session factory bound to mem_engine."""
return sessionmaker(bind=mem_engine)
# ---------------------------------------------------------------------------
# create_in_app_notification
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestCreateInAppNotification:
"""Tests for create_in_app_notification()."""
def test_returns_none_on_db_exception(self, Session):
"""create_in_app_notification should return None and rollback on error."""
from app.utils.user_notification import create_in_app_notification
# Provide a session whose commit raises to exercise the except branch
bad_session = MagicMock()
bad_session.add = MagicMock()
bad_session.commit = MagicMock(side_effect=RuntimeError("DB is down"))
bad_session.rollback = MagicMock()
bad_session.close = MagicMock()
BadSession = MagicMock(return_value=bad_session) # noqa: N806
with patch("app.utils.user_notification.SessionLocal", BadSession):
result = create_in_app_notification(
owner_id=_OWNER,
event_type="document.processed",
title="Oops",
message="Something went wrong",
)
assert result is None
bad_session.rollback.assert_called_once()
bad_session.close.assert_called_once()
# ---------------------------------------------------------------------------
# _send_email_notification
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSendEmailNotification:
"""Tests for _send_email_notification()."""
def test_success_with_tls_and_credentials(self):
"""Email is sent with STARTTLS and login when fully configured."""
from app.utils.user_notification import _send_email_notification
config = {
"smtp_host": "smtp.example.com",
"smtp_port": "587",
"smtp_username": "user@example.com",
"smtp_password": "secret",
"smtp_use_tls": True,
"recipient_email": "dest@example.com",
}
mock_server = MagicMock()
mock_smtp_cls = MagicMock(return_value=mock_server)
mock_server.__enter__ = MagicMock(return_value=mock_server)
mock_server.__exit__ = MagicMock(return_value=False)
with patch("app.utils.user_notification.smtplib.SMTP", mock_smtp_cls):
result = _send_email_notification(config, "Subject", "Body text")
assert result is True
mock_server.starttls.assert_called_once()
mock_server.login.assert_called_once_with("user@example.com", "secret")
mock_server.send_message.assert_called_once()
def test_success_without_tls_and_without_credentials(self):
"""Email sent without STARTTLS and login when tls=False and no creds."""
from app.utils.user_notification import _send_email_notification
config = {
"smtp_host": "relay.internal",
"smtp_port": 25,
"smtp_use_tls": False,
"recipient_email": "dest@example.com",
}
mock_server = MagicMock()
mock_smtp_cls = MagicMock(return_value=mock_server)
mock_server.__enter__ = MagicMock(return_value=mock_server)
mock_server.__exit__ = MagicMock(return_value=False)
with patch("app.utils.user_notification.smtplib.SMTP", mock_smtp_cls):
result = _send_email_notification(config, "Subject", "No TLS body")
assert result is True
mock_server.starttls.assert_not_called()
mock_server.login.assert_not_called()
mock_server.send_message.assert_called_once()
def test_returns_false_on_smtp_exception(self):
"""_send_email_notification returns False when SMTP.connect raises."""
from app.utils.user_notification import _send_email_notification
config = {
"smtp_host": "smtp.example.com",
"smtp_port": 587,
"recipient_email": "dest@example.com",
}
with patch(
"app.utils.user_notification.smtplib.SMTP",
side_effect=ConnectionRefusedError("refused"),
):
result = _send_email_notification(config, "Subject", "Body")
assert result is False
def test_sender_email_defaults_to_smtp_username(self):
"""When sender_email is absent the smtp_username is used as From."""
from app.utils.user_notification import _send_email_notification
captured_msgs = []
config = {
"smtp_host": "smtp.example.com",
"smtp_port": 587,
"smtp_username": "sender@example.com",
"smtp_use_tls": False,
"recipient_email": "dest@example.com",
}
mock_server = MagicMock()
def capture_send(msg):
captured_msgs.append(msg)
mock_server.send_message = capture_send
mock_server.__enter__ = MagicMock(return_value=mock_server)
mock_server.__exit__ = MagicMock(return_value=False)
with patch("app.utils.user_notification.smtplib.SMTP", return_value=mock_server):
result = _send_email_notification(config, "Hi", "Body")
assert result is True
assert captured_msgs[0]["From"] == "sender@example.com"
def test_sender_email_defaults_to_noreply_when_no_username(self):
"""When no sender_email and no smtp_username, From falls back to noreply."""
from app.utils.user_notification import _send_email_notification
captured_msgs = []
config = {
"smtp_host": "smtp.example.com",
"smtp_port": 25,
"smtp_use_tls": False,
"recipient_email": "dest@example.com",
}
mock_server = MagicMock()
def capture_send(msg):
captured_msgs.append(msg)
mock_server.send_message = capture_send
mock_server.__enter__ = MagicMock(return_value=mock_server)
mock_server.__exit__ = MagicMock(return_value=False)
with patch("app.utils.user_notification.smtplib.SMTP", return_value=mock_server):
result = _send_email_notification(config, "Hi", "Body")
assert result is True
assert captured_msgs[0]["From"] == "noreply@docuelevate.local"
# ---------------------------------------------------------------------------
# _send_webhook_notification
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSendWebhookNotification:
"""Tests for _send_webhook_notification()."""
def test_success_with_secret_header(self):
"""Webhook sent and X-DocuElevate-Secret header set when secret provided."""
from app.utils.user_notification import _send_webhook_notification
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.raise_for_status = MagicMock()
with patch("app.utils.user_notification.httpx.post", return_value=mock_response) as mock_post:
result = _send_webhook_notification(
{"url": "https://hook.example.com/test", "secret": "mysecret"},
"document.processed",
"Title",
"Body",
)
assert result is True
_, kwargs = mock_post.call_args
assert kwargs["headers"]["X-DocuElevate-Secret"] == "mysecret"
assert kwargs["json"]["event"] == "document.processed"
def test_success_without_secret(self):
"""Webhook sent without X-DocuElevate-Secret header when no secret."""
from app.utils.user_notification import _send_webhook_notification
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.raise_for_status = MagicMock()
with patch("app.utils.user_notification.httpx.post", return_value=mock_response) as mock_post:
result = _send_webhook_notification(
{"url": "https://hook.example.com/test"},
"document.failed",
"Failed",
"Error details",
)
assert result is True
_, kwargs = mock_post.call_args
assert "X-DocuElevate-Secret" not in kwargs["headers"]
def test_returns_false_on_http_error(self):
"""_send_webhook_notification returns False when httpx raises."""
from app.utils.user_notification import _send_webhook_notification
with patch(
"app.utils.user_notification.httpx.post",
side_effect=Exception("connection error"),
):
result = _send_webhook_notification(
{"url": "https://hook.example.com/test"},
"document.processed",
"T",
"M",
)
assert result is False
def test_returns_false_on_raise_for_status(self):
"""Returns False when response.raise_for_status() throws."""
import httpx as _httpx
from app.utils.user_notification import _send_webhook_notification
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock(
side_effect=_httpx.HTTPStatusError(
"400",
request=MagicMock(),
response=MagicMock(),
)
)
with patch("app.utils.user_notification.httpx.post", return_value=mock_response):
result = _send_webhook_notification(
{"url": "https://hook.example.com/test"},
"document.processed",
"T",
"M",
)
assert result is False
# ---------------------------------------------------------------------------
# dispatch_user_notification preference loop
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDispatchUserNotification:
"""Tests for dispatch_user_notification() preference dispatch logic."""
def _make_target(self, session, channel_type: str, config_dict: dict | None = None, is_active: bool = True):
target = UserNotificationTarget(
owner_id=_OWNER,
channel_type=channel_type,
name=f"{channel_type}-target",
config=json.dumps(config_dict) if config_dict is not None else None,
is_active=is_active,
)
session.add(target)
session.commit()
session.refresh(target)
return target
def _make_pref(self, session, channel_type: str, target_id: int | None, is_enabled: bool = True):
pref = UserNotificationPreference(
owner_id=_OWNER,
event_type="document.processed",
channel_type=channel_type,
target_id=target_id,
is_enabled=is_enabled,
)
session.add(pref)
session.commit()
return pref
def test_dispatches_email_when_pref_enabled(self, Session):
"""Email notification is sent for an active email preference."""
s = Session()
target = self._make_target(
s,
"email",
{
"smtp_host": "smtp.example.com",
"recipient_email": "u@example.com",
"smtp_use_tls": False,
},
)
self._make_pref(s, "email", target.id)
s.close()
with (
patch("app.utils.user_notification.SessionLocal", Session),
patch("app.utils.user_notification._send_email_notification", return_value=True) as mock_email,
patch("app.utils.push_notification.send_push_to_owner"),
):
from app.utils.user_notification import dispatch_user_notification
dispatch_user_notification(_OWNER, "document.processed", "Title", "Body")
mock_email.assert_called_once()
def test_dispatches_webhook_when_pref_enabled(self, Session):
"""Webhook notification is sent for an active webhook preference."""
s = Session()
target = self._make_target(s, "webhook", {"url": "https://hook.example.com"})
self._make_pref(s, "webhook", target.id)
s.close()
with (
patch("app.utils.user_notification.SessionLocal", Session),
patch("app.utils.user_notification._send_webhook_notification", return_value=True) as mock_hook,
patch("app.utils.push_notification.send_push_to_owner"),
):
from app.utils.user_notification import dispatch_user_notification
dispatch_user_notification(_OWNER, "document.processed", "Title", "Body")
mock_hook.assert_called_once()
def test_skips_pref_with_no_target_id(self, Session):
"""Preferences without a target_id are skipped (in-app only)."""
s = Session()
self._make_pref(s, "email", target_id=None)
s.close()
with (
patch("app.utils.user_notification.SessionLocal", Session),
patch("app.utils.user_notification._send_email_notification") as mock_email,
patch("app.utils.push_notification.send_push_to_owner"),
):
from app.utils.user_notification import dispatch_user_notification
dispatch_user_notification(_OWNER, "document.processed", "Title", "Body")
mock_email.assert_not_called()
def test_skips_inactive_target(self, Session):
"""Preferences pointing at an inactive target are skipped."""
s = Session()
target = self._make_target(s, "email", {"smtp_host": "x", "recipient_email": "y"}, is_active=False)
self._make_pref(s, "email", target.id)
s.close()
with (
patch("app.utils.user_notification.SessionLocal", Session),
patch("app.utils.user_notification._send_email_notification") as mock_email,
patch("app.utils.push_notification.send_push_to_owner"),
):
from app.utils.user_notification import dispatch_user_notification
dispatch_user_notification(_OWNER, "document.processed", "Title", "Body")
mock_email.assert_not_called()
def test_handles_invalid_json_config(self, Session):
"""Invalid JSON in target.config falls back to empty dict (no crash)."""
target = UserNotificationTarget(
owner_id=_OWNER,
channel_type="email",
name="bad-config-target",
config="NOT_VALID_JSON",
is_active=True,
)
s = Session()
s.add(target)
s.commit()
s.refresh(target)
self._make_pref(s, "email", target.id)
s.close()
with (
patch("app.utils.user_notification.SessionLocal", Session),
patch("app.utils.user_notification._send_email_notification", return_value=False) as mock_email,
patch("app.utils.push_notification.send_push_to_owner"),
):
from app.utils.user_notification import dispatch_user_notification
# Should not raise even though config is bad JSON
dispatch_user_notification(_OWNER, "document.processed", "Title", "Body")
# Called with empty config dict, which is missing smtp_host → returns False
mock_email.assert_called_once_with({}, "Title", "Body")
def test_handles_null_config(self, Session):
"""NULL target.config is treated as empty dict (no crash)."""
target = UserNotificationTarget(
owner_id=_OWNER,
channel_type="webhook",
name="null-config-target",
config=None,
is_active=True,
)
s = Session()
s.add(target)
s.commit()
s.refresh(target)
self._make_pref(s, "webhook", target.id)
s.close()
with (
patch("app.utils.user_notification.SessionLocal", Session),
patch("app.utils.user_notification._send_webhook_notification", return_value=False) as mock_hook,
patch("app.utils.push_notification.send_push_to_owner"),
):
from app.utils.user_notification import dispatch_user_notification
dispatch_user_notification(_OWNER, "document.processed", "Title", "Body")
mock_hook.assert_called_once_with({}, "document.processed", "Title", "Body")
def test_outer_exception_does_not_propagate(self):
"""An exception in the preference query must be caught and logged."""
bad_session = MagicMock()
bad_session.query = MagicMock(side_effect=RuntimeError("DB gone"))
bad_session.add = MagicMock()
bad_session.commit = MagicMock()
bad_session.refresh = MagicMock(return_value=MagicMock())
bad_session.close = MagicMock()
BadSession = MagicMock(return_value=bad_session) # noqa: N806
with (
patch("app.utils.user_notification.SessionLocal", BadSession),
patch("app.utils.push_notification.send_push_to_owner"),
):
from app.utils.user_notification import dispatch_user_notification
# Must not raise
dispatch_user_notification(_OWNER, "document.processed", "Title", "Body")
def test_push_notification_sent(self, Session):
"""Push notification is sent via send_push_to_owner."""
with (
patch("app.utils.user_notification.SessionLocal", Session),
patch("app.utils.push_notification.send_push_to_owner") as mock_push,
):
from app.utils.user_notification import dispatch_user_notification
dispatch_user_notification(_OWNER, "document.processed", "Push Title", "Push Body", file_id=99)
mock_push.assert_called_once_with(
owner_id=_OWNER,
title="Push Title",
body="Push Body",
data={"event_type": "document.processed", "file_id": 99},
)
def test_push_exception_does_not_propagate(self, Session):
"""An exception in send_push_to_owner must be caught and logged."""
with (
patch("app.utils.user_notification.SessionLocal", Session),
patch(
"app.utils.push_notification.send_push_to_owner",
side_effect=RuntimeError("push service down"),
),
):
from app.utils.user_notification import dispatch_user_notification
# Must not raise
dispatch_user_notification(_OWNER, "document.processed", "T", "M")
def test_unknown_channel_type_is_skipped(self, Session):
"""Preferences with an unrecognised channel_type are silently skipped.
The live query filters to ("email", "webhook"), so this branch is only
reachable via a mocked session that bypasses the filter. The test
exercises the dead else-branch in dispatch_user_notification so that
branch coverage reaches 100%.
"""
import json as _json
target = UserNotificationTarget(
owner_id=_OWNER,
channel_type="sms",
name="sms-target",
config=_json.dumps({"phone": "+1555000000"}),
is_active=True,
)
s = Session()
s.add(target)
s.commit()
s.refresh(target)
target_id = target.id
unknown_pref = MagicMock()
unknown_pref.target_id = target_id
unknown_pref.channel_type = "sms"
mock_query = MagicMock()
mock_query.filter.return_value = mock_query
mock_query.all.return_value = [unknown_pref]
# Build a real session but intercept only the query for preferences
real_session = Session()
def fake_query(model):
from app.models import UserNotificationPreference as _UNP
if model is _UNP:
return mock_query
return real_session.query(model)
real_session.query = fake_query # type: ignore[method-assign]
real_session_cls = MagicMock(return_value=real_session)
with (
patch("app.utils.user_notification.SessionLocal", real_session_cls),
patch("app.utils.user_notification._send_email_notification") as mock_email,
patch("app.utils.user_notification._send_webhook_notification") as mock_hook,
patch("app.utils.push_notification.send_push_to_owner"),
):
from app.utils.user_notification import dispatch_user_notification
dispatch_user_notification(_OWNER, "document.processed", "T", "M")
mock_email.assert_not_called()
mock_hook.assert_not_called()
real_session.close()
# ---------------------------------------------------------------------------
# notify_user_document_processed / notify_user_document_failed
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestNotifyUserDocumentHelpers:
"""Smoke tests for the convenience wrappers."""
def test_notify_processed_creates_in_app_record(self, Session):
"""notify_user_document_processed creates an InAppNotification."""
with (
patch("app.utils.user_notification.SessionLocal", Session),
patch("app.utils.push_notification.send_push_to_owner"),
):
from app.utils.user_notification import notify_user_document_processed
notify_user_document_processed(owner_id=_OWNER, filename="report.pdf", file_id=5)
s = Session()
notifs = s.query(InAppNotification).filter_by(owner_id=_OWNER).all()
s.close()
assert len(notifs) == 1
assert "report.pdf" in notifs[0].title
assert notifs[0].event_type == "document.processed"
def test_notify_failed_creates_in_app_record(self, Session):
"""notify_user_document_failed creates an InAppNotification."""
with (
patch("app.utils.user_notification.SessionLocal", Session),
patch("app.utils.push_notification.send_push_to_owner"),
):
from app.utils.user_notification import notify_user_document_failed
notify_user_document_failed(owner_id=_OWNER, filename="broken.pdf", error="Timeout")
s = Session()
notifs = s.query(InAppNotification).filter_by(owner_id=_OWNER).all()
s.close()
assert len(notifs) == 1
assert "broken.pdf" in notifs[0].title
assert "Timeout" in notifs[0].message
assert notifs[0].event_type == "document.failed"
+608
View File
@@ -0,0 +1,608 @@
"""Tests to boost code coverage for all view modules below 100%.
Covers: api_tokens, notifications, shared_links, share, plans,
imap_accounts, integrations, general, filemanager, files, help.
"""
import os
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.config import settings as app_settings
from app.database import Base, get_db
from app.main import app
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def _fresh_db():
"""Yield a fresh in-memory SQLite session."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session = sessionmaker(autocommit=False, autoflush=False, bind=engine)()
try:
yield session
finally:
session.close()
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def client_fresh(_fresh_db) -> TestClient:
"""TestClient backed by a fresh database."""
def _override():
try:
yield _fresh_db
finally:
pass
app.dependency_overrides[get_db] = _override
with TestClient(app, base_url="http://localhost") as tc:
yield tc
app.dependency_overrides.clear()
# ===================================================================
# 1. Simple template-render views (api_tokens, notifications,
# shared_links, share, plans)
# ===================================================================
class TestApiTokensView:
"""GET /api-tokens should render the management page."""
@pytest.mark.unit
def test_api_tokens_page_returns_200(self, client_fresh: TestClient):
resp = client_fresh.get("/api-tokens")
assert resp.status_code == 200
assert "API Tokens" in resp.text
class TestNotificationsView:
"""GET /notifications should render the dashboard."""
@pytest.mark.unit
def test_notifications_page_returns_200(self, client_fresh: TestClient):
resp = client_fresh.get("/notifications")
assert resp.status_code == 200
assert "Notifications" in resp.text
class TestSharedLinksView:
"""GET /shared-links should render the management page."""
@pytest.mark.unit
def test_shared_links_page_returns_200(self, client_fresh: TestClient):
resp = client_fresh.get("/shared-links")
assert resp.status_code == 200
assert "Shared Links" in resp.text
class TestShareView:
"""GET /share/{token} should render the public share landing page."""
@pytest.mark.unit
def test_share_page_returns_200(self, client_fresh: TestClient):
resp = client_fresh.get("/share/abc123")
assert resp.status_code == 200
# Token should be passed to the template
assert "abc123" in resp.text
class TestPlansViews:
"""GET /admin/plans and /admin/stripe-wizard should render pages."""
@pytest.mark.unit
def test_plan_designer_returns_200(self, client_fresh: TestClient):
resp = client_fresh.get("/admin/plans")
assert resp.status_code == 200
@pytest.mark.unit
def test_stripe_wizard_returns_200(self, client_fresh: TestClient):
resp = client_fresh.get("/admin/stripe-wizard")
assert resp.status_code == 200
# ===================================================================
# 2. imap_accounts view (30.95 % → 100 %)
# ===================================================================
class TestImapAccountsView:
"""Tests for /imap-accounts view."""
@pytest.mark.unit
def test_imap_accounts_page_no_owner(self, client_fresh: TestClient):
"""When no owner_id is resolved, page renders with defaults."""
resp = client_fresh.get("/imap-accounts")
assert resp.status_code == 200
@pytest.mark.unit
def test_imap_accounts_page_with_owner(self, _fresh_db, client_fresh: TestClient):
"""When session has a user, the page queries IMAP accounts."""
with patch("app.views.imap_accounts.get_current_owner_id", return_value="testuser"):
with patch(
"app.views.imap_accounts.get_user_tier_id",
return_value="starter",
):
with patch(
"app.views.imap_accounts.get_tier",
return_value={"id": "starter", "name": "Starter", "max_mailboxes": 3},
):
resp = client_fresh.get("/imap-accounts")
assert resp.status_code == 200
@pytest.mark.unit
def test_get_max_mailboxes_free_tier(self):
"""Free tier should return 0 mailboxes."""
from app.views.imap_accounts import _get_max_mailboxes
assert _get_max_mailboxes({"id": "free", "max_mailboxes": 0}) == 0
@pytest.mark.unit
def test_get_max_mailboxes_unlimited(self):
"""When max_mailboxes is 0 on a non-free tier, it means unlimited."""
from app.views.imap_accounts import _get_max_mailboxes
assert _get_max_mailboxes({"id": "power", "max_mailboxes": 0}) is None
@pytest.mark.unit
def test_get_max_mailboxes_limited(self):
"""When max_mailboxes > 0, return that value."""
from app.views.imap_accounts import _get_max_mailboxes
assert _get_max_mailboxes({"id": "starter", "max_mailboxes": 5}) == 5
# ===================================================================
# 3. integrations view (82.09 % → 100 %)
# ===================================================================
class TestIntegrationsView:
"""Tests for /integrations view."""
@pytest.mark.unit
def test_integrations_dashboard_no_owner(self, client_fresh: TestClient):
"""When no owner, the dashboard renders with zero-count defaults."""
resp = client_fresh.get("/integrations")
assert resp.status_code == 200
@pytest.mark.unit
def test_integrations_dashboard_with_owner(self, _fresh_db, client_fresh: TestClient):
"""When an owner_id is resolved, DB queries run and tier is fetched."""
with patch("app.views.integrations.get_current_owner_id", return_value="testuser"):
with patch("app.views.integrations.get_user_tier_id", return_value="power"):
with patch(
"app.views.integrations.get_tier",
return_value={
"id": "power",
"name": "Power",
"max_storage_destinations": 0,
"max_mailboxes": 0,
},
):
resp = client_fresh.get("/integrations")
assert resp.status_code == 200
@pytest.mark.unit
def test_integrations_dashboard_generic_exception(self, client_fresh: TestClient):
"""A non-HTTP exception in the dashboard returns 500."""
with patch(
"app.views.integrations.get_current_owner_id",
side_effect=RuntimeError("boom"),
):
resp = client_fresh.get("/integrations")
assert resp.status_code == 500
@pytest.mark.unit
def test_integrations_dashboard_http_exception_passthrough(self, client_fresh: TestClient):
"""An HTTPException inside the dashboard is re-raised, not wrapped in 500."""
from fastapi import HTTPException
with patch(
"app.views.integrations.get_current_owner_id",
side_effect=HTTPException(status_code=403, detail="Forbidden"),
):
resp = client_fresh.get("/integrations")
assert resp.status_code == 403
@pytest.mark.unit
def test_get_max_destinations_free_default(self):
from app.views.integrations import _get_max_destinations
assert _get_max_destinations({"id": "free", "max_storage_destinations": 0}) == 1
@pytest.mark.unit
def test_get_max_destinations_free_with_value(self):
from app.views.integrations import _get_max_destinations
assert _get_max_destinations({"id": "free", "max_storage_destinations": 3}) == 3
@pytest.mark.unit
def test_get_max_destinations_unlimited(self):
from app.views.integrations import _get_max_destinations
assert _get_max_destinations({"id": "power", "max_storage_destinations": 0}) is None
@pytest.mark.unit
def test_get_max_destinations_limited(self):
from app.views.integrations import _get_max_destinations
assert _get_max_destinations({"id": "starter", "max_storage_destinations": 5}) == 5
@pytest.mark.unit
def test_get_max_sources_free(self):
from app.views.integrations import _get_max_sources
assert _get_max_sources({"id": "free", "max_mailboxes": 0}) == 0
@pytest.mark.unit
def test_get_max_sources_unlimited(self):
from app.views.integrations import _get_max_sources
assert _get_max_sources({"id": "power", "max_mailboxes": 0}) is None
@pytest.mark.unit
def test_get_max_sources_limited(self):
from app.views.integrations import _get_max_sources
assert _get_max_sources({"id": "starter", "max_mailboxes": 2}) == 2
# ===================================================================
# 4. general view (88.68 % → 100 %)
# ===================================================================
class TestGeneralViewMultiUser:
"""Cover the multi_user_enabled subscription branch (lines 96-105)."""
@staticmethod
def _signed_session(user_data: dict) -> str:
"""Create a signed Starlette session cookie containing user_data."""
import json
from base64 import b64encode
from itsdangerous import TimestampSigner
secret = os.environ.get(
"SESSION_SECRET",
"test_secret_key_for_testing_must_be_at_least_32_characters_long",
)
signer = TimestampSigner(secret)
data = {"user": user_data}
return signer.sign(b64encode(json.dumps(data).encode("utf-8"))).decode("utf-8")
@pytest.mark.unit
def test_home_page_multi_user_with_subscription(self, _fresh_db, client_fresh: TestClient):
"""When multi_user_enabled is True and user has owner_id, subscription info is fetched.
Lines 96-103: Exercises the subscription lookup path.
"""
cookie_val = self._signed_session({"username": "testuser", "email": "test@example.com", "is_admin": False})
tier_mock = {
"id": "starter",
"name": "Starter",
"lifetime_file_limit": 1000,
"daily_upload_limit": 50,
"monthly_upload_limit": 500,
}
usage_mock = {"lifetime": 10, "today": 2, "month": 8}
with (
patch.object(app_settings, "multi_user_enabled", True),
patch("app.utils.setup_wizard.is_setup_required", return_value=False),
patch("app.views.general.get_provider_status", return_value={}),
patch("app.views.general.validate_storage_configs", return_value={}),
patch("app.utils.subscription.get_user_tier_id", return_value="starter"),
patch("app.utils.subscription.get_tier", return_value=tier_mock),
patch("app.utils.subscription.get_user_usage", return_value=usage_mock),
):
client_fresh.cookies.set("session", cookie_val)
resp = client_fresh.get("/?setup=complete")
assert resp.status_code == 200
@pytest.mark.unit
def test_home_page_multi_user_subscription_error(self, _fresh_db, client_fresh: TestClient):
"""When subscription lookup fails, error is logged but page still renders.
Lines 104-105: Exercises the exception handling branch.
"""
cookie_val = self._signed_session({"username": "testuser", "email": "test@example.com", "is_admin": False})
with (
patch.object(app_settings, "multi_user_enabled", True),
patch("app.utils.setup_wizard.is_setup_required", return_value=False),
patch("app.views.general.get_provider_status", return_value={}),
patch("app.views.general.validate_storage_configs", return_value={}),
patch("app.utils.subscription.get_user_tier_id", side_effect=RuntimeError("boom")),
):
client_fresh.cookies.set("session", cookie_val)
resp = client_fresh.get("/?setup=complete")
assert resp.status_code == 200
# ===================================================================
# 5. filemanager view (96.63 % → 100 %)
# ===================================================================
class TestFilemanagerCoverageGaps:
"""Cover the remaining gaps in filemanager.py."""
@pytest.mark.unit
def test_format_size_petabytes(self):
"""Line 43: _format_size should return PB for very large sizes."""
from app.views.filemanager import _format_size
# 1 PB = 1024^5 bytes
one_pb = 1024**5
result = _format_size(one_pb)
assert "PB" in result
assert "1.0 PB" == result
@pytest.mark.unit
def test_format_size_multiple_petabytes(self):
"""Large values above 1 PB."""
from app.views.filemanager import _format_size
result = _format_size(5 * 1024**5)
assert "PB" in result
@pytest.mark.unit
def test_scan_dir_with_broken_symlink(self, tmp_path):
"""Lines 104-106: files that cannot be stat'd are skipped with a warning.
Using a broken symlink to trigger OSError on stat().
"""
from app.views.filemanager import _scan_dir
# Create a broken symlink — stat() will raise FileNotFoundError (subclass of OSError)
broken_link = tmp_path / "broken_link.txt"
broken_link.symlink_to("/nonexistent/target/file")
# Also create a valid file so we can verify it's included
valid_file = tmp_path / "valid.txt"
valid_file.write_text("hello")
db_paths: set[str] = set()
entries = _scan_dir(tmp_path, tmp_path, db_paths)
# The broken symlink should be skipped, the valid file should be included
entry_names = [e["name"] for e in entries]
assert "broken_link.txt" not in entry_names
assert "valid.txt" in entry_names
@pytest.mark.unit
def test_scan_dir_oserror(self, tmp_path):
"""OSError during stat in _scan_dir is caught and file is skipped.
We create a second broken symlink for this test.
"""
from app.views.filemanager import _scan_dir
broken_link = tmp_path / "also_broken.txt"
broken_link.symlink_to("/another/nonexistent/path")
valid_file = tmp_path / "good.txt"
valid_file.write_text("ok")
db_paths: set[str] = set()
entries = _scan_dir(tmp_path, tmp_path, db_paths)
entry_names = [e["name"] for e in entries]
assert "also_broken.txt" not in entry_names
assert "good.txt" in entry_names
@pytest.mark.unit
def test_walk_all_files_with_broken_symlink(self, tmp_path):
"""Lines 146-147: files that fail stat during walk are skipped.
Using a broken symlink to trigger OSError.
"""
from app.views.filemanager import _walk_all_files
broken_link = tmp_path / "broken.pdf"
broken_link.symlink_to("/nonexistent/target/file")
valid_file = tmp_path / "valid.pdf"
valid_file.write_text("content")
db_paths: set[str] = set()
entries = _walk_all_files(tmp_path, db_paths)
entry_names = [e["name"] for e in entries]
assert "broken.pdf" not in entry_names
assert "valid.pdf" in entry_names
# ===================================================================
# 6. files view (99.21 % → 100 %)
# ===================================================================
class TestFilesViewCoverageGaps:
"""Cover the remaining branches in files.py."""
@pytest.mark.unit
def test_compute_processing_flow_with_pipeline_steps(self):
"""Lines 504-515: pipeline_steps filtering in _compute_processing_flow."""
from app.views.files import _compute_processing_flow
# Create mock pipeline steps
ps1 = SimpleNamespace(enabled=True, step_type="ocr")
ps2 = SimpleNamespace(enabled=False, step_type="extract_metadata")
ps3 = SimpleNamespace(enabled=True, step_type="send_to_destinations")
# Create mock logs with all required attributes including task_id
log1 = SimpleNamespace(
step_name="create_file_record",
status="completed",
message="ok",
timestamp=None,
started_at=None,
completed_at=None,
task_id="task-001",
)
log2 = SimpleNamespace(
step_name="check_text",
status="completed",
message="ok",
timestamp=None,
started_at=None,
completed_at=None,
task_id="task-002",
)
result = _compute_processing_flow([log1, log2], pipeline_steps=[ps1, ps2, ps3])
# _compute_processing_flow returns a list of stage dicts
stage_keys = [s["key"] for s in result]
assert "create_file_record" in stage_keys # always shown
assert "check_text" in stage_keys # OCR step type + ran
# extract_metadata is disabled, so its stages should NOT be included
assert "extract_metadata_with_gpt" not in stage_keys
@pytest.mark.unit
def test_compute_processing_flow_with_pipeline_steps_none(self):
"""When pipeline_steps is None, all stages are shown."""
from app.views.files import _compute_processing_flow
result = _compute_processing_flow([], pipeline_steps=None)
stage_keys = [s["key"] for s in result]
assert "create_file_record" in stage_keys
assert "extract_metadata_with_gpt" in stage_keys
@pytest.mark.unit
def test_compute_processing_flow_with_empty_pipeline_steps(self):
"""When pipeline_steps is empty list, only always-show + ran stages remain."""
from app.views.files import _compute_processing_flow
result = _compute_processing_flow([], pipeline_steps=[])
stage_keys = [s["key"] for s in result]
assert "create_file_record" in stage_keys
# Other stages should be filtered out
assert "convert_to_pdf" not in stage_keys
@pytest.mark.unit
def test_compute_processing_flow_dedup_enabled(self):
"""When dedup is enabled and shown, check_for_duplicates stage appears."""
from app.views.files import _compute_processing_flow
with (
patch.object(app_settings, "enable_deduplication", True),
patch.object(app_settings, "show_deduplication_step", True),
):
result = _compute_processing_flow([], pipeline_steps=None)
stage_keys = [s["key"] for s in result]
assert "check_for_duplicates" in stage_keys
@pytest.mark.unit
def test_file_detail_safe_exists_value_error(self, _fresh_db, client_fresh: TestClient):
"""Test that _safe_exists handles ValueError from commonpath gracefully.
Lines 240-241: When os.path.commonpath raises ValueError (e.g., paths
on different drives on Windows), _safe_exists returns False.
"""
from app.models import FileRecord
# Create a file record with all required fields
rec = FileRecord(
original_filename="test.pdf",
local_filename="/tmp/test_local.pdf",
original_file_path="/tmp/test_original.pdf",
processed_file_path="/tmp/test_processed.pdf",
file_size=100,
mime_type="application/pdf",
filehash="abc123def456",
)
_fresh_db.add(rec)
_fresh_db.commit()
_fresh_db.refresh(rec)
# Patch commonpath to raise ValueError
with patch("os.path.commonpath", side_effect=ValueError("different drives")):
resp = client_fresh.get(f"/files/{rec.id}")
assert resp.status_code == 200
# ===================================================================
# 7. help view (96 % → 100 %)
# ===================================================================
class TestHelpViewCoverageGaps:
"""Cover the missing branch in help.py (34->37)."""
@pytest.mark.unit
def test_help_page_no_session_attr(self, client_fresh: TestClient):
"""When no session user is set, defaults are used for Zammad widgets."""
resp = client_fresh.get("/help")
assert resp.status_code == 200
@pytest.mark.unit
@pytest.mark.asyncio
async def test_help_page_request_without_session(self):
"""Direct function call where request has no session attribute.
Branch 34->37: when hasattr(request, 'session') is False.
"""
from app.views.help import help_center
# Create a mock request without session attribute
mock_request = MagicMock(spec=[]) # spec=[] means no attributes
# help_center checks hasattr(request, "session")
# With spec=[], hasattr will return False
with patch("app.views.help.templates") as mock_templates:
mock_templates.TemplateResponse.return_value = "ok"
await help_center(mock_request)
# Template should be called with empty user context
call_args = mock_templates.TemplateResponse.call_args
ctx = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("context", {})
assert ctx["user_name"] == ""
assert ctx["user_email"] == ""
assert ctx["user_id"] == ""
@pytest.mark.unit
def test_help_page_with_logged_in_user(self, client_fresh: TestClient):
"""When session has user data, Zammad widget fields are populated.
Covers lines 41-43 (user_name, user_email, user_id extraction).
"""
import json
from base64 import b64encode
from itsdangerous import TimestampSigner
secret = os.environ.get(
"SESSION_SECRET",
"test_secret_key_for_testing_must_be_at_least_32_characters_long",
)
signer = TimestampSigner(secret)
session_data = {
"user": {
"name": "Jane Doe",
"email": "jane@example.com",
"preferred_username": "janedoe",
}
}
cookie_val = signer.sign(b64encode(json.dumps(session_data).encode("utf-8"))).decode("utf-8")
client_fresh.cookies.set("session", cookie_val)
resp = client_fresh.get("/help")
assert resp.status_code == 200
+26
View File
@@ -82,6 +82,8 @@ class TestGetConfiguredDestinations:
cfg.sftp_username = None
cfg.ftp_host = None
cfg.ftp_username = None
cfg.icloud_username = None
cfg.icloud_password = None
result = _get_configured_destinations(cfg)
assert result == []
@@ -107,6 +109,8 @@ class TestGetConfiguredDestinations:
cfg.sftp_username = None
cfg.ftp_host = None
cfg.ftp_username = None
cfg.icloud_username = None
cfg.icloud_password = None
result = _get_configured_destinations(cfg)
ids = [d["id"] for d in result]
@@ -133,6 +137,8 @@ class TestGetConfiguredDestinations:
cfg.sftp_username = None
cfg.ftp_host = None
cfg.ftp_username = None
cfg.icloud_username = None
cfg.icloud_password = None
result = _get_configured_destinations(cfg)
ids = [d["id"] for d in result]
@@ -159,6 +165,8 @@ class TestGetConfiguredDestinations:
cfg.sftp_username = None
cfg.ftp_host = None
cfg.ftp_username = None
cfg.icloud_username = None
cfg.icloud_password = None
result = _get_configured_destinations(cfg)
ids = [d["id"] for d in result]
@@ -185,6 +193,8 @@ class TestGetConfiguredDestinations:
cfg.sftp_username = None
cfg.ftp_host = None
cfg.ftp_username = None
cfg.icloud_username = None
cfg.icloud_password = None
result = _get_configured_destinations(cfg)
ids = [d["id"] for d in result]
@@ -211,6 +221,8 @@ class TestGetConfiguredDestinations:
cfg.sftp_username = None
cfg.ftp_host = None
cfg.ftp_username = None
cfg.icloud_username = None
cfg.icloud_password = None
result = _get_configured_destinations(cfg)
ids = [d["id"] for d in result]
@@ -237,6 +249,8 @@ class TestGetConfiguredDestinations:
cfg.sftp_username = None
cfg.ftp_host = None
cfg.ftp_username = None
cfg.icloud_username = None
cfg.icloud_password = None
result = _get_configured_destinations(cfg)
ids = [d["id"] for d in result]
@@ -263,6 +277,8 @@ class TestGetConfiguredDestinations:
cfg.sftp_username = None
cfg.ftp_host = None
cfg.ftp_username = None
cfg.icloud_username = None
cfg.icloud_password = None
result = _get_configured_destinations(cfg)
ids = [d["id"] for d in result]
@@ -289,6 +305,8 @@ class TestGetConfiguredDestinations:
cfg.sftp_username = "user"
cfg.ftp_host = None
cfg.ftp_username = None
cfg.icloud_username = None
cfg.icloud_password = None
result = _get_configured_destinations(cfg)
ids = [d["id"] for d in result]
@@ -315,6 +333,8 @@ class TestGetConfiguredDestinations:
cfg.sftp_username = None
cfg.ftp_host = "ftp.example.com"
cfg.ftp_username = "user"
cfg.icloud_username = None
cfg.icloud_password = None
result = _get_configured_destinations(cfg)
ids = [d["id"] for d in result]
@@ -341,6 +361,8 @@ class TestGetConfiguredDestinations:
cfg.sftp_username = "sftpuser"
cfg.ftp_host = "ftp.example.com"
cfg.ftp_username = "ftpuser"
cfg.icloud_username = "user@example.com"
cfg.icloud_password = "app-pass"
result = _get_configured_destinations(cfg)
assert len(result) == len(_DESTINATION_META)
@@ -366,6 +388,8 @@ class TestGetConfiguredDestinations:
cfg.sftp_username = None
cfg.ftp_host = None
cfg.ftp_username = None
cfg.icloud_username = None
cfg.icloud_password = None
result = _get_configured_destinations(cfg)
assert len(result) == 1
@@ -393,6 +417,8 @@ class TestGetConfiguredDestinations:
cfg.sftp_username = None
cfg.ftp_host = None
cfg.ftp_username = None
cfg.icloud_username = None
cfg.icloud_password = None
result = _get_configured_destinations(cfg)
ids = [d["id"] for d in result]