01527ae4f0
Combine both branches into a single coherent branch: - Full mobile responsive parity (useIsMobile, MobileCardRow, SheetForm, .mobile-touch-target, mobile cards, SheetForm forms, 44px targets, dirty-state confirm, TablePagination, refetchIntervalInBackground). - Full services-as-hub IA (data-driven nav, service-page tab skeleton, new service types, Authentik directory + messaging, named dashboards, legacy routes 404, Observability split, Jellyseerr absorbed). Enhancement: service tabs now use mobile-parity primitives: - MediaTab: MobileCardRow below md (title/size/HDR/library/year) + TablePagination; DataTable at md+ (desktop branch preserved). - FilesTab: MobileCardRow below md (name/type/size/modified) + handleRowClick; DataTable at md+. - ServicePage: SheetForm branch below md (open-on-mount, sticky header + save bar, cancel navigates back to /services, dirty-state guard). - Dashboard: single-column + section anchors below md (from mobile-parity) + empty-state CTA (from services-hub). - App.tsx: useIsMobile() replaces inline matchMedia (from mobile-parity) + data-driven useNavItems (from services-hub). - Backup tables (BackupAlerts/Jobs/Runs) already have MobileCardRow from mobile-parity; JobsTab inherits mobile behavior through its sub-components. Conflict resolutions: - Backend: entirely from services-hub (mobile didn't touch it). - Deleted pages (Media/FileBrowser/Actions/Users/UsersPage/Applications/ ObservabilityPage/BackupsPage + hooks/useUsers + tests): kept deleted (services-hub deleted them; content moved into service tabs). - New service-tabs/*: from services-hub, enhanced with mobile patterns. - App.tsx: services-hub's data-driven nav + mobile-parity's useIsMobile. - Dashboard.tsx: merged (services-hub CTA + mobile-parity sections/anchors). - ServicePage.tsx: services-hub's tab skeleton + mobile-parity's SheetForm. - Primitives (useIsMobile/mobile-card/sheet-form/etc.): from mobile-parity. 117 frontend tests pass (mobile-parity's 122 - 5 deleted page tests + services-hub's new tab/dashboard tests); 271 backend tests pass; lint/ build green both sides.
184 lines
7.2 KiB
Python
184 lines
7.2 KiB
Python
"""Tests for AuthentikClient and the directory endpoint."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from cryptography.fernet import Fernet
|
|
from fastapi.testclient import TestClient
|
|
|
|
from media_library_viewer_api.clients.authentik import AuthentikClient
|
|
from media_library_viewer_api.dependencies import get_settings_store
|
|
from media_library_viewer_api.main import app
|
|
from media_library_viewer_api.services.secrets import reset_encryption_key_cache
|
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
|
|
|
TEST_KEY = Fernet.generate_key().decode()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _encryption_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Provide a stable MANAGE_ENCRYPTION_KEY for every test."""
|
|
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY)
|
|
reset_encryption_key_cache()
|
|
yield
|
|
reset_encryption_key_cache()
|
|
|
|
|
|
@pytest.fixture()
|
|
def store(tmp_path: Path) -> SettingsStore:
|
|
s = SettingsStore(tmp_path / "settings.sqlite")
|
|
s.ensure_defaults()
|
|
app.dependency_overrides[get_settings_store] = lambda: s
|
|
yield s
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Client unit tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAuthentikClient:
|
|
def test_base_url_normalizes_trailing_slash(self) -> None:
|
|
c = AuthentikClient(base_url="https://auth.example.com/", api_token="t")
|
|
assert c.base_url == "https://auth.example.com"
|
|
|
|
def test_base_url_strips_api_v3_suffix(self) -> None:
|
|
c = AuthentikClient(base_url="https://auth.example.com/api/v3", api_token="t")
|
|
assert c.base_url == "https://auth.example.com"
|
|
|
|
def test_bearer_header_is_set(self) -> None:
|
|
c = AuthentikClient(base_url="https://auth.example.com", api_token="tok")
|
|
assert c.session.headers["Authorization"] == "Bearer tok"
|
|
|
|
def test_empty_base_url_raises(self) -> None:
|
|
with pytest.raises(ValueError):
|
|
AuthentikClient(base_url="", api_token="t")
|
|
|
|
def test_empty_api_token_raises(self) -> None:
|
|
with pytest.raises(ValueError):
|
|
AuthentikClient(base_url="https://auth.example.com", api_token="")
|
|
|
|
@patch.object(AuthentikClient, "get")
|
|
def test_users_normalizes_pagination(self, mock_get: MagicMock) -> None:
|
|
mock_get.return_value = {
|
|
"pagination": {"count": 42, "next": 2, "previous": 0, "current": 1},
|
|
"results": [
|
|
{"pk": 1, "username": "alice", "email": "alice@example.com"},
|
|
{"pk": 2, "username": "bob", "email": "bob@example.com"},
|
|
],
|
|
}
|
|
client = AuthentikClient(base_url="https://auth.example.com", api_token="t")
|
|
result = client.users(search="ali", page=1, page_size=2)
|
|
assert result["total"] == 42
|
|
assert result["page"] == 1
|
|
assert result["page_size"] == 2
|
|
assert len(result["items"]) == 2
|
|
assert result["items"][0]["username"] == "alice"
|
|
|
|
@patch.object(AuthentikClient, "get")
|
|
def test_users_handles_empty_results(self, mock_get: MagicMock) -> None:
|
|
mock_get.return_value = {"pagination": {"count": 0}, "results": []}
|
|
client = AuthentikClient(base_url="https://auth.example.com", api_token="t")
|
|
result = client.users()
|
|
assert result["items"] == []
|
|
assert result["total"] == 0
|
|
|
|
@patch.object(AuthentikClient, "get")
|
|
def test_users_handles_non_dict_payload(self, mock_get: MagicMock) -> None:
|
|
mock_get.return_value = []
|
|
client = AuthentikClient(base_url="https://auth.example.com", api_token="t")
|
|
result = client.users()
|
|
assert result["items"] == []
|
|
assert result["total"] == 0
|
|
|
|
@patch("media_library_viewer_api.clients.authentik.requests.Session")
|
|
def test_get_sends_correct_url_and_params(self, mock_session_cls: MagicMock) -> None:
|
|
mock_session = MagicMock()
|
|
mock_session_cls.return_value = mock_session
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"results": []}
|
|
mock_response.raise_for_status.return_value = None
|
|
mock_session.get.return_value = mock_response
|
|
|
|
c = AuthentikClient(base_url="https://auth.example.com", api_token="t")
|
|
c.get("/core/users/", search="x", page=2)
|
|
|
|
call_args = mock_session.get.call_args
|
|
assert call_args.kwargs["params"] == {"search": "x", "page": 2}
|
|
assert call_args.args[0] == "https://auth.example.com/api/v3/core/users/"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Endpoint integration tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAuthentikUsersEndpoint:
|
|
def test_not_configured_returns_empty_with_error(self, store: SettingsStore) -> None:
|
|
client = TestClient(app)
|
|
response = client.get("/api/services/authentik/nonexistent/users")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["items"] == []
|
|
assert data["total"] == 0
|
|
assert "error" in data
|
|
|
|
@patch("media_library_viewer_api.routers.authentik_users.AuthentikClient")
|
|
def test_success_returns_users(self, mock_client_cls: MagicMock, store: SettingsStore) -> None:
|
|
mock_client = MagicMock()
|
|
mock_client.users.return_value = {
|
|
"items": [{"pk": 1, "username": "alice"}],
|
|
"total": 1,
|
|
"page": 1,
|
|
"page_size": 50,
|
|
}
|
|
mock_client_cls.return_value = mock_client
|
|
|
|
created = store.upsert_service(
|
|
{
|
|
"service_type": "authentik",
|
|
"name": "Main",
|
|
"config": {"base_url": "https://auth.example.com"},
|
|
"enabled": True,
|
|
},
|
|
secret_values={"api_token": "secret-token"},
|
|
)
|
|
service_id = created["id"]
|
|
|
|
client = TestClient(app)
|
|
response = client.get(f"/api/services/authentik/{service_id}/users?search=ali")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data["items"]) == 1
|
|
assert data["items"][0]["username"] == "alice"
|
|
assert data["total"] == 1
|
|
assert "error" not in data
|
|
|
|
@patch("media_library_viewer_api.routers.authentik_users.AuthentikClient")
|
|
def test_unreachable_returns_error(self, mock_client_cls: MagicMock, store: SettingsStore) -> None:
|
|
mock_client = MagicMock()
|
|
mock_client.users.side_effect = ConnectionError("refused")
|
|
mock_client_cls.return_value = mock_client
|
|
|
|
created = store.upsert_service(
|
|
{
|
|
"service_type": "authentik",
|
|
"name": "Main",
|
|
"config": {"base_url": "https://auth.example.com"},
|
|
"enabled": True,
|
|
},
|
|
secret_values={"api_token": "secret-token"},
|
|
)
|
|
service_id = created["id"]
|
|
|
|
client = TestClient(app)
|
|
response = client.get(f"/api/services/authentik/{service_id}/users")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["items"] == []
|
|
assert "error" in data
|