S1 backend: TestResult + test_callable + translate_connection_error + POST /api/services/test + 7 per-type routines + tests. S2 frontend: type + API fn + hook + shared ServiceTestPanel wired into CreateServiceDialog + Settings.tsx ServiceConfigEditor (per design source-finding). Each slice leaves pytest/npm build/npm lint green.
28 KiB
SDD Tasks: Service Credential Tester
Change: service-credential-tester
Phase: tasks
Date: 2026-07-09
Review Workload Forecast
| Field | Value |
|---|---|
| Estimated changed lines | ~550–700 (sum of two implementation slices) |
| 400-line budget risk | Low–Medium |
| Chained PRs recommended | Yes |
| Suggested split | PR 1: backend endpoint + TestResult/test_callable/translate_connection_error + 7 per-type routines + tests → PR 2: frontend type + API fn + hook + ServiceTestPanel + wire into both surfaces + tests |
| Delivery strategy | auto-chain |
| Chain strategy | stacked-to-main |
Decision needed before apply: No
Chained PRs recommended: Yes
Chain strategy: stacked-to-main
400-line budget risk: Low–Medium
Each slice individually lands under the 400-line review budget. Slices are ordered S1 → S2; S1 is independently shippable (backend endpoint works, frontend has no Test UI until S2). Per
openspec/config.yamlrules, each slice leavesnpm run build(tsc -b + vite build),npm run lint, and backendpytestgreen.
Slice ordering rationale (critical)
Slice 1 builds the backend endpoint + per-type test routines. After S1:
POST /api/services/testvalidates input (reuses_validate_input), dispatches to the definition'stest_callable, and returns{ok, detail, evidence}— no persistence.- Each of 7 service types (qbittorrent, prometheus, alertmanager, jellyfin, authentik, ssh_tasks, nextcloud) has a
test_connectionroutine; backups hastest_callable = None. TestResultdataclass +translate_connection_errorshared helper live inintegrations/base.py.- All backend tests are green: per-type mocked routines, endpoint dispatch, validation-first, no-persistence.
- The frontend has NO Test UI yet — the endpoint is callable via API only. That's fine; S2 adds the UI.
Slice 2 adds the frontend Test UI. A shared ServiceTestPanel component (Test button + result pill + Save-anyway override + field-edit-clears-result) is wired into BOTH CreateServiceDialog (in ServicesPage.tsx) AND ServiceConfigEditor (in Settings.tsx — per the design source finding, NOT ServicePage.tsx). The panel is identical in both surfaces.
This ordering ensures the backend is proven (endpoint dispatches correctly, validation rejects malformed input, no-persistence holds) before any frontend churn.
Source-finding corrections (read before applying)
The design (§0) surfaced deviations from the proposal/spec. Tasks below incorporate these corrections:
| Spec/proposal claim | Actual source reality | Correction in tasks |
|---|---|---|
CT-115: "the edit dialog on ServicePage.tsx" |
ServicePage.tsx has no edit dialog. Service editing lives in Settings.tsx::ServiceConfigEditor (master-detail panel, lines ~1435–1596). |
Task 2.6 targets Settings.tsx::ServiceConfigEditor, NOT ServicePage.tsx. |
CT-109: "reuses the test_machine_ssh connect flow" |
test_machine_ssh (settings.py:122–180) is a router endpoint, not a reusable function. But build_ssh_client in task_runner.py builds the client from a ServiceRecord. |
Task 1.8 uses build_ssh_client(store, ServiceRecord) + .connect(), then applies the same error-translation patterns inline. It does NOT import or call test_machine_ssh. |
Proposal: "construct a QbittorrentClient from the config + decrypted secrets" |
QbittorrentClient.__init__ takes positional strings (base_url, username, password, timeout), not a config dict. |
Task 1.4 extracts fields explicitly from config/secrets dicts. |
Proposal mentions jellyseerr as a distinct type |
Active registry has no jellyseerr entry (merged into Jellyfin by services-as-hub-ia). |
No jellyseerr test routine. The 7 remote types are: qbittorrent, prometheus, alertmanager, jellyfin, authentik, ssh_tasks, nextcloud. |
No proposal/spec scope change is required — the intent (per-type credential tester) holds. These corrections refine implementation details and the edit-surface target.
Slice 1: Backend endpoint + test_callable + per-type routines + tests
Goal: Add POST /api/services/test that validates unsaved service input, dispatches to a per-type test_callable, and returns {ok, detail, evidence} without persisting. Each of 7 service types gets a test_connection routine; backups has no test.
Satisfies: CT-101, CT-102, CT-103, CT-104, CT-105, CT-106, CT-107, CT-108, CT-109, CT-110, CT-111, CT-112, CT-113, CT-119.
-
1.1 Add
TestResultdataclass +TestCallabletype alias +test_callablefield onServiceDefinition(CT-101, CT-103)- Files:
backend/src/media_library_viewer_api/integrations/base.py(modify) - Lines: ~30
- Dependencies: none
- Details: Add
@dataclass(frozen=True) class TestResultwith fieldsok: bool,detail: str,evidence: str | None = None. Add a type aliasTestCallable = Callable[[dict[str, Any], dict[str, str], "SettingsStore"], TestResult](forward-reference SettingsStore to avoid circular import at module level; import it inside the test routines or use a string annotation). Addtest_callable: TestCallable | None = NonetoServiceDefinition— placed LAST (afterwidget_kinds) so the dataclass field-ordering with a default works. ImportCallablefromtyping. UpdateTYPE_CHECKINGguard if needed for the SettingsStore forward reference.
- Files:
-
1.2 Add
translate_connection_errorshared helper (CT-104..CT-110)- Files:
backend/src/media_library_viewer_api/integrations/base.py(modify, same file) - Lines: ~40
- Dependencies: 1.1
- Details: Add
def translate_connection_error(exc: Exception, *, context: str = "") -> TestResult:that maps common exception types to human-friendly messages. Handle:requests.HTTPErrorwith status 401/403 → auth message;requests.ConnectionError/ConnectionRefusedError/OSErrorwith DNS keywords (getaddrinfo,name or service not known) → "Host not found"; generic connection error → "Connection refused"; SSL/certificate keywords → "SSL/TLS error";requests.Timeout/TimeoutError/asyncio.TimeoutError→ "Connection timed out"; SSH "protocol banner" → "SSH banner not received". Fallback: truncate message to 200 chars, prefix with context. The helper returnsTestResult(ok=False, detail=...)always. Importrequestsandasyncioat the top ofbase.py(or inside the function to avoid circular imports — prefer top-level sincebase.pyalready may needrequestsfor the type checks).
- Files:
-
1.3 Add
test_connectionfor qbittorrent (CT-104)- Files:
backend/src/media_library_viewer_api/integrations/qbittorrent.py(modify) - Lines: ~25
- Dependencies: 1.1, 1.2
- Details: Add
def test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) -> TestResult:that constructsQbittorrentClient(config["base_url"], secrets["username"], secrets["password"], timeout=config.get("timeout_seconds", 10)), callsclient.maindata(), extractsserver_stateversion (fallback"connected"), returnsTestResult(ok=True, detail="Connected to qBittorrent.", evidence=version). CatchRuntimeErrorwith "login failed" in the message →TestResult(ok=False, detail="Authentication failed — qBittorrent rejected the credentials."). All other exceptions →translate_connection_error(exc, context="qBittorrent"). ImportQbittorrentClientfromclients.qbittorrent,TestResult+translate_connection_errorfromintegrations.base. Wiretest_callable=test_connectioninto theDEFINITION.
- Files:
-
1.4 Add
test_connectionfor prometheus via Grafana gateway (CT-105)- Files:
backend/src/media_library_viewer_api/integrations/prometheus.py(modify) - Lines: ~30
- Dependencies: 1.1, 1.2
- Details: Add
def test_connection(config, secrets, store) -> TestResult:that issuesPOST {grafana_url}/api/ds/querywithAuthorization: Bearer {grafana_api_key}, body{"queries": [{"datasource": {"uid": datasource_uid, "type": "prometheus"}, "expr": "up", "format": "time_series", "intervalMs": 15000, "maxDataPoints": 1, "refId": "A"}], "from": "now-1m", "to": "now"}. Extractgrafana_url,datasource_uid,timeout_secondsfrom config;grafana_api_keyfrom secrets. Missing URL/key →TestResult(ok=False, detail="...")(no network). Success →TestResult(ok=True, detail="Grafana gateway reachable.", evidence="Gateway reachable; datasource responded."). Errors →translate_connection_error(exc, context="Prometheus via Grafana"). MirrorMetricSource._gateway_queryrequest shape exactly. Wiretest_callable=test_connectioninto theDEFINITION.
- Files:
-
1.5 Add
test_connectionfor alertmanager (CT-106)- Files:
backend/src/media_library_viewer_api/integrations/alertmanager.py(modify) - Lines: ~20
- Dependencies: 1.1, 1.2
- Details: Add
def test_connection(config, secrets, store) -> TestResult:that GETs{base_url}/api/v2/statuswith optionalAuthorization: Bearer {api_key}(when secret present). Extract version fromversionInfo.version(fallback"connected"). Success →TestResult(ok=True, detail="Connected to Alertmanager.", evidence=version). Errors →translate_connection_error(exc, context="Alertmanager"). Wiretest_callable=test_connectioninto theDEFINITION.
- Files:
-
1.6 Add
test_connectionfor jellyfin (CT-107)- Files:
backend/src/media_library_viewer_api/integrations/jellyfin.py(modify) - Lines: ~20
- Dependencies: 1.1, 1.2
- Details: Add
def test_connection(config, secrets, store) -> TestResult:that constructsJellyfinClient(config["base_url"], secrets["api_key"], timeout=config.get("timeout_seconds", 10))and calls.users(). Success →TestResult(ok=True, detail="Connected to Jellyfin.", evidence=f"{len(users)} users"). Errors →translate_connection_error(exc, context="Jellyfin"). ImportJellyfinClientfromclients.jellyfin. Wiretest_callable=test_connectioninto theDEFINITION.
- Files:
-
1.7 Add
test_connectionfor authentik (CT-108)- Files:
backend/src/media_library_viewer_api/integrations/authentik.py(modify) - Lines: ~20
- Dependencies: 1.1, 1.2
- Details: Add
def test_connection(config, secrets, store) -> TestResult:that constructsAuthentikClient(base_url=config["base_url"], api_token=secrets["api_token"], timeout=config.get("timeout_seconds", 10))and calls.users(page=1, page_size=1)— the lightest directory probe. Success →TestResult(ok=True, detail="Connected to Authentik.", evidence=...). Errors →translate_connection_error(exc, context="Authentik"). ImportAuthentikClientfrom its module (checkrouters/authentik_users.py::_build_clientfor the exact import path + constructor signature). Wiretest_callable=test_connectioninto theDEFINITION.
- Files:
-
1.8 Add
test_connectionfor ssh_tasks reusingbuild_ssh_client(CT-109)- Files:
backend/src/media_library_viewer_api/integrations/ssh_tasks.py(modify) - Lines: ~30
- Dependencies: 1.1, 1.2
- Details: Add
def test_connection(config, secrets, store) -> TestResult:that constructs aServiceRecord(id="", service_type="ssh_tasks", name="test", config=config, secrets=secrets, enabled=True), callsbuild_ssh_client(store, service)(fromservices.task_runner), then.connect()inside try/finally with.close(). Translate errors inline for SSH-specific patterns: "protocol banner" →"SSH banner not received from {host}:{port}; confirm the SSH service is running."; "no authentication methods available" / "authentication failed" →"SSH authentication failed for {host}:{port}; check the SSH key, passphrase, or username.". Other exceptions →translate_connection_error(exc, context=f"SSH {host}:{port}"). Success →TestResult(ok=True, detail=f"SSH connection succeeded for {host}:{port}.", evidence=f"Connected to {host}:{port}"). Importbuild_ssh_clientfrommedia_library_viewer_api.services.task_runnerandServiceRecordfrommedia_library_viewer_api.widgets.sources(or define locally to avoid circular import — check). Do NOT import or calltest_machine_ssh— it's a router endpoint, not a reusable function. Known-host recording is preserved automatically byRemoteSSHClient.connect(). Wiretest_callable=test_connectioninto theDEFINITION.
- Files:
-
1.9 Add
test_connectionfor nextcloud (CT-110)- Files:
backend/src/media_library_viewer_api/integrations/nextcloud.py(modify) - Lines: ~15
- Dependencies: 1.1, 1.2
- Details: Add
def test_connection(config, secrets, store) -> TestResult:that GETs{base_url}/status.php(unauthenticated —/status.phpis public). Extractversionfrom the JSON response (fallback"connected"). Success →TestResult(ok=True, detail="Connected to Nextcloud.", evidence=version). Errors →translate_connection_error(exc, context="Nextcloud"). Wiretest_callable=test_connectioninto theDEFINITION.
- Files:
-
1.10 Confirm backups DEFINITION has
test_callable = None(CT-111)- Files:
backend/src/media_library_viewer_api/integrations/backups.py(verify, modify only if the field isn't defaulted) - Lines: ~0 (default applies)
- Dependencies: 1.1
- Details:
backupsshould NOT gain atest_connectionfunction. ItsDEFINITIONrelies on the defaulttest_callable=NonefromServiceDefinition. Verify by reading the file; no code change expected unless the DEFINITION is constructed with explicit keyword args that omittest_callable(the default handles it). The endpoint returns{ok: true, detail: "No connection test for this service type"}automatically.
- Files:
-
1.11 Add
POST /api/services/testendpoint (CT-101, CT-102, CT-103, CT-112, CT-113)- Files:
backend/src/media_library_viewer_api/routers/services.py(modify) - Lines: ~30
- Dependencies: 1.1–1.10
- Details: Add
@router.post("/test") def test_instance(body: ServiceInstanceInput, store: SettingsStore = Depends(get_settings_store)) -> dict[str, Any]:that: (a) calls_validate_input(body)— reuses the existing helper, raises HTTPException(422) on malformed config/type/secrets BEFORE any network call (CT-102); (b) resolvesdefinition = require_service_definition(body.service_type); (c) ifdefinition.test_callable is None→ logtest requested type=%s ok=trueand return{"ok": True, "detail": "No connection test for this service type", "evidence": None}(CT-103/111); (d) else callresult = definition.test_callable(body.config, body.secrets, store)inside a try/except (defensive — routines catch internally, but if one raises, returnTestResult(ok=False, detail=f"Test failed unexpectedly: {exc}")); (e) logtest requested type=%s ok=%sat INFO — NEVER log body/config/secrets (CT-113); (f) return{"ok": result.ok, "detail": result.detail, "evidence": result.evidence}. NO call tostore.upsert_service,store.update_setting, or any persistence method (CT-112). Importrequire_service_definitionfromintegrations.registry(already imported or adjacent).
- Files:
-
1.12 Add backend tests for
translate_connection_error(CT-119)- Files:
backend/tests/test_services.py(modify) or a newbackend/tests/test_credential_tester.py(create) - Lines: ~40
- Dependencies: 1.2
- Details: Test the shared helper directly: (a)
requests.HTTPErrorwith a mock response.status_code = 401→ auth message; (b)requests.ConnectionError("getaddrinfo failed")→ "Host not found"; (c)requests.Timeout()→ "timed out"; (d) genericValueError("something")→ truncated fallback with context prefix. Assertok=Falseand the detail string contains expected keywords.
- Files:
-
1.13 Add backend tests for per-type
test_connectionroutines (CT-119)- Files:
backend/tests/test_services.py(modify) orbackend/tests/test_credential_tester.py(create/modify) - Lines: ~100
- Dependencies: 1.3–1.9
- Details: For each of the 7 remote types, add at least two tests: (a) SUCCESS — mock the client/request to return valid data, assert
{ok: True, detail: ..., evidence: ...}; (b) FAILURE — mock the client/request to raise, assert{ok: False, detail: <readable message>, evidence: None}. Specific cases:- qbittorrent: success returns version;
RuntimeError("qBittorrent login failed: Fails.")→ detail mentions "Authentication failed". - prometheus: success returns gateway-reachable evidence; HTTP 401 → auth message.
- alertmanager: success returns version; connection refused → unreachable message.
- jellyfin: success returns user count; HTTP 401 → auth message.
- authentik: success returns user count; connection error → unreachable.
- ssh_tasks: mock
build_ssh_clientto return a mock client;.connect()raises "authentication failed" → SSH auth message;.connect()succeeds → connected evidence. - nextcloud: success returns version; 404 → connection error.
- qbittorrent: success returns version;
- Use
unittest.mock.patchto mock at the right boundary (the client constructor orrequests.get/post).
- Files:
-
1.14 Add backend tests for the endpoint: dispatch, validation-first, no-persistence (CT-119)
- Files:
backend/tests/test_api.py(modify) orbackend/tests/test_credential_tester.py(create/modify) - Lines: ~50
- Dependencies: 1.11
- Details: (a) DISPATCH:
POST /api/services/testwith abackupsbody →{ok: True, detail: "No connection test..."}and no network call. (b) VALIDATION-FIRST: body with schema-lessbase_urlfor qbittorrent → HTTP 422 (not 200); assert the test_callable was NOT called (mock it, assert call count 0). (c) NO-PERSISTENCE: call/testwith a valid qbittorrent body (test_callable mocked to return ok), assertstore.list_services()count is unchanged before/after. (d) NO-SECRET-LOGS: usecaplogat INFO level, call/testwithsecrets: {"password": "hunter2"}, assert no log line contains "hunter2".
- Files:
-
1.15 Verify Slice 1 (pytest + ruff + frontend still builds)
- Run:
cd backend && PYTHONPATH=src python3 -m pytest -q && PYTHONPATH=src python3 -m ruff check src tests - Run:
cd frontend && npm run build && npm run lint - Verify: all backend tests pass (per-type routines, endpoint dispatch, validation, no-persistence, no-secret-logs); ruff clean; frontend still builds + lints (no frontend change in S1, so this is a regression check only).
- Risk flag: the
test_callablefield onServiceDefinitionis a new field with a default. Verify that existing DEFINITION construction sites (all 8 integration modules) still compile — they should, since the default isNone, but a frozen dataclass with field-ordering can surprise if any DEFINITION passes positional args. Check for positional-arg construction (unlikely — the codebase uses keyword args).
- Run:
Slice 1 total: ~300–380 changed lines.
Slice 2: Frontend Test UI — API client + hook + shared panel + wire into both surfaces + tests
Goal: Add a "Test credentials" button + result pill + Create/Save gating to BOTH the add-service dialog (CreateServiceDialog in ServicesPage.tsx) and the edit panel (ServiceConfigEditor in Settings.tsx). A shared ServiceTestPanel component avoids duplication.
Satisfies: CT-114, CT-115, CT-116, CT-117, CT-118, CT-120, CT-121.
-
2.1 Add
ServiceTestResulttype (CT-114)- Files:
frontend/src/types/index.ts(modify) - Lines: ~5
- Dependencies: none
- Details: Add
export interface ServiceTestResult { ok: boolean; detail: string; evidence: string | null; }.
- Files:
-
2.2 Add
testServiceInstanceAPI client function (CT-114)- Files:
frontend/src/api/services.ts(modify) - Lines: ~5
- Dependencies: 2.1
- Details: Add
export async function testServiceInstance(input: ServiceInstanceInput): Promise<ServiceTestResult> { return post<ServiceTestResult>("/api/services/test", input); }. ImportServiceTestResultfrom../types. The existingposthelper (from./shared) handles auth headers + error extraction.
- Files:
-
2.3 Add
useTestServiceInstancehook (CT-114)- Files:
frontend/src/hooks/useServices.ts(modify) - Lines: ~8
- Dependencies: 2.2
- Details: Add
export function useTestServiceInstance() { return useMutation({ mutationFn: (input: ServiceInstanceInput) => testServiceInstance(input) }); }. ImportuseMutationfrom@tanstack/react-query(already imported in the file). ImporttestServiceInstancefrom../api/services. No cache invalidation needed — the test is a one-shot mutation.
- Files:
-
2.4 Create shared
ServiceTestPanelcomponent (CT-115, CT-116, CT-117, CT-118)- Files:
frontend/src/components/ServiceTestPanel.tsx(NEW) - Lines: ~70
- Dependencies: 2.3
- Details: Create a component with props
{ input: ServiceInstanceInput | null; onTestResult: (passed: boolean) => void; }. Internal state:result: ServiceTestResult | null,saveAnyway: boolean.useEffect([input])clearsresultand callsonTestResult(false)wheneverinputchanges (new object reference on every field edit — CT-118). A "Test credentials"Button(variant outline, size sm) firestestService.mutateAsync(input); while pending shows "Testing…" and is disabled. On result: render anAlert— green/default variant with✓ Connected — {evidence}on success, destructive variant with✗ {detail}on failure. A "Save anyway (skip test)" checkbox togglessaveAnyway; when checked, callsonTestResult(true)regardless of test outcome (CT-117). The parent reads the gating signal via theonTestResultcallback. ImportuseTestServiceInstance,Alert/AlertDescriptionfrom@/components/ui/alert,Buttonfrom@/components/ui/button.
- Files:
-
2.5 Wire
ServiceTestPanelintoCreateServiceDialog(CT-115, CT-117)- Files:
frontend/src/pages/ServicesPage.tsx(modify) - Lines: ~20
- Dependencies: 2.4
- Details: In
CreateServiceDialog: addconst [testPassed, setTestPassed] = useState(false). Reset it inreset(). BuildtestInputfrom the current draft (null if no draft). Render<ServiceTestPanel input={testInput} onTestResult={setTestPassed} />below the config/secret fields and above the footer. UpdateDialogFooter'sconfirmDisabledto include!testPassed(i.e.confirmDisabled={!draft.name.trim() || saveService.isPending || !testPassed}). ImportServiceTestPanelfrom../components/ServiceTestPanel.
- Files:
-
2.6 Wire
ServiceTestPanelintoServiceConfigEditorinSettings.tsx(CT-115, CT-117)- Files:
frontend/src/pages/Settings.tsx(modify) - Lines: ~20
- Dependencies: 2.4
- Details: In
ServiceConfigEditor(NOTServicePage.tsx— per design source finding §0, service editing lives inSettings.tsx): addconst [testPassed, setTestPassed] = useState(false). BuildtestInputfrom the editor's current draft state (name, config, secrets, enabled). Render<ServiceTestPanel input={testInput} onTestResult={setTestPassed} />below the form fields. Gate the Save button on!testPassed(disabled until test passes or Save anyway is checked). ResettestPassedwhen switching instances (if the editor has an instance-switch effect). ImportServiceTestPanel. - Risk flag: the spec CT-115 says "ServicePage.tsx" — that is textual drift. The actual edit surface is
Settings.tsx::ServiceConfigEditor. This task targets the correct file.
- Files:
-
2.7 Add frontend tests for
ServiceTestPanel(CT-120)- Files:
frontend/src/components/__tests__/ServiceTestPanel.test.tsx(NEW) - Lines: ~80
- Dependencies: 2.4
- Details: Test cases: (a) renders Test button; (b) click fires the mocked
useTestServiceInstancemutation; (c) success result → green✓ Connectedpill with evidence text; (d) failure result → red✗pill with detail text; (e) "Save anyway" checkbox checked →onTestResult(true)called regardless of test state; (f) editinginput(passing a new object) clears the result and callsonTestResult(false). MockuseTestServiceInstanceviavi.mock("../../hooks/useServices", ...). Use@testing-library/react+@testing-library/user-event.
- Files:
-
2.8 Update existing dialog tests for gating (CT-120)
- Files:
frontend/src/pages/__tests__/ServicesPage.test.tsx(modify, if it exists) or confirm coverage via the panel test - Lines: ~20
- Dependencies: 2.5
- Details: If
ServicesPage.test.tsxtests the create dialog, add assertions that the Create button is disabled until the test passes. Mock the test mutation to return{ok: true}and verify the button enables. If there's no existing ServicesPage test covering the dialog, the panel test (2.7) covers the gating behavior adequately — note the coverage decision.
- Files:
-
2.9 Verify Slice 2 (build + lint + test)
- Run:
cd frontend && npm run build && npm run lint && npx vitest run - Run:
cd backend && PYTHONPATH=src python3 -m pytest -q(regression: Slice 1 tests still pass) - Verify: frontend typechecks + builds; lint 0 errors; vitest passes (new panel test + existing tests); backend still green.
- Risk flag:
ServiceConfigEditorinSettings.tsxis a large component (~160 lines). The wiring (state + panel render + button gating) must be surgical — do not refactor the editor. Add thetestPassedstate, thetestInputbuild, the panel render, and the buttondisabledprop only.
- Run:
Slice 2 total: ~250–320 changed lines.
Integration verification (post-slice)
-
3.1 Full backend test run
- Run:
cd backend && PYTHONPATH=src python3 -m pytest -q - Verify: all tests pass (per-type routines, endpoint, validation, no-persistence, no-secret-logs).
- Run:
-
3.2 Full frontend build + lint + test
- Run:
cd frontend && npm run build && npm run lint && npx vitest run - Verify: 0 errors; panel test covers button/pill/gating/field-clear.
- Run:
-
3.3 End-to-end dispatch check
- Verify (by reading source or running a manual API call):
POST /api/services/testwith abackupsbody returns{ok: true, detail: "No connection test for this service type"}; with aqbittorrentbody (mocked client) dispatches to the qbittorrent routine; with an unknown type returns 422.
- Verify (by reading source or running a manual API call):
-
3.4 No-persistence check
- Verify (by test in 1.14): calling
/testdoes not create a service row.
- Verify (by test in 1.14): calling
-
3.5 No-secret-logs check
- Verify (by test in 1.14): no log line contains a secret value.
Risk flags summary
-
(a) CT-115 spec drift — edit surface is
Settings.tsx::ServiceConfigEditor, NOTServicePage.tsx. Task 2.6 targets the correct file. The spec's literal text is stale; the design (§0) corrected it. Do not add a Test button toServicePage.tsx(it's a read-only tabbed view with no edit form). -
(b) ssh_tasks test must NOT duplicate
test_machine_ssh.test_machine_ssh(settings.py:122–180) is a router endpoint, not a reusable function. Task 1.8 usesbuild_ssh_client(store, ServiceRecord)fromtask_runner.py+.connect(), then translates errors inline using the same message patterns. No import oftest_machine_ssh. -
(c) The shared
ServiceTestPanelmust work identically in both surfaces. Tasks 2.5 + 2.6 wire the same component intoCreateServiceDialogandServiceConfigEditor. The panel'sinputprop is an object built from the parent's draft state;onTestResultis a callback the parent uses for gating. The panel owns the Test button, result pill, and Save-anyway checkbox; the parent owns the confirm button'sdisabledprop. -
(d)
ServiceDefinitionis a frozen dataclass. Addingtest_callablewith a defaultNoneas the LAST field works with dataclass field-ordering. Verify no DEFINITION is constructed with positional args (the codebase uses keyword args, so this should be safe). Task 1.15 checks this. -
(e) Circular import risk for
SettingsStoreinTestCallable. The type alias referencesSettingsStore(fromservices.settings_store). Use a string forward-reference ("SettingsStore") in the type alias to avoid importingsettings_storeintointegrations/base.pyat module level. The actualSettingsStoreis passed at runtime by the endpoint handler. Task 1.1 handles this. -
(f)
Settings.tsx::ServiceConfigEditoris a large component. Task 2.6 wiring must be surgical (add state + input build + panel render + button disabled prop). Do NOT refactor the editor or touch unrelated fields.