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
+71
View File
@@ -845,3 +845,74 @@ class TestPrometheusStartupValidation:
# Best-effort validator logs a migration hint referencing grafana_url.
assert "grafana_url" in caplog.text
assert any(record.levelno == logging.WARNING for record in caplog.records)
# --- Service credential tester endpoint (CT-101..CT-113) ---
class TestServiceTestEndpoint:
"""Tests for POST /api/services/test — dispatch, validation-first, no-persistence, no-secret-logs."""
def test_backups_returns_no_test_needed(self, test_client: TestClient) -> None:
"""backups has test_callable=None → returns ok=true with 'No test' detail."""
response = test_client.post(
"/api/services/test",
json={
"service_type": "backups",
"name": "test",
"config": {"ingestion_label": "default"},
"secrets": {},
"enabled": True,
},
)
assert response.status_code == 200
body = response.json()
assert body["ok"] is True
assert "No" in body["detail"]
def test_validation_first_rejects_malformed_config(self, test_client: TestClient) -> None:
"""Malformed config (schema-less base_url) → 422, no test_callable called."""
response = test_client.post(
"/api/services/test",
json={
"service_type": "qbittorrent",
"name": "test",
"config": {"base_url": "localhost:8080"}, # missing http://
"secrets": {"username": "u", "password": "p"},
"enabled": True,
},
)
assert response.status_code == 422
def test_no_persistence_after_test(self, test_client: TestClient, tmp_path) -> None:
"""Calling /test does not create a service row."""
store = app.dependency_overrides[get_settings_store]()
before = len(store.list_services())
test_client.post(
"/api/services/test",
json={
"service_type": "backups",
"name": "test",
"config": {"ingestion_label": "default"},
"secrets": {},
"enabled": True,
},
)
after = len(store.list_services())
assert before == after
def test_secrets_not_logged(self, test_client: TestClient, caplog) -> None:
"""No log line contains the secret value."""
secret_value = "super-secret-hunter2"
with caplog.at_level(logging.INFO):
test_client.post(
"/api/services/test",
json={
"service_type": "backups",
"name": "test",
"config": {"ingestion_label": "default"},
"secrets": {},
"enabled": True,
},
)
assert secret_value not in caplog.text