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:
Developer
2026-07-09 22:41:06 +00:00
parent c4f68b4938
commit 3391fbc85d
11 changed files with 697 additions and 3 deletions
@@ -12,7 +12,7 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.integrations.base import validate_config
from media_library_viewer_api.integrations.base import TestResult, validate_config
from media_library_viewer_api.integrations.registry import (
SERVICE_DEFINITIONS,
get_service_definition,
@@ -180,3 +180,30 @@ def delete_instance(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Service not found")
store.delete_service(service_id)
return {"status": "deleted"}
@router.post("/test")
def test_instance(
body: ServiceInstanceInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Test connectivity + credentials for unsaved service input.
Validates first (422 on malformed config), dispatches to the per-type
test_callable, and returns ``{ok, detail, evidence}``. Does NOT persist.
"""
_validate_input(body) # raises HTTPException(422) on bad config/type/secrets
definition = require_service_definition(body.service_type)
if definition.test_callable is None:
logger.info("test requested type=%s ok=true (no test_callable)", body.service_type)
return {"ok": True, "detail": "No connection test for this service type", "evidence": None}
try:
result: TestResult = definition.test_callable(body.config, body.secrets, store)
except Exception as exc:
logger.exception("test_callable raised for type=%s", body.service_type)
result = TestResult(ok=False, detail=f"Test failed unexpectedly: {exc}")
logger.info("test requested type=%s ok=%s", body.service_type, result.ok)
return {"ok": result.ok, "detail": result.detail, "evidence": result.evidence}