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