Files
manage/openspec/changes/service-credential-tester/design.md
T
Developer 1fc3127b58 spec(service-credential-tester): add design
8 decisions: TestResult dataclass, test_callable(store, config, secrets),
translate_connection_error shared helper (extracts test_machine_ssh patterns),
POST /api/services/test validation-first, shared ServiceTestPanel component,
field-edit-clears-result, Prom test via Grafana gateway, ssh_tasks reuses
build_ssh_client. 2-slice plan. Source findings: edit dialog is in Settings.tsx
ServiceConfigEditor (not ServicePage.tsx — spec drift); test_machine_ssh is
inlined in router (not reusable as-is).
2026-07-09 22:21:03 +00:00

675 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SDD Design: Service Credential Tester
**Change:** `service-credential-tester`
**Phase:** design
**Date:** 2026-07-09
## 0. Source findings (read before anything else)
The proposal and spec were written against a mental model. Reading actual source surfaced deviations the design must account for. Trust source, not assumptions.
| Spec/proposal claim | Actual source reality | Design impact |
|---|---|---|
| Spec CT-115: "the edit dialog on `ServicePage.tsx`" | **`ServicePage.tsx` has no edit dialog.** It's a read-only tabbed view (Overview + content tabs + Widgets). Service editing lives in `Settings.tsx` as `ServiceConfigEditor` (a master-detail panel, lines ~14351596). The "Save" button calls `saveService.mutateAsync(buildInput())` directly (no try/catch — a latent error-swallow like the pre-#1-fix create dialog). | The Test button is added to **two** edit surfaces: `CreateServiceDialog` (ServicesPage.tsx) and `ServiceConfigEditor` (Settings.tsx). NOT ServicePage.tsx. Spec CT-115 should note this textual drift. |
| Proposal §5.1.3: "the edit dialog on `ServicePage.tsx`" | Same as above. | Same fix — target is `Settings.tsx::ServiceConfigEditor`. |
| Spec CT-109: "ssh_tasks test reuses the `test_machine_ssh` connect flow" | `test_machine_ssh` (settings.py:122180) is a **router endpoint**, not a reusable function. Its logic (build client → connect → translate errors) is inlined. However, `build_ssh_client` in `task_runner.py` already builds an `RemoteSSHClient` from a `ServiceRecord` — the exact pattern needed. | The ssh_tasks test routine constructs an `RemoteSSHClient` via `build_ssh_client(store, service_record)` and calls `.connect()`, then translates errors using the same message patterns as `test_machine_ssh` (protocol banner / auth failed). It needs a `SettingsStore` (for SSH key resolution) — see §3.6 for how test_callables get store access. |
| Proposal: "construct a `QbittorrentClient` from the config + decrypted secrets" | `QbittorrentClient.__init__(base_url, username, password, timeout)` takes **positional strings**, not a `config` dict. The `qbittorrent` config has `base_url` + `timeout_seconds`; secrets have `username` + `password`. | The test routine extracts fields from `config`/`secrets` dicts explicitly: `QbittorrentClient(config["base_url"], secrets["username"], secrets["password"], config.get("timeout_seconds", 10))`. |
| Proposal: "Alertmanager test probes `/api/v2/alerts`" | `AlertmanagerWidgetSource.fetch` (sources.py) already hits `/api/v2/alerts` with optional bearer auth. The exact URL pattern + auth-header logic is reusable. | The test routine mirrors the widget source's request shape (GET `{base_url}/api/v2/alerts`, optional `Authorization: Bearer {api_key}`). |
| Proposal: "Authentik test probes its directory endpoint" | `routers/authentik_users.py::_build_client` builds an `AuthentikClient(base_url, api_token, timeout)`. The client has a `.users()` method that hits the directory endpoint. | The test routine constructs an `AuthentikClient` and calls `.users(page=1, page_size=1)` — the lightest possible probe. |
| Proposal: "Nextcloud test probes `/status.php`" | `NextcloudConfig` has `base_url` + `username` (not a ServiceBaseUrl). Secret is `app_password`. `/status.php` is unauthenticated. | The test routine does a simple `GET {base_url}/status.php` (no auth headers); extracts `version` from the JSON response. |
| Proposal: "test_callable field on `ServiceDefinition`" | `ServiceDefinition` is a `@dataclass(frozen=True)` with fields: `service_type, name, description, config_model, secret_fields, widget_kinds`. Adding `test_callable` after `widget_kinds` works (dataclass field ordering — it has a default). | New field `test_callable: TestCallable | None = None` placed last. The type alias `TestCallable = Callable[[dict[str, Any], dict[str, str], SettingsStore], TestResult]` includes the store for SSH key resolution. |
No proposal/spec scope change is required — the *intent* (per-type credential tester) still holds. The findings above refine implementation details and correct the edit-surface target.
---
## 1. Architecture overview
A new `POST /api/services/test` endpoint validates the input (reusing `_validate_input`), resolves the service definition's `test_callable`, and runs it. Each integration declares its own `test_connection` routine alongside its `DEFINITION`. A shared error-translation helper maps common exception types to human-friendly strings. The frontend gains a Test button + result pill + gating in both the create dialog and the settings edit panel.
```
POST /api/services/test
└► _validate_input(body) → 422 on malformed config (CT-102)
└► definition = get_service_definition(body.service_type)
└► if definition.test_callable is None → {ok: true, detail: "No test..."} (CT-103/111)
└► result = test_callable(config, secrets, store)
└► per-type routine:
qbittorrent → QbittorrentClient.maindata()
prometheus → POST {grafana_url}/api/ds/query (expr "up")
alertmanager → GET {base_url}/api/v2/alerts
jellyfin → JellyfinClient.users()
authentik → AuthentikClient.users(page=1, page_size=1)
ssh_tasks → RemoteSSHClient.connect() via build_ssh_client
nextcloud → GET {base_url}/status.php
└► translate_connection_error(exc) → TestResult(ok=False, detail=...)
└► return {ok, detail, evidence} → HTTP 200 (CT-101)
```
No persistence. No secrets in logs. Auth-gated identically to every other `/api/services/*` endpoint.
---
## 2. Core data structures
### 2.1 TestResult dataclass (CT-101)
**File:** `backend/src/media_library_viewer_api/integrations/base.py`
```python
@dataclass(frozen=True)
class TestResult:
"""Outcome of a credential/connectivity test for a service instance."""
ok: bool
detail: str
evidence: str | None = None
```
### 2.2 TestCallable type alias + test_callable field on ServiceDefinition
**File:** `backend/src/media_library_viewer_api/integrations/base.py`
```python
from typing import Callable
# A test routine receives (config, secrets, store). The store is needed for
# ssh_tasks (SSH-key resolution via store.get_ssh_key). Other types ignore it.
TestCallable = Callable[["dict[str, Any]", "dict[str, str]", "SettingsStore"], TestResult]
```
`ServiceDefinition` gains (placed last, after `widget_kinds`, with a default):
```python
@dataclass(frozen=True)
class ServiceDefinition:
service_type: str
name: str
description: str
config_model: type[ServiceConfigBase]
secret_fields: list[SecretField]
widget_kinds: list[WidgetKind]
test_callable: TestCallable | None = None # NEW — placed last
```
**Why `SettingsStore` in the signature:** `ssh_tasks` needs `store.get_ssh_key(ssh_key_id)` to resolve the private key material. Every other type ignores it. Making it a uniform parameter avoids a special-case dispatch for one type.
### 2.3 Shared error-translation helper
**File:** `backend/src/media_library_viewer_api/integrations/base.py` (or a new `integrations/test_helpers.py` if base.py gets too large — design recommends base.py for discoverability)
```python
def translate_connection_error(exc: Exception, *, context: str = "") -> TestResult:
"""Map a common connection/auth exception to a human-friendly TestResult.
Handles the patterns extracted from ``test_machine_ssh`` (settings.py) plus
the HTTP-client patterns from the widget sources.
"""
message = str(exc)
lowered = message.lower()
# Auth failures (HTTP 401/403 or auth-specific strings)
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.")
# 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.")
# Timeout
if isinstance(exc, (requests.Timeout, TimeoutError, asyncio.TimeoutError)):
return TestResult(ok=False, detail="Connection timed out — the service did not respond in time.")
# 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]}")
```
Each per-type routine wraps its probe in `try/except` and calls `translate_connection_error` for unexpected exceptions, but handles its **type-specific** auth failures directly (e.g., qBit `"Fails."` response).
---
## 3. Per-type test routines
Each routine lives alongside its integration's `DEFINITION`. The signature is `(config: dict, secrets: dict, store: SettingsStore) -> TestResult`. All use `requests` (synchronous) wrapped in a short timeout — no asyncio needed (the endpoint handler calls the callable synchronously).
### 3.1 qbittorrent (CT-104)
**File:** `backend/src/media_library_viewer_api/integrations/qbittorrent.py`
```python
def test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) -> TestResult:
"""Login + probe maindata; surface auth failures specifically."""
try:
base_url = str(config.get("base_url") or "")
username = str(secrets.get("username") or "")
password = str(secrets.get("password") or "")
timeout = int(config.get("timeout_seconds") or 10)
client = QbittorrentClient(base_url, username, password, timeout=timeout)
data = client.maindata()
version = str(data.get("server_state", {}).get("qbittorrent_version", "") or "connected")
return TestResult(ok=True, detail="Connected to qBittorrent.", evidence=version)
except RuntimeError as exc:
# QbittorrentClient._login raises RuntimeError("qBittorrent login failed: Fails.")
lowered = str(exc).lower()
if "login failed" in lowered:
return TestResult(ok=False, detail="Authentication failed — qBittorrent rejected the credentials.")
return translate_connection_error(exc, context="qBittorrent")
except Exception as exc:
return translate_connection_error(exc, context="qBittorrent")
```
**Evidence:** qBittorrent version from `server_state` (fallback `"connected"`).
### 3.2 prometheus (CT-105)
**File:** `backend/src/media_library_viewer_api/integrations/prometheus.py`
```python
def test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) -> TestResult:
"""POST {grafana_url}/api/ds/query with expr 'up' via the Grafana gateway."""
try:
grafana_url = str(config.get("grafana_url") or "").rstrip("/")
api_key = str(secrets.get("grafana_api_key") or "")
datasource_uid = str(config.get("datasource_uid") or "prometheus")
timeout = int(config.get("timeout_seconds") or 10)
if not grafana_url:
return TestResult(ok=False, detail="Grafana gateway URL is required.")
if not api_key:
return TestResult(ok=False, detail="Grafana API key is required.")
body = {
"queries": [{"datasource": {"uid": datasource_uid, "type": "prometheus"},
"expr": "up", "format": "time_series",
"intervalMs": 15000, "maxDataPoints": 1, "refId": "A"}],
"from": "now-1m", "to": "now",
}
resp = requests.post(
f"{grafana_url}/api/ds/query", json=body, timeout=timeout,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
)
resp.raise_for_status()
return TestResult(ok=True, detail="Grafana gateway reachable.", evidence="Gateway reachable; datasource responded.")
except requests.HTTPError as exc:
return translate_connection_error(exc, context="Prometheus via Grafana")
except Exception as exc:
return translate_connection_error(exc, context="Prometheus via Grafana")
```
**Evidence:** `"Gateway reachable; datasource responded."` — validates the full path (Grafana up + datasource reachable + Prom responding).
### 3.3 alertmanager (CT-106)
**File:** `backend/src/media_library_viewer_api/integrations/alertmanager.py`
```python
def test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) -> TestResult:
"""GET /api/v2/alerts (or /api/v2/status) with optional bearer auth."""
try:
base_url = str(config.get("base_url") or "").rstrip("/")
timeout = int(config.get("timeout_seconds") or 5)
headers = {}
api_key = str(secrets.get("api_key") or "")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
resp = requests.get(f"{base_url}/api/v2/status", headers=headers, timeout=timeout)
resp.raise_for_status()
payload = resp.json()
version = str(payload.get("versionInfo", {}).get("version", "") or "connected")
return TestResult(ok=True, detail="Connected to Alertmanager.", evidence=version)
except Exception as exc:
return translate_connection_error(exc, context="Alertmanager")
```
**Evidence:** Alertmanager cluster version from `/api/v2/status`.
### 3.4 jellyfin (CT-107)
**File:** `backend/src/media_library_viewer_api/integrations/jellyfin.py`
```python
def test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) -> TestResult:
"""Call JellyfinClient.users() — the lightest authenticated probe."""
try:
base_url = str(config.get("base_url") or "")
api_key = str(secrets.get("api_key") or "")
timeout = int(config.get("timeout_seconds") or 10)
client = JellyfinClient(base_url, api_key, timeout=timeout)
users = client.users()
return TestResult(ok=True, detail="Connected to Jellyfin.", evidence=f"{len(users)} users")
except Exception as exc:
return translate_connection_error(exc, context="Jellyfin")
```
**Evidence:** `"<N> users"`.
### 3.5 authentik (CT-108)
**File:** `backend/src/media_library_viewer_api/integrations/authentik.py`
```python
def test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) -> TestResult:
"""Probe AuthentikClient.users(page=1, page_size=1) — lightest directory call."""
try:
base_url = str(config.get("base_url") or "").rstrip("/")
api_token = str(secrets.get("api_token") or "")
timeout = float(config.get("timeout_seconds") or 10)
client = AuthentikClient(base_url=base_url, api_token=api_token, timeout=timeout)
result = client.users(page=1, page_size=1)
return TestResult(ok=True, detail="Connected to Authentik.", evidence=f"{result.get('total', 0)} users")
except Exception as exc:
return translate_connection_error(exc, context="Authentik")
```
**Evidence:** `"<N> users"` from the directory total.
### 3.6 ssh_tasks (CT-109)
**File:** `backend/src/media_library_viewer_api/integrations/ssh_tasks.py`
```python
def test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) -> TestResult:
"""Build an SSH client via build_ssh_client and attempt .connect().
Reuses the same error-translation patterns as test_machine_ssh (banner,
auth failed). Known-host recording is preserved (first successful connect
records the host key, same as test_machine_ssh).
"""
from media_library_viewer_api.services.task_runner import build_ssh_client
from media_library_viewer_api.widgets.sources import ServiceRecord
host = str(config.get("host") or "").strip()
port = int(config.get("port") or 22)
try:
service = ServiceRecord(id="", service_type="ssh_tasks", name="test",
config=config, secrets=secrets, enabled=True)
client = build_ssh_client(store, service)
try:
client.connect()
except Exception as exc:
lowered = str(exc).lower()
if "protocol banner" in lowered:
return TestResult(ok=False, detail=f"SSH banner not received from {host}:{port}; confirm the SSH service is running.")
if "no authentication methods available" in lowered or "authentication failed" in lowered:
return TestResult(ok=False, detail=f"SSH authentication failed for {host}:{port}; check the SSH key, passphrase, or username.")
return translate_connection_error(exc, context=f"SSH {host}:{port}")
finally:
client.close()
return TestResult(ok=True, detail=f"SSH connection succeeded for {host}:{port}.", evidence=f"Connected to {host}:{port}")
except ValueError as exc:
return TestResult(ok=False, detail=str(exc))
except Exception as exc:
return translate_connection_error(exc, context=f"SSH {host}:{port}")
```
**Evidence:** `"Connected to <host>:<port>"`. **Known-host recording:** preserved — `RemoteSSHClient.connect()` records the host key on first successful connect (same as `test_machine_ssh`).
### 3.7 nextcloud (CT-110)
**File:** `backend/src/media_library_viewer_api/integrations/nextcloud.py`
```python
def test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) -> TestResult:
"""GET {base_url}/status.php (unauthenticated server probe)."""
try:
base_url = str(config.get("base_url") or "").rstrip("/")
resp = requests.get(f"{base_url}/status.php", timeout=10)
resp.raise_for_status()
payload = resp.json()
version = str(payload.get("version", "") or "connected")
return TestResult(ok=True, detail="Connected to Nextcloud.", evidence=version)
except Exception as exc:
return translate_connection_error(exc, context="Nextcloud")
```
**Evidence:** Nextcloud version from `/status.php`.
### 3.8 backups (CT-111)
**File:** `backend/src/media_library_viewer_api/integrations/backups.py`
```python
# No test_connection function. The DEFINITION's test_callable stays None (default).
# The endpoint returns {ok: true, detail: "No connection test for this service type"}.
```
---
## 4. Backend endpoint design
### 4.1 POST /api/services/test (CT-101, CT-102, CT-103, CT-112, CT-113)
**File:** `backend/src/media_library_viewer_api/routers/services.py`
```python
from media_library_viewer_api.integrations.base import TestResult
@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:
# A routine should never raise (it catches internally), but defend.
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}
```
**Key properties:**
- **Validation-first** (CT-102): `_validate_input` runs before any network call. Malformed config → 422 with the same detail format as create.
- **No persistence** (CT-112): no `store.upsert_service`, no `store.update_setting`. The body is consumed and discarded.
- **No secret logging** (CT-113): only `test requested type=%s ok=%s` at INFO. No body/config/secrets in any log line.
- **Dispatch** (CT-103): keys off `definition.test_callable` (None → default ok response).
### 4.2 DEFINITION updates — wiring test_callable
Each integration's `DEFINITION` gains `test_callable=test_connection`:
```python
# integrations/qbittorrent.py
DEFINITION = ServiceDefinition(
...,
test_callable=test_connection,
)
# integrations/backups.py
DEFINITION = ServiceDefinition(
..., # test_callable stays None (default)
)
```
Every integration module that has a `test_connection` function imports `TestResult` + `translate_connection_error` from `base.py` and `requests` as needed.
---
## 5. Frontend design
### 5.1 TestResult type (CT-114)
**File:** `frontend/src/types/index.ts`
```typescript
export interface ServiceTestResult {
ok: boolean;
detail: string;
evidence: string | null;
}
```
### 5.2 API client function (CT-114)
**File:** `frontend/src/api/services.ts`
```typescript
import type { ServiceTestResult } from "../types";
export async function testServiceInstance(
input: ServiceInstanceInput,
): Promise<ServiceTestResult> {
return post<ServiceTestResult>("/api/services/test", input);
}
```
### 5.3 useTestServiceInstance hook (CT-114)
**File:** `frontend/src/hooks/useServices.ts`
```typescript
import { testServiceInstance } from "../api/services";
export function useTestServiceInstance() {
return useMutation({
mutationFn: (input: ServiceInstanceInput) => testServiceInstance(input),
});
}
```
No cache invalidation needed — the test is a one-shot mutation with no query to refresh.
### 5.4 Shared ServiceTestPanel component
**File:** `frontend/src/components/ServiceTestPanel.tsx` (NEW — shared by both surfaces)
Both `CreateServiceDialog` and `ServiceConfigEditor` need the same UI: a Test button, a result pill, testPassed state, and Save-anyway toggle. Extracting it avoids duplication.
```tsx
interface Props {
input: ServiceInstanceInput | null; // null = no draft yet
onTestPassed: (passed: boolean) => void;
}
export function ServiceTestPanel({ input, onTestPassed }: Props) {
const testService = useTestServiceInstance();
const [result, setResult] = useState<ServiceTestResult | null>(null);
const [saveAnyway, setSaveAnyway] = useState(false);
// Clear result when input changes (CT-118)
useEffect(() => {
setResult(null);
onTestPassed(false);
}, [input]); // input is a new object on every field edit → re-test required
async function handleTest() {
if (!input) return;
setResult(null);
try {
const res = await testService.mutateAsync(input);
setResult(res);
onTestPassed(res.ok);
} catch (err) {
setResult({ ok: false, detail: err instanceof Error ? err.message : String(err), evidence: null });
onTestPassed(false);
}
}
const testPassed = result?.ok === true;
const canSave = testPassed || saveAnyway;
return (
<div className="flex flex-col gap-2">
<Button variant="outline" size="sm" onClick={handleTest} disabled={testService.isPending || !input}>
{testService.isPending ? "Testing…" : "Test credentials"}
</Button>
{result ? (
<Alert variant={result.ok ? "default" : "destructive"}>
<AlertDescription>
{result.ok ? `✓ Connected${result.evidence ? `${result.evidence}` : ""}` : `${result.detail}`}
</AlertDescription>
</Alert>
) : null}
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input type="checkbox" checked={saveAnyway} onChange={(e) => { setSaveAnyway(e.target.checked); onTestPassed(e.target.checked || testPassed); }} />
Save anyway (skip test)
</label>
{/* Hidden signal: parent reads canSave via onTestPassed callback */}
</div>
);
}
```
**CT-118 (field-edit clears result):** the `useEffect([input])` triggers on every render where `input` is a new object reference (React state updates create new objects). Since `setDraft({...draft, config})` creates a new object, any field edit resets the result.
**CT-117 (Save gating):** the parent passes `onTestPassed` to track the gating state. The parent's confirm button is disabled unless `testPassed || saveAnyway`.
### 5.5 Integration into CreateServiceDialog (CT-115, CT-117)
**File:** `frontend/src/pages/ServicesPage.tsx`
```tsx
function CreateServiceDialog({ open, onClose }) {
// ... existing state ...
const [testPassed, setTestPassed] = useState(false);
function reset() {
setDraft(null);
setSubmitError(null);
setTestPassed(false);
}
// Build the input object from the draft (for the test panel)
const testInput: ServiceInstanceInput | null = draft ? {
service_type: draft.serviceType,
name: draft.name.trim(),
config: draft.config,
secrets: draft.secrets,
enabled: draft.enabled,
} : null;
return (
<Dialog ...>
<DialogContent>
{/* ... existing fields ... */}
{draft ? (
<ServiceTestPanel input={testInput} onTestPassed={setTestPassed} />
) : null}
{submitError ? <Alert variant="destructive">...</Alert> : null}
{draft ? (
<DialogFooter
onCancel={reset}
onConfirm={save}
confirmLabel="Create service"
confirmDisabled={!draft.name.trim() || saveService.isPending || !testPassed}
/>
) : null}
</DialogContent>
</Dialog>
);
}
```
### 5.6 Integration into ServiceConfigEditor (CT-115, CT-117)
**File:** `frontend/src/pages/Settings.tsx`
```tsx
function ServiceConfigEditor({ instance, typeInfo }) {
// ... existing state ...
const [testPassed, setTestPassed] = useState(false);
const testInput: ServiceInstanceInput = {
id: instance.id,
service_type: instance.service_type,
name,
config: draftConfig,
secrets: Object.fromEntries(Object.entries(draftSecrets).filter(([, v]) => v !== "")),
enabled,
};
return (
<>
<div className="rounded-lg border p-4">
{/* ... existing fields ... */}
<ServiceTestPanel input={testInput} onTestPassed={setTestPassed} />
<div className="flex justify-between">
<Button onClick={handleSave} disabled={saveService.isPending || !testPassed}>Save</Button>
{/* ... delete button ... */}
</div>
</div>
</>
);
}
```
---
## 6. Slice plan
### Slice 1: Backend (~300380 lines)
**Goal:** endpoint + test_callable + shared helper + 7 per-type routines + tests.
| Task | File(s) | Lines |
|---|---|---|
| `TestResult` dataclass + `TestCallable` type + `translate_connection_error` helper | `integrations/base.py` | ~60 |
| `test_callable` field on `ServiceDefinition` | `integrations/base.py` | ~3 |
| 7 per-type `test_connection` routines + wire into DEFINITIONs | `integrations/{qbittorrent,prometheus,alertmanager,jellyfin,authentik,ssh_tasks,nextcloud}.py` | ~180 |
| `POST /api/services/test` endpoint | `routers/services.py` | ~30 |
| Backend tests: per-type mocked routines, endpoint dispatch, validation-first, no-persistence | `tests/test_services.py` + `tests/test_api.py` | ~120 |
**Exit gate:** `pytest` + `ruff` green.
### Slice 2: Frontend (~250320 lines)
**Goal:** API client + hook + shared Test panel + wire into both surfaces + tests.
| Task | File(s) | Lines |
|---|---|---|
| `ServiceTestResult` type | `types/index.ts` | ~5 |
| `testServiceInstance` API function | `api/services.ts` | ~5 |
| `useTestServiceInstance` hook | `hooks/useServices.ts` | ~8 |
| `ServiceTestPanel` component | `components/ServiceTestPanel.tsx` | ~70 |
| Wire into `CreateServiceDialog` | `pages/ServicesPage.tsx` | ~20 |
| Wire into `ServiceConfigEditor` | `pages/Settings.tsx` | ~15 |
| Frontend tests: panel renders both states, button fires mutation, gating, field-clear | `components/__tests__/ServiceTestPanel.test.tsx` + existing page tests | ~130 |
**Exit gate:** `npm run build` + `npm run lint` + `npm run test` green.
---
## 7. Test strategy
### 7.1 Backend tests (CT-119)
- **Per-type test_connection** (mocked): each routine gets a success case (mocked client returns data → `{ok: true, evidence: ...}`) and at least one failure case (mocked client raises → `{ok: false, detail: ...}`). qBit gets a specific `"Fails."` auth-failure test.
- **Endpoint dispatch**: `POST /api/services/test` with each service type dispatches correctly; backups returns the default ok-no-test.
- **Validation-first**: schema-less URL → 422, no network call mocked.
- **No-persistence**: call `/test`, assert store count unchanged.
### 7.2 Frontend tests (CT-120)
- **ServiceTestPanel**: renders Test button; click fires mocked mutation; success → green pill with evidence; failure → red pill with detail; Save-anyway checkbox toggles gating.
- **CreateServiceDialog**: confirm disabled until test passes; Save anyway re-enables; editing a field clears the result.
- **ServiceConfigEditor**: same gating behavior.
---
## 8. Security considerations
- **No persistence** (CT-112): the endpoint never calls any `store.*` method except read-only `get_ssh_key` (for SSH key resolution in ssh_tasks). No `upsert_service`, no `update_setting`.
- **No secret logging** (CT-113): only `test requested type=%s ok=%s` at INFO. No request body in logs. The `sanitize_log_extra` helper is available if structured logging is added later.
- **Auth gating**: identical to every other `/api/services/*` endpoint (JWT/API-key via the app-level middleware).
- **Secrets in transit**: plaintext in the request body over TLS — identical to the existing `POST /api/services/instances` create endpoint. No new attack surface.
- **Timeout**: each routine uses a short timeout (≤10s) from the service config. No long-running probes.
---
## 9. Key design decisions summary
1. **`TestResult` is a frozen dataclass** in `integrations/base.py` — immutable, serializable, co-located with the `ServiceDefinition` it augments.
2. **`test_callable` signature includes `SettingsStore`** — needed for ssh_tasks SSH-key resolution; other types ignore it. Uniform signature avoids per-type dispatch special-cases.
3. **`translate_connection_error` is shared** — extracts the `test_machine_ssh` message-translation pattern into a reusable helper covering HTTP errors, connection errors, SSL, timeout, SSH banner. Type-specific auth failures (qBit `"Fails."`) are handled in-routine.
4. **`POST /api/services/test` validation-first** — reuses `_validate_input` so malformed configs get the same 422 as create. The #1 validation-surfacing fix covers this endpoint too.
5. **Shared `ServiceTestPanel` component** — extracted to avoid duplicating the Test button + pill + gating between `CreateServiceDialog` and `ServiceConfigEditor`.
6. **Field-edit-clears-result via `useEffect([input])`** — React state updates create new object references on every edit, naturally clearing the result without explicit field-tracking.
7. **Prometheus test queries via Grafana gateway** — mirrors `MetricSource._gateway_query` exactly (`POST /api/ds/query` with `expr: "up"`). No direct Prom.
8. **ssh_tasks test reuses `build_ssh_client`** — the same `task_runner.build_ssh_client(store, ServiceRecord)` that powers task execution, wrapped with the `test_machine_ssh` error-translation patterns.