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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"] == []
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user