feat: production deployment - full update with providers, admin, glossaries, pricing, tests
Major changes across backend, frontend, infrastructure: - Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud) - Admin panel: user management, pricing, settings - Glossary system with CSV import/export - Subscription and tier quota management - Security hardening (rate limiting, API key auth, path traversal fixes) - Docker compose for dev, prod, and IONOS deployment - Alembic migrations for new tables - Frontend: dashboard, pricing page, landing page, i18n (en/fr) - Test suite and verification scripts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
190
tests/test_admin_logs.py
Normal file
190
tests/test_admin_logs.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
Tests for GET /api/v1/admin/logs - Admin Error Logs Viewer (Story 5.7).
|
||||
AC: auth required, pagination, filters, no original_filename or document content.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
from datetime import datetime, timezone
|
||||
|
||||
ADMIN_LOGIN_URL = "/api/v1/admin/login"
|
||||
ADMIN_LOGS_URL = "/api/v1/admin/logs"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def users_file(tmp_path: Path) -> Path:
|
||||
return tmp_path / "users.json"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(users_file: Path, monkeypatch):
|
||||
"""TestClient with admin and rate limiting disabled."""
|
||||
import services.auth_service as auth_svc
|
||||
from middleware.rate_limiting import RateLimitManager
|
||||
|
||||
monkeypatch.setattr(auth_svc, "USERS_FILE", users_file)
|
||||
monkeypatch.setattr(auth_svc, "USE_DATABASE", False)
|
||||
monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", False)
|
||||
|
||||
async def _check_request_allow(self, request):
|
||||
return True, "ok", "test"
|
||||
|
||||
async def _check_translation_allow(self, request, file_size_mb=0):
|
||||
return True, "ok"
|
||||
|
||||
monkeypatch.setattr(RateLimitManager, "check_request", _check_request_allow)
|
||||
monkeypatch.setattr(RateLimitManager, "check_translation", _check_translation_allow)
|
||||
|
||||
from main import app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
return TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_password():
|
||||
return "admin-secret"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_admin(client, admin_password, monkeypatch):
|
||||
import routes.admin_routes as admin_routes_mod
|
||||
|
||||
monkeypatch.setattr(admin_routes_mod, "ADMIN_USERNAME", "admin")
|
||||
monkeypatch.setattr(admin_routes_mod, "ADMIN_PASSWORD", admin_password)
|
||||
monkeypatch.setattr(admin_routes_mod, "ADMIN_PASSWORD_HASH", None)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_token(client_with_admin, admin_password):
|
||||
r = client_with_admin.post(ADMIN_LOGIN_URL, json={"password": admin_password})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _make_mock_translation(
|
||||
user_id="usr_abc",
|
||||
error_message="Translation failed: PROVIDER_UNAVAILABLE",
|
||||
created_at=None,
|
||||
provider="google",
|
||||
file_type="xlsx",
|
||||
original_filename="secret.docx",
|
||||
):
|
||||
"""Build a mock Translation row. original_filename must never appear in API response."""
|
||||
m = MagicMock()
|
||||
m.user_id = user_id
|
||||
m.error_message = error_message
|
||||
m.created_at = created_at or datetime.now(timezone.utc)
|
||||
m.provider = provider
|
||||
m.file_type = file_type
|
||||
m.original_filename = original_filename # must NOT be in response
|
||||
return m
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth: 401 without token / invalid token
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_admin_logs_without_token_returns_401(client_with_admin):
|
||||
r = client_with_admin.get(ADMIN_LOGS_URL)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_admin_logs_with_invalid_token_returns_401(client_with_admin):
|
||||
r = client_with_admin.get(
|
||||
ADMIN_LOGS_URL,
|
||||
headers={"Authorization": "Bearer invalid-token"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 200 + response shape; no sensitive data (NFR11, NFR16)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_admin_logs_returns_200_and_shape(client_with_admin, admin_token):
|
||||
"""With empty DB or mocked empty list, response has data.logs, data.total, data.page, data.per_page, meta.generated_at."""
|
||||
r = client_with_admin.get(
|
||||
ADMIN_LOGS_URL,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert "data" in body
|
||||
assert "logs" in body["data"]
|
||||
assert "total" in body["data"]
|
||||
assert "page" in body["data"]
|
||||
assert "per_page" in body["data"]
|
||||
assert "meta" in body
|
||||
assert "generated_at" in body["meta"]
|
||||
assert isinstance(body["data"]["logs"], list)
|
||||
|
||||
|
||||
def test_admin_logs_no_original_filename_in_response(client_with_admin, admin_token):
|
||||
"""NFR11/NFR16: response must never contain original_filename or document content."""
|
||||
row = _make_mock_translation(original_filename="sensitive.docx")
|
||||
with patch("routes.admin_routes.get_sync_session") as mock_get_session:
|
||||
session_mock = MagicMock()
|
||||
mock_get_session.return_value.__enter__.return_value = session_mock
|
||||
mock_get_session.return_value.__exit__.return_value = None
|
||||
|
||||
q = MagicMock()
|
||||
q.filter.return_value = q
|
||||
q.count.return_value = 1
|
||||
q.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [row]
|
||||
session_mock.query.return_value = q
|
||||
|
||||
r = client_with_admin.get(
|
||||
ADMIN_LOGS_URL,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data_str = str(r.json())
|
||||
assert "original_filename" not in data_str
|
||||
assert "sensitive.docx" not in data_str
|
||||
assert "data" in r.json()
|
||||
logs = r.json()["data"]["logs"]
|
||||
assert len(logs) == 1
|
||||
entry = logs[0]
|
||||
assert "timestamp" in entry
|
||||
assert "level" in entry
|
||||
assert entry["level"] == "error"
|
||||
assert "message" in entry
|
||||
assert "user_id" in entry
|
||||
assert "error_code" in entry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# level=warning and level=info return empty logs
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_admin_logs_level_warning_returns_empty(client_with_admin, admin_token):
|
||||
r = client_with_admin.get(
|
||||
f"{ADMIN_LOGS_URL}?level=warning",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["data"]["logs"] == []
|
||||
assert r.json()["data"]["total"] == 0
|
||||
|
||||
|
||||
def test_admin_logs_level_info_returns_empty(client_with_admin, admin_token):
|
||||
r = client_with_admin.get(
|
||||
f"{ADMIN_LOGS_URL}?level=info",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["data"]["logs"] == []
|
||||
assert r.json()["data"]["total"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Query params: page, per_page
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_admin_logs_accepts_page_and_per_page(client_with_admin, admin_token):
|
||||
r = client_with_admin.get(
|
||||
f"{ADMIN_LOGS_URL}?page=2&per_page=25",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["data"]["page"] == 2
|
||||
assert r.json()["data"]["per_page"] == 25
|
||||
Reference in New Issue
Block a user