feat: add Authentik access widgets

This commit is contained in:
Developer
2026-07-14 21:41:24 +00:00
parent 4562a9dfca
commit 17976eab80
21 changed files with 1082 additions and 214 deletions
@@ -1,9 +1,7 @@
"""Authentik directory API client.
"""Read-only Authentik directory client.
Authentik is the user-directory source (replacing the Jellyfin-backed Users
page). This client wraps the Authentik REST API for browsing the user directory
with pagination and search. OIDC authentication is unchanged — this client is
for the directory, not SSO.
The client normalizes the subset of Authentik core data that Manage displays.
It deliberately does not fetch individual users or expose policy/provider data.
"""
from __future__ import annotations
@@ -17,9 +15,34 @@ from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT,
logger = logging.getLogger(__name__)
_MAX_COLLECTION_ITEMS = 10_000
_PAGE_SIZE = 100
def _text(value: Any) -> str:
return str(value).strip() if value is not None else ""
def _identifier(item: dict[str, Any]) -> str:
for key in ("pk", "id", "uuid"):
value = _text(item.get(key))
if value:
return value
return ""
def _page_total(payload: dict[str, Any], fallback: int) -> int:
pagination = payload.get("pagination")
if isinstance(pagination, dict):
try:
return max(0, int(pagination.get("count") or fallback))
except (TypeError, ValueError):
pass
return fallback
class AuthentikClient:
"""Small wrapper around the Authentik core directory API."""
"""Small wrapper around Authentik's read-only core API."""
def __init__(self, base_url: str, api_token: str, timeout: float = DEFAULT_READ_TIMEOUT):
if not base_url:
@@ -31,88 +54,139 @@ class AuthentikClient:
if self.base_url.endswith("/api/v3"):
self.base_url = self.base_url[:-7]
self.api_token = api_token
# timeout is the per-response READ timeout (seconds); connect timeout is fixed at 5s.
self.timeout = http_timeout(timeout)
self.session = requests.Session()
self.session.headers.update(
{
"Authorization": f"Bearer {api_token}",
"Accept": "application/json",
}
)
self.session.headers.update({"Authorization": f"Bearer {api_token}", "Accept": "application/json"})
def get(self, path: str, **params: Any) -> Any:
"""GET an Authentik endpoint and include useful response text on errors."""
clean_params = {k: v for k, v in params.items() if v is not None and v != ""}
clean_params = {key: value for key, value in params.items() if value is not None and value != ""}
logger.debug("Authentik GET %s params=%s", path, sorted(clean_params.keys()))
response = self.session.get(
f"{self.base_url}/api/v3{path}",
params=clean_params,
timeout=self.timeout,
)
response = self.session.get(f"{self.base_url}/api/v3{path}", params=clean_params, timeout=self.timeout)
try:
response.raise_for_status()
except requests.HTTPError as exc:
detail = response.text[:500]
logger.warning(
"Authentik GET %s failed status=%s url=%s",
path,
response.status_code,
response.url,
)
raise requests.HTTPError(
f"{response.status_code} for {response.url}: {detail}",
response=response,
) from exc
logger.debug("Authentik GET %s ok status=%s", path, response.status_code)
logger.warning("Authentik GET %s failed status=%s url=%s", path, response.status_code, response.url)
raise requests.HTTPError(f"{response.status_code} for {response.url}: {detail}", response=response) from exc
return response.json()
def users(
def users(self, search: str | None = None, page: int = 1, page_size: int = 50) -> dict[str, Any]:
"""Return one raw user page for the directory and messaging surfaces."""
payload = self.get("/core/users/", search=search, page=page, page_size=page_size)
if not isinstance(payload, dict):
logger.warning("Authentik users payload was not a dict: %s", type(payload).__name__)
return {"items": [], "total": 0, "page": page, "page_size": page_size}
results = payload.get("results")
items = [item for item in results if isinstance(item, dict)] if isinstance(results, list) else []
return {"items": items, "total": _page_total(payload, len(items)), "page": page, "page_size": page_size}
def _collection(self, path: str, limit: int = _MAX_COLLECTION_ITEMS) -> dict[str, Any]:
"""Read a paginated core collection with a hard cap and loop protection."""
try:
requested = max(1, min(int(limit), _MAX_COLLECTION_ITEMS))
except (TypeError, ValueError):
requested = _MAX_COLLECTION_ITEMS
items: list[dict[str, Any]] = []
page = 1
total = 0
while len(items) < requested:
payload = self.get(path, page=page, page_size=min(_PAGE_SIZE, requested - len(items)))
if not isinstance(payload, dict):
logger.warning("Authentik %s payload was not a dict: %s", path, type(payload).__name__)
break
results = payload.get("results")
page_items = [item for item in results if isinstance(item, dict)] if isinstance(results, list) else []
total = _page_total(payload, len(items) + len(page_items))
items.extend(page_items[: requested - len(items)])
if not page_items or len(items) >= total:
break
page += 1
if page > 100: # defensive limit for malformed pagination responses
logger.warning("Authentik %s pagination stopped after 100 pages", path)
break
return {"items": items, "total": total or len(items)}
@staticmethod
def _normalize_group(item: dict[str, Any]) -> dict[str, str] | None:
group_id = _identifier(item)
if not group_id:
return None
name = _text(item.get("name") or item.get("display_name") or item.get("slug"))
return {"id": group_id, "name": name or f"Unnamed group ({group_id})"}
@staticmethod
def _normalize_application(item: dict[str, Any]) -> dict[str, str]:
app_id = _identifier(item)
return {
"id": app_id,
"name": _text(item.get("name") or item.get("slug") or item.get("meta_name")) or "Unnamed application",
"slug": _text(item.get("slug")),
"launch_url": _text(item.get("launch_url") or item.get("meta_launch_url")),
}
def groups(self, limit: int = _MAX_COLLECTION_ITEMS) -> dict[str, Any]:
"""Return normalized groups; only display-safe identifiers and names are retained."""
raw = self._collection("/core/groups/", limit)
items = [normalized for item in raw["items"] if (normalized := self._normalize_group(item)) is not None]
return {"items": items, "total": raw["total"]}
def applications(self, limit: int = _MAX_COLLECTION_ITEMS) -> dict[str, Any]:
"""Return normalized applications without provider, policy, or secret fields."""
raw = self._collection("/core/applications/", limit)
return {"items": [self._normalize_application(item) for item in raw["items"]], "total": raw["total"]}
@staticmethod
def _group_references(user: dict[str, Any]) -> list[str]:
"""Extract group ids from release-dependent user reference shapes."""
raw = user.get("groups", user.get("group", []))
if not isinstance(raw, list):
raw = [raw] if raw is not None else []
ids: list[str] = []
for reference in raw:
if isinstance(reference, dict):
group_id = _identifier(reference)
else:
group_id = _text(reference)
if group_id and group_id not in ids:
ids.append(group_id)
return ids
def access_summaries(
self,
search: str | None = None,
page: int = 1,
page_size: int = 50,
) -> dict[str, Any]:
"""Return a normalized page of Authentik users.
"""Summarize user group references and privileged flags without N+1 user reads.
Calls ``GET /api/v3/core/users/`` and normalizes the paginated
Authentik response into ``{items, total, page, page_size}``. Each item
is the raw Authentik user dict (pk, username, name, email, avatar, …)
so the frontend can pick the fields it needs.
This is directory metadata only: group membership plus the explicit
``is_superuser`` and ``is_staff`` fields. It does not evaluate policies
or claim to calculate effective authorization.
"""
payload = self.get(
"/core/users/",
search=search,
page=page,
page_size=page_size,
)
if not isinstance(payload, dict):
logger.warning("Authentik users payload was not a dict: %s", type(payload).__name__)
return {"items": [], "total": 0, "page": page, "page_size": page_size}
results = payload.get("results")
items: list[dict[str, Any]] = (
[item for item in results if isinstance(item, dict)] if isinstance(results, list) else []
)
pagination = payload.get("pagination") or {}
total = 0
if isinstance(pagination, dict):
try:
total = int(pagination.get("count") or 0)
except (TypeError, ValueError):
total = 0
logger.info(
"Authentik users page=%s page_size=%s -> %s items (total=%s)",
page,
page_size,
len(items),
total,
)
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
}
users = self.users(search=search, page=page, page_size=page_size)
groups = self.groups()
group_names = {group["id"]: group["name"] for group in groups["items"]}
summaries: list[dict[str, Any]] = []
for user in users["items"]:
group_ids = self._group_references(user)
summaries.append(
{
"id": _identifier(user),
"username": _text(user.get("username")),
"name": _text(user.get("name")),
"email": _text(user.get("email")),
"is_active": bool(user.get("is_active", True)),
"is_superuser": bool(user.get("is_superuser", False)),
"is_staff": bool(user.get("is_staff", False)),
"groups": [
{
"id": group_id,
"name": group_names.get(group_id, f"Unknown group ({group_id})"),
"known": group_id in group_names,
}
for group_id in group_ids
],
}
)
return {"items": summaries, "total": users["total"], "page": users["page"], "page_size": users["page_size"]}
@@ -1,15 +1,11 @@
"""Authentik service definition.
Authentik is the user-directory source (replacing the Jellyfin-backed Users
page). Its directory API is queried via :class:`AuthentikClient` and surfaced
on the Authentik service page (Users + Messaging tabs). OIDC authentication
is unchanged -- this service type is for the directory, not SSO.
"""
"""Authentik service definition for read-only directory and access metadata."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from pydantic import Field
from media_library_viewer_api.clients.authentik import AuthentikClient
from media_library_viewer_api.integrations.base import (
SecretField,
@@ -17,27 +13,25 @@ from media_library_viewer_api.integrations.base import (
ServiceConfigBase,
ServiceDefinition,
TestResult,
WidgetConfigBase,
translate_connection_error,
widget_kind,
)
if TYPE_CHECKING:
from media_library_viewer_api.services.settings_store import SettingsStore
def test_connection(
config: dict[str, Any],
secrets: dict[str, str],
store: SettingsStore,
) -> TestResult:
"""Probe AuthentikClient.users(page=1, page_size=1) — lightest directory call."""
def test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) -> TestResult:
"""Probe the least-expensive Authentik directory endpoint."""
try:
base_url = str(config.get("base_url") or "").rstrip("/")
api_token = str(secrets.get("api_token") or "")
timeout = float(config.get("timeout_seconds") or 60)
client = AuthentikClient(base_url=base_url, api_token=api_token, timeout=timeout)
client = AuthentikClient(
base_url=str(config.get("base_url") or "").rstrip("/"),
api_token=str(secrets.get("api_token") or ""),
timeout=float(config.get("timeout_seconds") or 60),
)
result = client.users(page=1, page_size=1)
total = result.get("total", 0) if isinstance(result, dict) else 0
return TestResult(ok=True, detail="Connected to Authentik.", evidence=f"{total} users")
return TestResult(ok=True, detail="Connected to Authentik.", evidence=f"{result.get('total', 0)} users")
except Exception as exc:
return translate_connection_error(exc, context="Authentik")
@@ -46,17 +40,46 @@ class AuthentikConfig(ServiceConfigBase):
"""Non-secret Authentik connection config."""
base_url: ServiceBaseUrl
timeout_seconds: int = 60
timeout_seconds: int = Field(default=60, ge=1, le=300)
class AuthentikListWidgetConfig(WidgetConfigBase):
"""Bounded display count for read-only Authentik list widgets."""
limit: int = Field(default=10, ge=1, le=50)
DEFINITION = ServiceDefinition(
service_type="authentik",
name="Authentik",
description="User directory and identity provider integration.",
description="Read-only user directory, groups, and application access metadata.",
config_model=AuthentikConfig,
secret_fields=[
SecretField(key="api_token", label="API token", required=True),
secret_fields=[SecretField(key="api_token", label="API token", required=True)],
widget_kinds=[
widget_kind(
kind="access_summary",
name="User access summary",
description="User group memberships and explicit staff/superuser status; not effective authorization.",
model_cls=AuthentikListWidgetConfig,
default_config={"limit": 10},
refresh_interval_ms=60_000,
),
widget_kind(
kind="groups",
name="Groups",
description="Read-only Authentik group list.",
model_cls=AuthentikListWidgetConfig,
default_config={"limit": 10},
refresh_interval_ms=60_000,
),
widget_kind(
kind="applications",
name="Applications",
description="Read-only Authentik application list.",
model_cls=AuthentikListWidgetConfig,
default_config={"limit": 10},
refresh_interval_ms=60_000,
),
],
widget_kinds=[],
test_callable=test_connection,
)
@@ -1,10 +1,7 @@
"""Authentik directory + messaging router.
"""Read-only Authentik directory, access metadata, and messaging router.
Resolves an ``authentik`` service instance from the registry, builds an
:class:`AuthentikClient` from its config + decrypted ``api_token`` secret, and
proxies paginated directory queries plus message-compose (email enqueue).
Graceful "not configured" / "unreachable" payloads (matching the monitoring
router's pattern) so the UI always renders.
Directory data is service-scoped and fails gracefully so the service page can
render a useful empty/error state when Authentik is unavailable.
"""
from __future__ import annotations
@@ -12,7 +9,7 @@ from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from media_library_viewer_api.clients.authentik import AuthentikClient
@@ -30,7 +27,7 @@ router = APIRouter(prefix="/api/services/authentik", tags=["authentik"])
class MessageRequest(BaseModel):
"""Compose-request body for the Authentik messaging endpoint."""
"""Compose-request body for the existing Authentik messaging endpoint."""
recipient_emails: list[str]
subject: str
@@ -38,39 +35,99 @@ class MessageRequest(BaseModel):
def _build_client(service: ServiceRecord) -> AuthentikClient:
base_url = str(service.config.get("base_url") or "").rstrip("/")
api_token = str(service.secrets.get("api_token") or "")
try:
timeout = float(service.config.get("timeout_seconds") or 10)
except (TypeError, ValueError):
timeout = 10.0
return AuthentikClient(base_url=base_url, api_token=api_token, timeout=timeout)
return AuthentikClient(
base_url=str(service.config.get("base_url") or "").rstrip("/"),
api_token=str(service.secrets.get("api_token") or ""),
timeout=timeout,
)
def _empty(error: str) -> dict[str, Any]:
def _empty_directory(error: str) -> dict[str, Any]:
return {"items": [], "total": 0, "page": 1, "page_size": 50, "error": error}
def _empty_collection(error: str) -> dict[str, Any]:
return {"items": [], "total": 0, "error": error}
def _service_or_error(store: SettingsStore, service_id: str) -> ServiceRecord | None:
return resolve_service_record(store, "authentik", service_id)
@router.get("/{service_id}/users")
def get_authentik_users(
service_id: str,
search: str | None = None,
page: int = 1,
page_size: int = 50,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200),
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Paginated Authentik user directory for a specific service instance."""
service = resolve_service_record(store, "authentik", service_id)
"""Paginated raw directory users for the existing messaging surface."""
service = _service_or_error(store, service_id)
if service is None:
logger.info("Authentik users requested but no enabled authentik service for id=%s", service_id)
return _empty("Authentik service not configured")
return _empty_directory("Authentik service not configured")
try:
client = _build_client(service)
return client.users(search=search, page=page, page_size=page_size)
return _build_client(service).users(search=search, page=page, page_size=page_size)
except Exception:
logger.exception("Authentik users query failed for service %s", service_id)
return _empty("Authentik is unreachable")
return _empty_directory("Authentik is unreachable")
@router.get("/{service_id}/access-summary")
def get_authentik_access_summary(
service_id: str,
search: str | None = None,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200),
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""User groups plus explicit staff/superuser flags, not effective permissions."""
service = _service_or_error(store, service_id)
if service is None:
return _empty_directory("Authentik service not configured")
try:
return _build_client(service).access_summaries(search=search, page=page, page_size=page_size)
except Exception:
logger.exception("Authentik access summary query failed for service %s", service_id)
return _empty_directory("Authentik is unreachable")
@router.get("/{service_id}/groups")
def get_authentik_groups(
service_id: str,
limit: int = Query(default=100, ge=1, le=200),
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Display-safe, service-scoped Authentik group list."""
service = _service_or_error(store, service_id)
if service is None:
return _empty_collection("Authentik service not configured")
try:
return _build_client(service).groups(limit=limit)
except Exception:
logger.exception("Authentik groups query failed for service %s", service_id)
return _empty_collection("Authentik is unreachable")
@router.get("/{service_id}/applications")
def get_authentik_applications(
service_id: str,
limit: int = Query(default=100, ge=1, le=200),
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Display-safe Authentik applications without provider or policy details."""
service = _service_or_error(store, service_id)
if service is None:
return _empty_collection("Authentik service not configured")
try:
return _build_client(service).applications(limit=limit)
except Exception:
logger.exception("Authentik applications query failed for service %s", service_id)
return _empty_collection("Authentik is unreachable")
@router.get("/{service_id}/message/status")
@@ -80,8 +137,7 @@ def get_authentik_message_status(
mail_queue: MailQueue = Depends(get_mail_queue),
) -> dict[str, Any]:
"""Mail-queue status snapshot for the Authentik messaging tab."""
service = resolve_service_record(store, "authentik", service_id)
if service is None:
if _service_or_error(store, service_id) is None:
return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"}
return mail_queue.status()
@@ -94,20 +150,16 @@ def post_authentik_message(
mail_queue: MailQueue = Depends(get_mail_queue),
) -> dict[str, Any]:
"""Enqueue an email to Authentik-sourced recipients via the mail queue."""
service = resolve_service_record(store, "authentik", service_id)
if service is None:
if _service_or_error(store, service_id) is None:
return {"status": "error", "error": "Authentik service not configured"}
recipients = [r.strip() for r in body.recipient_emails if r.strip()]
recipients = [recipient.strip() for recipient in body.recipient_emails if recipient.strip()]
if not recipients:
return {"status": "error", "error": "No recipients with valid email addresses."}
settings = get_settings()
try:
validate_smtp_settings(settings)
except ValueError as exc:
return {"status": "error", "error": f"SMTP settings invalid: {exc}"}
request_id = mail_queue.enqueue(
settings=settings,
recipients=recipients,
@@ -115,8 +167,4 @@ def post_authentik_message(
html_body=body.html_body,
)
logger.info("Authentik message enqueued for service %s (%d recipients)", service_id, len(recipients))
return {
"status": "queued",
"request_id": request_id,
"recipient_count": len(recipients),
}
return {"status": "queued", "request_id": request_id, "recipient_count": len(recipients)}
@@ -19,6 +19,7 @@ from typing import Any, Protocol
import requests
from media_library_viewer_api.clients.authentik import AuthentikClient
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
from media_library_viewer_api.domain.dashboard import (
@@ -316,6 +317,36 @@ class AlertmanagerWidgetSource:
return {"error": f"Alertmanager query failed: {exc}"}
class AuthentikWidgetSource:
"""Fetch bounded, display-safe Authentik directory metadata."""
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
try:
if service is None:
return {"error": "Authentik widget is missing its service"}
base_url = str(service.config.get("base_url") or "")
api_token = str(service.secrets.get("api_token") or "")
timeout = _safe_int(service.config.get("timeout_seconds") or 60, 60)
limit = max(1, min(_safe_int(config.get("limit") or 10, 10), 50))
client = await asyncio.wait_for(
asyncio.to_thread(AuthentikClient, base_url, api_token, timeout), timeout=timeout
)
if widget_kind == "access_summary":
return await asyncio.wait_for(
asyncio.to_thread(client.access_summaries, page=1, page_size=limit), timeout=timeout
)
if widget_kind == "groups":
return await asyncio.wait_for(asyncio.to_thread(client.groups, limit=limit), timeout=timeout)
if widget_kind == "applications":
return await asyncio.wait_for(asyncio.to_thread(client.applications, limit=limit), timeout=timeout)
return {"error": f"Unknown Authentik widget kind: {widget_kind}"}
except asyncio.TimeoutError as _timeout_error:
return {"error": "Authentik data fetch timed out"}
except Exception as exc:
logger.exception("authentik adapter failed")
return {"error": f"Authentik data fetch failed: {exc}"}
class JellyfinWidgetSource:
"""Fetch Jellyfin sessions and map them to activity rows."""
@@ -531,6 +562,7 @@ SERVICE_ADAPTERS: dict[str, WidgetSource] = {
"qbittorrent": QbittorrentWidgetSource(),
"alertmanager": AlertmanagerWidgetSource(),
"jellyfin": JellyfinWidgetSource(),
"authentik": AuthentikWidgetSource(),
"remote_machine": SshTaskWidgetSource(),
}
+103 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Generator
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -19,7 +20,7 @@ TEST_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def _encryption_key(monkeypatch: pytest.MonkeyPatch) -> None:
def _encryption_key(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]:
"""Provide a stable MANAGE_ENCRYPTION_KEY for every test."""
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY)
reset_encryption_key_cache()
@@ -28,7 +29,7 @@ def _encryption_key(monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.fixture()
def store(tmp_path: Path) -> SettingsStore:
def store(tmp_path: Path) -> Generator[SettingsStore, None, None]:
s = SettingsStore(tmp_path / "settings.sqlite")
s.ensure_defaults()
app.dependency_overrides[get_settings_store] = lambda: s
@@ -181,3 +182,103 @@ class TestAuthentikUsersEndpoint:
data = response.json()
assert data["items"] == []
assert "error" in data
class TestAuthentikAccessMetadata:
@patch.object(AuthentikClient, "get")
def test_groups_and_applications_paginate_and_whitelist_fields(self, mock_get: MagicMock) -> None:
def payload(path: str, **params: object) -> dict[str, object]:
if path == "/core/groups/":
if params["page"] == 1:
return {"pagination": {"count": 2}, "results": [{"pk": 1, "name": "Admins"}]}
return {"pagination": {"count": 2}, "results": [{"id": "g2", "display_name": "Readers"}]}
return {
"pagination": {"count": 1},
"results": [
{
"pk": 3,
"name": "Portal",
"slug": "portal",
"meta_launch_url": "https://portal.example.com",
"provider": {"client_secret": "must-not-leak"},
"policy_engine_mode": "any",
}
],
}
mock_get.side_effect = payload
auth = AuthentikClient(base_url="https://auth.example.com", api_token="t")
assert auth.groups(limit=2)["items"] == [{"id": "1", "name": "Admins"}, {"id": "g2", "name": "Readers"}]
application = auth.applications(limit=1)["items"][0]
assert application == {
"id": "3",
"name": "Portal",
"slug": "portal",
"launch_url": "https://portal.example.com",
}
assert "provider" not in application
@patch.object(AuthentikClient, "get")
def test_access_summary_uses_group_references_without_user_detail_calls(self, mock_get: MagicMock) -> None:
def payload(path: str, **params: object) -> dict[str, object]:
if path == "/core/users/":
return {
"pagination": {"count": 1},
"results": [
{
"pk": 7,
"username": "alice",
"name": "Alice",
"groups": [1, {"id": "missing"}],
"is_superuser": True,
"is_staff": False,
}
],
}
assert path == "/core/groups/"
return {"pagination": {"count": 1}, "results": [{"pk": 1, "name": "Admins"}]}
mock_get.side_effect = payload
result = AuthentikClient(base_url="https://auth.example.com", api_token="t").access_summaries()
assert result["items"][0]["groups"] == [
{"id": "1", "name": "Admins", "known": True},
{"id": "missing", "name": "Unknown group (missing)", "known": False},
]
assert bool(result["items"][0]["is_superuser"])
assert all(call.args[0] in {"/core/users/", "/core/groups/"} for call in mock_get.call_args_list)
class TestAuthentikAccessEndpoints:
def test_not_configured_access_collections_return_empty_envelopes(self, store: SettingsStore) -> None:
client = TestClient(app)
for path in ("access-summary", "groups", "applications"):
response = client.get(f"/api/services/authentik/missing/{path}")
assert response.status_code == 200
assert response.json()["items"] == []
assert response.json()["error"] == "Authentik service not configured"
@patch("media_library_viewer_api.routers.authentik_users.AuthentikClient")
def test_access_summary_endpoint_returns_normalized_data(
self, mock_client_cls: MagicMock, store: SettingsStore
) -> None:
mock_client = MagicMock()
mock_client.access_summaries.return_value = {
"items": [{"id": "1", "groups": []}],
"total": 1,
"page": 1,
"page_size": 25,
}
mock_client_cls.return_value = mock_client
service = store.upsert_service(
{
"service_type": "authentik",
"name": "Main",
"config": {"base_url": "https://auth.example.com"},
"enabled": True,
},
secret_values={"api_token": "secret-token"},
)
response = TestClient(app).get(f"/api/services/authentik/{service['id']}/access-summary?page_size=25")
assert response.status_code == 200
assert response.json()["items"] == [{"id": "1", "groups": []}]
mock_client.access_summaries.assert_called_once_with(search=None, page=1, page_size=25)
+50 -28
View File
@@ -28,6 +28,13 @@ from media_library_viewer_api.services.secrets import (
)
from media_library_viewer_api.services.settings_store import SettingsStore
def _definition(service_type: str):
definition = get_service_definition(service_type)
assert definition
return definition
TEST_KEY = Fernet.generate_key().decode()
@@ -73,7 +80,7 @@ def test_registry_contains_eight_service_types():
def test_jellyseerr_absorbed_into_jellyfin():
"""Jellyseerr is no longer its own service type (absorbed into Jellyfin)."""
assert "jellyseerr" not in SERVICE_DEFINITIONS
jellyfin = get_service_definition("jellyfin")
jellyfin = _definition("jellyfin")
jellyfin_config = jellyfin.config_schema["properties"]
assert "jellyseerr_url" in jellyfin_config
# jellyseerr_api_key moved from config to a secret field.
@@ -82,8 +89,8 @@ def test_jellyseerr_absorbed_into_jellyfin():
def test_backups_service_definition():
definition = get_service_definition("backups")
assert definition is not None
definition = _definition("backups")
assert definition
assert definition.secret_fields == []
assert {wk.kind for wk in definition.widget_kinds} == {"summary"}
schema = definition.config_schema
@@ -91,35 +98,43 @@ def test_backups_service_definition():
def test_authentik_service_definition():
definition = get_service_definition("authentik")
assert definition is not None
definition = _definition("authentik")
assert definition
assert {sf.key for sf in definition.secret_fields} == {"api_token"}
assert definition.secret_fields[0].required is True
assert definition.widget_kinds == []
assert definition.secret_fields[0].required
assert {widget.kind for widget in definition.widget_kinds} == {
"access_summary",
"groups",
"applications",
}
schema = definition.config_schema
assert "base_url" in schema["properties"]
assert "timeout_seconds" in schema["properties"]
def test_definitions_declare_widget_kinds():
assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric", "chart", "gauge", "mean"}
assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"}
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {
assert {wk.kind for wk in _definition("prometheus").widget_kinds} == {"metric", "chart", "gauge", "mean"}
assert {wk.kind for wk in _definition("alertmanager").widget_kinds} == {"active_alerts"}
assert {wk.kind for wk in _definition("jellyfin").widget_kinds} == {
"activity",
"now_playing",
"stat",
"stats_overview",
}
assert get_service_definition("nextcloud").widget_kinds == []
assert get_service_definition("authentik").widget_kinds == []
assert {wk.kind for wk in get_service_definition("backups").widget_kinds} == {"summary"}
assert {wk.kind for wk in get_service_definition("remote_machine").widget_kinds} == {"task_output"}
assert _definition("nextcloud").widget_kinds == []
assert {widget.kind for widget in _definition("authentik").widget_kinds} == {
"access_summary",
"groups",
"applications",
}
assert {wk.kind for wk in _definition("backups").widget_kinds} == {"summary"}
assert {wk.kind for wk in _definition("remote_machine").widget_kinds} == {"task_output"}
def test_widget_kind_lookup():
assert get_widget_kind("prometheus", "metric") is not None
assert get_widget_kind("prometheus", "missing") is None
assert get_widget_kind("unknown", "metric") is None
assert get_widget_kind("prometheus", "metric")
assert not get_widget_kind("prometheus", "missing")
assert not get_widget_kind("unknown", "metric")
def test_chart_widget_kinds_expose_unit_and_scale_options():
@@ -128,25 +143,28 @@ def test_chart_widget_kinds_expose_unit_and_scale_options():
scales = ["auto", "k", "m", "g", "t"]
prom_chart = get_widget_kind("prometheus", "chart")
assert prom_chart is not None
assert prom_chart
prom_props = prom_chart.config_schema["properties"]
assert prom_props["unit"]["enum"] == units
assert prom_props["scale"]["enum"] == scales
qbit_speed = get_widget_kind("qbittorrent", "speed")
assert qbit_speed is not None
assert qbit_speed
qbit_props = qbit_speed.config_schema["properties"]
assert qbit_props["unit"]["enum"] == units
assert qbit_props["scale"]["enum"] == scales
# qBittorrent speed data is bytes/sec by default.
assert qbit_speed.default_config["unit"] == "bytes_per_sec"
# totals/active are not graphs and stay option-less.
assert "unit" not in get_widget_kind("qbittorrent", "totals").config_schema["properties"]
assert "unit" not in get_widget_kind("qbittorrent", "active").config_schema["properties"]
qbit_totals = get_widget_kind("qbittorrent", "totals")
qbit_active = get_widget_kind("qbittorrent", "active")
assert qbit_totals and qbit_active
assert "unit" not in qbit_totals.config_schema["properties"]
assert "unit" not in qbit_active.config_schema["properties"]
def test_service_config_schema_is_json_schema():
schema = get_service_definition("prometheus").config_schema
schema = _definition("prometheus").config_schema
assert schema["type"] == "object"
assert "grafana_url" in schema["properties"]
@@ -321,7 +339,7 @@ def test_service_test_uses_stored_secrets_when_not_reentered(client):
},
)
assert res.status_code == 200
assert res.json()["ok"] is True
assert res.json()["ok"]
# The stored grafana_api_key was used for the request (not empty).
headers = mock_post.call_args.kwargs["headers"]
assert headers["Authorization"] == "Bearer secret-token"
@@ -354,16 +372,16 @@ def test_invalid_config_rejected(client):
)
def test_service_base_url_requires_http_schema(bad_url):
"""Every service base_url must include an http:// or https:// schema."""
model = get_service_definition("prometheus").config_model
model = _definition("prometheus").config_model
with pytest.raises(ValidationError):
model.model_validate({"grafana_url": bad_url, "timeout_seconds": 5})
@pytest.mark.parametrize("service_type", ["alertmanager", "jellyfin", "authentik", "nextcloud"])
def test_service_base_url_accepts_absolute_urls(service_type):
model = get_service_definition(service_type).config_model
model = _definition(service_type).config_model
instance = model.model_validate({"base_url": "https://example.com"})
assert instance.base_url == "https://example.com"
assert getattr(instance, "base_url") == "https://example.com"
def test_unknown_secret_field_rejected(client):
@@ -451,13 +469,14 @@ def test_delete_service_cascades_to_widgets(client, tmp_path):
)
store.delete_service(service["id"])
assert store.get_service(service["id"]) is None
assert not store.get_service(service["id"])
with store.connect() as conn:
remaining = conn.execute(
"SELECT COUNT(*) FROM dashboard_widgets WHERE service_id = ?",
(service["id"],),
).fetchone()
assert int(remaining[0]) == 0
assert remaining is not None
assert remaining[0] == 0
# ---------------------------------------------------------------------------
@@ -593,6 +612,7 @@ def test_jellyseerr_migrates_into_single_jellyfin(tmp_path):
# Jellyfin config gained jellyseerr_url; the api key is now a secret.
migrated = store.get_service(jellyfin["id"])
assert migrated
assert migrated["config"]["jellyseerr_url"] == "https://jellyseerr.example.com"
assert "jellyseerr_api_key" not in migrated["config"]
assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "js-key"
@@ -621,11 +641,13 @@ def test_jellyseerr_api_key_migrates_from_config_to_secret(tmp_path):
store.ensure_defaults() # runs the config->secret migration
migrated = store.get_service(jellyfin["id"])
assert migrated
assert "jellyseerr_api_key" not in migrated["config"]
assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "plaintext-key"
# Idempotent: a second run keeps it in secrets, doesn't wipe it.
store.ensure_defaults()
migrated = store.get_service(jellyfin["id"])
assert migrated
assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "plaintext-key"
+27 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import time
from types import SimpleNamespace
from typing import Any
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
from cryptography.fernet import Fernet
@@ -16,6 +16,7 @@ from media_library_viewer_api.main import app
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.sources import (
AlertmanagerWidgetSource,
AuthentikWidgetSource,
BackupsWidgetSource,
JellyfinWidgetSource,
ServiceRecord,
@@ -416,6 +417,31 @@ async def test_static_adapter():
assert result == {"text": "hi"}
@pytest.mark.asyncio
async def test_authentik_adapter_returns_bounded_access_summaries():
client = MagicMock()
client.access_summaries.return_value = {"items": [{"id": "u1", "groups": []}], "total": 1}
service = ServiceRecord(
id="auth",
service_type="authentik",
name="Auth",
config={"base_url": "https://auth.example.com", "timeout_seconds": 5},
secrets={"api_token": "token"},
)
with patch("media_library_viewer_api.widgets.sources.AuthentikClient", return_value=client):
result = await AuthentikWidgetSource().fetch(service, "access_summary", {"limit": 100})
assert result["items"] == [{"id": "u1", "groups": []}]
client.access_summaries.assert_called_once_with(page=1, page_size=50)
def test_authentik_definition_declares_read_only_widget_kinds():
from media_library_viewer_api.integrations.registry import get_service_definition
definition = get_service_definition("authentik")
assert definition is not None
assert {kind.kind for kind in definition.widget_kinds} == {"access_summary", "groups", "applications"}
@pytest.mark.asyncio
async def test_backups_adapter(client):
store = app.dependency_overrides[get_settings_store]()