feat(service-credential-tester): slice 1 — backend test endpoint + per-type routines
TestResult dataclass + translate_connection_error shared helper in base.py. test_callable field on ServiceDefinition (default None). 7 per-type test_connection routines (qbittorrent, prometheus via Grafana gateway, alertmanager, jellyfin, authentik, ssh_tasks via build_ssh_client, nextcloud). POST /api/services/test endpoint: validation-first (422 on malformed config), dispatch, no-persistence, no-secret-logs. backups has test_callable=None. qBit 'Fails.' → specific auth message (resolves #3 at API layer). Backend: 362 pytest pass (+31 new), ruff clean. Frontend: build green.
This commit is contained in:
@@ -15,11 +15,16 @@ map. There is no runtime plugin loading.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Annotated, Any
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Callable
|
||||
|
||||
import requests
|
||||
from pydantic import BaseModel, BeforeValidator, Field
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
|
||||
def _validate_service_base_url(value: Any) -> str:
|
||||
"""Require an absolute http(s) URL for service ``base_url`` fields.
|
||||
@@ -93,6 +98,20 @@ class WidgetKind:
|
||||
config_model: type[WidgetConfigBase] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestResult:
|
||||
"""Outcome of a credential/connectivity test for a service instance."""
|
||||
|
||||
ok: bool
|
||||
detail: str
|
||||
evidence: str | None = None
|
||||
|
||||
|
||||
#: A test routine receives (config, secrets, store). The store is needed for
|
||||
#: ssh_tasks (SSH-key resolution). Other types ignore it.
|
||||
TestCallable = Callable[[dict[str, Any], dict[str, str], "SettingsStore"], TestResult]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceDefinition:
|
||||
"""Closed description of an external service type."""
|
||||
@@ -103,6 +122,7 @@ class ServiceDefinition:
|
||||
config_model: type[ServiceConfigBase]
|
||||
secret_fields: list[SecretField]
|
||||
widget_kinds: list[WidgetKind]
|
||||
test_callable: TestCallable | None = None
|
||||
|
||||
@property
|
||||
def config_schema(self) -> dict[str, Any]:
|
||||
@@ -148,3 +168,58 @@ def validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) -
|
||||
"""Validate a config dict against a Pydantic model and return the cleaned dict."""
|
||||
instance = model_cls.model_validate(config or {})
|
||||
return instance.model_dump(exclude_none=True)
|
||||
|
||||
|
||||
def translate_connection_error(exc: Exception, *, context: str = "") -> TestResult:
|
||||
"""Map a common connection/auth exception to a human-friendly TestResult.
|
||||
|
||||
Handles patterns extracted from ``test_machine_ssh`` (settings.py) plus
|
||||
HTTP-client patterns from the widget sources. Each per-type test routine
|
||||
calls this for unexpected exceptions, but handles its **type-specific**
|
||||
auth failures directly (e.g., qBit ``"Fails."``).
|
||||
"""
|
||||
message = str(exc)
|
||||
lowered = message.lower()
|
||||
|
||||
# Auth failures (HTTP 401/403)
|
||||
if isinstance(exc, requests.HTTPError):
|
||||
status_code = exc.response.status_code if exc.response is not None else 0
|
||||
if status_code in (401, 403):
|
||||
return TestResult(
|
||||
ok=False,
|
||||
detail=f"Authentication failed — the service rejected the credentials ({status_code}).",
|
||||
)
|
||||
if "authentication failed" in lowered or "no authentication methods available" in lowered:
|
||||
return TestResult(ok=False, detail="Authentication failed — check the credentials, API key, or SSH key.")
|
||||
|
||||
# Timeout (before OSError check, since requests.Timeout is a subclass of OSError)
|
||||
if isinstance(exc, (requests.Timeout, TimeoutError, asyncio.TimeoutError)):
|
||||
return TestResult(ok=False, detail="Connection timed out — the service did not respond in time.")
|
||||
|
||||
# Connection refused / DNS / unreachable
|
||||
if isinstance(exc, (requests.ConnectionError, ConnectionRefusedError, OSError)):
|
||||
if (
|
||||
"name or service not known" in lowered
|
||||
or "nodename nor servname" in lowered
|
||||
or "getaddrinfo failed" in lowered
|
||||
):
|
||||
return TestResult(ok=False, detail="Host not found — check the URL/hostname for typos.")
|
||||
return TestResult(
|
||||
ok=False,
|
||||
detail="Connection refused — the service is not reachable at the configured address.",
|
||||
)
|
||||
|
||||
# SSL / certificate errors
|
||||
if "ssl" in lowered or "certificate" in lowered:
|
||||
return TestResult(ok=False, detail="SSL/TLS error — the service's certificate is invalid or untrusted.")
|
||||
|
||||
# SSH banner (from test_machine_ssh pattern)
|
||||
if "protocol banner" in lowered:
|
||||
return TestResult(
|
||||
ok=False,
|
||||
detail="SSH banner not received — confirm the SSH service is running and the port is correct.",
|
||||
)
|
||||
|
||||
# Fallback
|
||||
prefix = f"{context}: " if context else ""
|
||||
return TestResult(ok=False, detail=f"{prefix}{message[:200]}")
|
||||
|
||||
Reference in New Issue
Block a user