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
+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"