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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user