feat: add Authentik access widgets
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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]()
|
||||
|
||||
Reference in New Issue
Block a user