- **ssh_tasks** — reuses `build_ssh_client(store, service)` + `connect()`; banner/auth translation; no duplication of `test_machine_ssh` internals (CT-109).
- **nextcloud** — `GET /status.php` (unauth); evidence version (CT-110).
- **backups** — `test_callable=None` → `{ok: True, detail: "No test needed"}` (CT-111).
-`routers/services.py` — `POST /api/services/test`: validation-first (422 on malformed config, no network call), dispatch, **zero persistence** (no `upsert`/`update_setting`), INFO log only type+ok (sanitized; no secrets) (CT-102, CT-103, CT-113).
-`api/services.ts` + `hooks/useServices.ts` — `testServiceInstance` + `useTestServiceInstance` mutation (no cache invalidation; test is side-effect-free).
- NEW `components/ServiceTestPanel.tsx` — **presentational** shared component (cleaner than the design's stateful version — deviation N-6). Props: `{ input, onResult, disabled }`. Renders Test button + `Testing…` state + result pill (✓ green evidence / ✗ red detail) + Save-anyway checkbox (only on failure). Parent owns `testResult` + `saveAnyway` state; store-previous pattern clears on input change (CT-114, CT-115, CT-116, CT-117).
- Wired into BOTH surfaces: `CreateServiceDialog` (`ServicesPage.tsx`) AND `ServiceConfigEditor` (`Settings.tsx` — the correct edit surface per design source-finding, not `ServicePage.tsx`). Create/Save confirm gated on `testPassed || saveAnyway` (CT-118).
- **N-6 (intentional improvement):** `ServiceTestPanel` is presentational; the design's stateful version was simplified. Parent owns result + saveAnyway state. Cleaner; works identically in both surfaces.
- **Edit surface correction:** spec CT-115 said `ServicePage.tsx`; the actual edit dialog is `Settings.tsx::ServiceConfigEditor` (design source-finding). Tasks targeted the right file.
- Details: Add `@dataclass(frozen=True) class TestResult` with fields `ok: bool`, `detail: str`, `evidence: str | None = None`. Add a type alias `TestCallable = 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). Add `test_callable: TestCallable | None = None` to `ServiceDefinition` — placed LAST (after `widget_kinds`) so the dataclass field-ordering with a default works. Import `Callable` from `typing`. Update `TYPE_CHECKING` guard if needed for the SettingsStore forward reference.
- Details: Add `@dataclass(frozen=True) class TestResult` with fields `ok: bool`, `detail: str`, `evidence: str | None = None`. Add a type alias `TestCallable = 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). Add `test_callable: TestCallable | None = None` to `ServiceDefinition` — placed LAST (after `widget_kinds`) so the dataclass field-ordering with a default works. Import `Callable` from `typing`. Update `TYPE_CHECKING` guard if needed for the SettingsStore forward reference.
- Files: `backend/src/media_library_viewer_api/integrations/base.py` (modify, same file)
- Files: `backend/src/media_library_viewer_api/integrations/base.py` (modify, same file)
- Lines: ~40
- Lines: ~40
- Dependencies: 1.1
- 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.HTTPError` with status 401/403 → auth message; `requests.ConnectionError` / `ConnectionRefusedError` / `OSError` with 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 returns `TestResult(ok=False, detail=...)` always. Import `requests` and `asyncio` at the top of `base.py` (or inside the function to avoid circular imports — prefer top-level since `base.py` already may need `requests` for the type checks).
- Details: Add `def translate_connection_error(exc: Exception, *, context: str = "") -> TestResult:` that maps common exception types to human-friendly messages. Handle: `requests.HTTPError` with status 401/403 → auth message; `requests.ConnectionError` / `ConnectionRefusedError` / `OSError` with 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 returns `TestResult(ok=False, detail=...)` always. Import `requests` and `asyncio` at the top of `base.py` (or inside the function to avoid circular imports — prefer top-level since `base.py` already may need `requests` for the type checks).
- []**1.3 Add `test_connection` for qbittorrent (CT-104)**
- [x]**1.3 Add `test_connection` for qbittorrent (CT-104)**
- Details: Add `def test_connection(config, secrets, store) -> TestResult:` that constructs a `ServiceRecord(id="", service_type="ssh_tasks", name="test", config=config, secrets=secrets, enabled=True)`, calls `build_ssh_client(store, service)` (from `services.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}")`. Import `build_ssh_client` from `media_library_viewer_api.services.task_runner` and `ServiceRecord` from `media_library_viewer_api.widgets.sources` (or define locally to avoid circular import — check). **Do NOT import or call `test_machine_ssh`** — it's a router endpoint, not a reusable function. Known-host recording is preserved automatically by `RemoteSSHClient.connect()`. Wire `test_callable=test_connection` into the `DEFINITION`.
- Details: Add `def test_connection(config, secrets, store) -> TestResult:` that constructs a `ServiceRecord(id="", service_type="ssh_tasks", name="test", config=config, secrets=secrets, enabled=True)`, calls `build_ssh_client(store, service)` (from `services.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}")`. Import `build_ssh_client` from `media_library_viewer_api.services.task_runner` and `ServiceRecord` from `media_library_viewer_api.widgets.sources` (or define locally to avoid circular import — check). **Do NOT import or call `test_machine_ssh`** — it's a router endpoint, not a reusable function. Known-host recording is preserved automatically by `RemoteSSHClient.connect()`. Wire `test_callable=test_connection` into the `DEFINITION`.
- []**1.9 Add `test_connection` for nextcloud (CT-110)**
- [x]**1.9 Add `test_connection` for nextcloud (CT-110)**
- Details: Add `def test_connection(config, secrets, store) -> TestResult:` that GETs `{base_url}/status.php` (unauthenticated — `/status.php` is public). Extract `version` from the JSON response (fallback `"connected"`). Success → `TestResult(ok=True, detail="Connected to Nextcloud.", evidence=version)`. Errors → `translate_connection_error(exc, context="Nextcloud")`. Wire `test_callable=test_connection` into the `DEFINITION`.
- Details: Add `def test_connection(config, secrets, store) -> TestResult:` that GETs `{base_url}/status.php` (unauthenticated — `/status.php` is public). Extract `version` from the JSON response (fallback `"connected"`). Success → `TestResult(ok=True, detail="Connected to Nextcloud.", evidence=version)`. Errors → `translate_connection_error(exc, context="Nextcloud")`. Wire `test_callable=test_connection` into the `DEFINITION`.
- []**1.10 Confirm backups DEFINITION has `test_callable = None` (CT-111)**
- [x]**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)
- Files: `backend/src/media_library_viewer_api/integrations/backups.py` (verify, modify only if the field isn't defaulted)
- Lines: ~0 (default applies)
- Lines: ~0 (default applies)
- Dependencies: 1.1
- Dependencies: 1.1
- Details: `backups` should NOT gain a `test_connection` function. Its `DEFINITION` relies on the default `test_callable=None` from `ServiceDefinition`. Verify by reading the file; no code change expected unless the DEFINITION is constructed with explicit keyword args that omit `test_callable` (the default handles it). The endpoint returns `{ok: true, detail: "No connection test for this service type"}` automatically.
- Details: `backups` should NOT gain a `test_connection` function. Its `DEFINITION` relies on the default `test_callable=None` from `ServiceDefinition`. Verify by reading the file; no code change expected unless the DEFINITION is constructed with explicit keyword args that omit `test_callable` (the default handles it). The endpoint returns `{ok: true, detail: "No connection test for this service type"}` automatically.
- Use `unittest.mock.patch` to mock at the right boundary (the client constructor or `requests.get/post`).
- Use `unittest.mock.patch` to mock at the right boundary (the client constructor or `requests.get/post`).
- []**1.14 Add backend tests for the endpoint: dispatch, validation-first, no-persistence (CT-119)**
- [x]**1.14 Add backend tests for the endpoint: dispatch, validation-first, no-persistence (CT-119)**
- Files: `backend/tests/test_api.py` (modify) or `backend/tests/test_credential_tester.py` (create/modify)
- Files: `backend/tests/test_api.py` (modify) or `backend/tests/test_credential_tester.py` (create/modify)
- Lines: ~50
- Lines: ~50
- Dependencies: 1.11
- Dependencies: 1.11
- Details: (a) DISPATCH: `POST /api/services/test` with a `backups` body → `{ok: True, detail: "No connection test..."}` and no network call. (b) VALIDATION-FIRST: body with schema-less `base_url` for qbittorrent → HTTP 422 (not 200); assert the test_callable was NOT called (mock it, assert call count 0). (c) NO-PERSISTENCE: call `/test` with a valid qbittorrent body (test_callable mocked to return ok), assert `store.list_services()` count is unchanged before/after. (d) NO-SECRET-LOGS: use `caplog` at INFO level, call `/test` with `secrets: {"password": "hunter2"}`, assert no log line contains "hunter2".
- Details: (a) DISPATCH: `POST /api/services/test` with a `backups` body → `{ok: True, detail: "No connection test..."}` and no network call. (b) VALIDATION-FIRST: body with schema-less `base_url` for qbittorrent → HTTP 422 (not 200); assert the test_callable was NOT called (mock it, assert call count 0). (c) NO-PERSISTENCE: call `/test` with a valid qbittorrent body (test_callable mocked to return ok), assert `store.list_services()` count is unchanged before/after. (d) NO-SECRET-LOGS: use `caplog` at INFO level, call `/test` with `secrets: {"password": "hunter2"}`, assert no log line contains "hunter2".
- Run: `cd frontend && npm run build && npm run lint`
- 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).
- 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).
@@ -171,56 +171,56 @@ No proposal/spec scope change is required — the intent (per-type credential te
- Details: Add `export function useTestServiceInstance() { return useMutation({ mutationFn: (input: ServiceInstanceInput) => testServiceInstance(input) }); }`. Import `useMutation` from `@tanstack/react-query` (already imported in the file). Import `testServiceInstance` from `../api/services`. No cache invalidation needed — the test is a one-shot mutation.
- Details: Add `export function useTestServiceInstance() { return useMutation({ mutationFn: (input: ServiceInstanceInput) => testServiceInstance(input) }); }`. Import `useMutation` from `@tanstack/react-query` (already imported in the file). Import `testServiceInstance` from `../api/services`. No cache invalidation needed — the test is a one-shot mutation.
- Details: Create a component with props `{ input: ServiceInstanceInput | null; onTestResult: (passed: boolean) => void; }`. Internal state: `result: ServiceTestResult | null`, `saveAnyway: boolean`. `useEffect([input])` clears `result` and calls `onTestResult(false)` whenever `input` changes (new object reference on every field edit — CT-118). A "Test credentials" `Button` (variant outline, size sm) fires `testService.mutateAsync(input)`; while pending shows "Testing…" and is disabled. On result: render an `Alert` — green/default variant with `✓ Connected — {evidence}` on success, destructive variant with `✗ {detail}` on failure. A "Save anyway (skip test)" checkbox toggles `saveAnyway`; when checked, calls `onTestResult(true)` regardless of test outcome (CT-117). The parent reads the gating signal via the `onTestResult` callback. Import `useTestServiceInstance`, `Alert`/`AlertDescription` from `@/components/ui/alert`, `Button` from `@/components/ui/button`.
- Details: Create a component with props `{ input: ServiceInstanceInput | null; onTestResult: (passed: boolean) => void; }`. Internal state: `result: ServiceTestResult | null`, `saveAnyway: boolean`. `useEffect([input])` clears `result` and calls `onTestResult(false)` whenever `input` changes (new object reference on every field edit — CT-118). A "Test credentials" `Button` (variant outline, size sm) fires `testService.mutateAsync(input)`; while pending shows "Testing…" and is disabled. On result: render an `Alert` — green/default variant with `✓ Connected — {evidence}` on success, destructive variant with `✗ {detail}` on failure. A "Save anyway (skip test)" checkbox toggles `saveAnyway`; when checked, calls `onTestResult(true)` regardless of test outcome (CT-117). The parent reads the gating signal via the `onTestResult` callback. Import `useTestServiceInstance`, `Alert`/`AlertDescription` from `@/components/ui/alert`, `Button` from `@/components/ui/button`.
- []**2.5 Wire `ServiceTestPanel` into `CreateServiceDialog` (CT-115, CT-117)**
- [x]**2.5 Wire `ServiceTestPanel` into `CreateServiceDialog` (CT-115, CT-117)**
- Details: In `CreateServiceDialog`: add `const [testPassed, setTestPassed] = useState(false)`. Reset it in `reset()`. Build `testInput` from the current draft (null if no draft). Render `<ServiceTestPanel input={testInput} onTestResult={setTestPassed} />` below the config/secret fields and above the footer. Update `DialogFooter`'s `confirmDisabled` to include `!testPassed` (i.e. `confirmDisabled={!draft.name.trim() || saveService.isPending || !testPassed}`). Import `ServiceTestPanel` from `../components/ServiceTestPanel`.
- Details: In `CreateServiceDialog`: add `const [testPassed, setTestPassed] = useState(false)`. Reset it in `reset()`. Build `testInput` from the current draft (null if no draft). Render `<ServiceTestPanel input={testInput} onTestResult={setTestPassed} />` below the config/secret fields and above the footer. Update `DialogFooter`'s `confirmDisabled` to include `!testPassed` (i.e. `confirmDisabled={!draft.name.trim() || saveService.isPending || !testPassed}`). Import `ServiceTestPanel` from `../components/ServiceTestPanel`.
- []**2.6 Wire `ServiceTestPanel` into `ServiceConfigEditor` in `Settings.tsx` (CT-115, CT-117)**
- [x]**2.6 Wire `ServiceTestPanel` into `ServiceConfigEditor` in `Settings.tsx` (CT-115, CT-117)**
- Details: In `ServiceConfigEditor` (NOT `ServicePage.tsx` — per design source finding §0, service editing lives in `Settings.tsx`): add `const [testPassed, setTestPassed] = useState(false)`. Build `testInput` from 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). Reset `testPassed` when switching instances (if the editor has an instance-switch effect). Import `ServiceTestPanel`.
- Details: In `ServiceConfigEditor` (NOT `ServicePage.tsx` — per design source finding §0, service editing lives in `Settings.tsx`): add `const [testPassed, setTestPassed] = useState(false)`. Build `testInput` from 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). Reset `testPassed` when switching instances (if the editor has an instance-switch effect). Import `ServiceTestPanel`.
- **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.
- **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.
- []**2.7 Add frontend tests for `ServiceTestPanel` (CT-120)**
- [x]**2.7 Add frontend tests for `ServiceTestPanel` (CT-120)**
- Details: Test cases: (a) renders Test button; (b) click fires the mocked `useTestServiceInstance` mutation; (c) success result → green `✓ Connected` pill with evidence text; (d) failure result → red `✗` pill with detail text; (e) "Save anyway" checkbox checked → `onTestResult(true)` called regardless of test state; (f) editing `input` (passing a new object) clears the result and calls `onTestResult(false)`. Mock `useTestServiceInstance` via `vi.mock("../../hooks/useServices", ...)`. Use `@testing-library/react` + `@testing-library/user-event`.
- Details: Test cases: (a) renders Test button; (b) click fires the mocked `useTestServiceInstance` mutation; (c) success result → green `✓ Connected` pill with evidence text; (d) failure result → red `✗` pill with detail text; (e) "Save anyway" checkbox checked → `onTestResult(true)` called regardless of test state; (f) editing `input` (passing a new object) clears the result and calls `onTestResult(false)`. Mock `useTestServiceInstance` via `vi.mock("../../hooks/useServices", ...)`. Use `@testing-library/react` + `@testing-library/user-event`.
- []**2.8 Update existing dialog tests for gating (CT-120)**
- [x]**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
- Files: `frontend/src/pages/__tests__/ServicesPage.test.tsx` (modify, if it exists) or confirm coverage via the panel test
- Lines: ~20
- Lines: ~20
- Dependencies: 2.5
- Dependencies: 2.5
- Details: If `ServicesPage.test.tsx` tests 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.
- Details: If `ServicesPage.test.tsx` tests 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.
- []**2.9 Verify Slice 2 (build + lint + test)**
- [x]**2.9 Verify Slice 2 (build + lint + test)**
- Run: `cd frontend && npm run build && npm run lint && npx vitest run`
- Run: `cd frontend && npm run build && npm run lint && npx vitest run`
- Run: `cd frontend && npm run build && npm run lint && npx vitest run`
- Run: `cd frontend && npm run build && npm run lint && npx vitest run`
- Verify: 0 errors; panel test covers button/pill/gating/field-clear.
- Verify: 0 errors; panel test covers button/pill/gating/field-clear.
- []**3.3 End-to-end dispatch check**
- [x]**3.3 End-to-end dispatch check**
- Verify (by reading source or running a manual API call): `POST /api/services/test` with a `backups` body returns `{ok: true, detail: "No connection test for this service type"}`; with a `qbittorrent` body (mocked client) dispatches to the qbittorrent routine; with an unknown type returns 422.
- Verify (by reading source or running a manual API call): `POST /api/services/test` with a `backups` body returns `{ok: true, detail: "No connection test for this service type"}`; with a `qbittorrent` body (mocked client) dispatches to the qbittorrent routine; with an unknown type returns 422.
- []**3.4 No-persistence check**
- [x]**3.4 No-persistence check**
- Verify (by test in 1.14): calling `/test` does not create a service row.
- Verify (by test in 1.14): calling `/test` does not create a service row.
- []**3.5 No-secret-logs check**
- [x]**3.5 No-secret-logs check**
- Verify (by test in 1.14): no log line contains a secret value.
- Verify (by test in 1.14): no log line contains a secret value.
| CT-101 | `POST /api/services/test` accepts `ServiceInstanceInput`, returns `{ok,detail,evidence}`, auth-gated | ✅ PASS | `routers/services.py:189``test_instance`; returns 200 dict; auth-gated via app middleware (`path.startswith("/api")`, not in `EXEMPT_PATHS`) |
| CT-102 | Validation before test → 422 on malformed config | ✅ PASS | `_validate_input(body)` called first (`services.py:196`); `test_validation_first_rejects_malformed_config` asserts 422 for schema-less `base_url` |
| CT-103 | Closed per-type dispatch via `test_callable`; `None` → default ok | ✅ PASS | `base.py:125` field `test_callable: TestCallable \| None = None`; `None` branch returns `{ok:true, "No connection test…"}` |
| CT-112 | No persistence (no `upsert_service`/`update_setting`) | ✅ PASS | Endpoint has zero persistence calls; `test_no_persistence_after_test` asserts store count unchanged |
| CT-113 | Secrets never logged | ✅ PASS | Only logs `test requested type=%s ok=%s`; no body/config/secrets. ⚠ test is weak (see N-2) |
| CT-114 | API client fn + hook + type | ✅ PASS | `api/services.ts:testServiceInstance`, `hooks/useServices.ts:useTestServiceInstance`, `types/index.ts:ServiceTestResult` |
| CT-115 | Test button in both create + edit dialogs | ✅ PASS | `ServiceTestPanel` in `ServicesPage.tsx::CreateServiceDialog` AND `Settings.tsx::ServiceConfigEditor` |
| CT-116 | Result pill renders both states w/ evidence/detail | ✅ PASS | `ServiceTestPanel` renders `✓ Connected — {evidence}` (default variant) / `✗ {detail}` (destructive) |
| CT-118 | Editing a connectivity field clears result | ✅ PASS | store-previous pattern in both parents (ServicesPage on `draft`; Settings on `testInput` via JSON.stringify) |
**N-1: Uncommitted cosmetic diff in working tree.** `frontend/src/pages/ServicesPage.tsx` has an
uncommitted change that is purely JSX indentation/prettier reflow (no functional diff). Should be
committed or discarded; does not affect verification.
**N-2: `test_secrets_not_logged` (CT-113) is weak/misleading.** The test sends `secrets: {}`
(empty) then asserts an arbitrary string (`"super-secret-hunter2"`) is absent from `caplog.text`.
Because the secret was never sent, the assertion is trivially satisfied and does **not** actually
exercise the property that secrets present in the request body are not logged. The
**implementation is correct** (the endpoint logs only `type`/`ok`), but the test should send a
real secret in the body (e.g. `secrets: {"password": "hunter2"}`) to genuinely verify CT-113.
**N-3: Defensive `logger.exception` could log a traceback.** `services.py:205`
`logger.exception("test_callable raised for type=%s", ...)` emits a full traceback if a
`test_callable` ever raises. All routines catch internally, so this is a rare defensive path, but a
traceback could theoretically surface sensitive data embedded in an exception message. Low risk;
consider `logger.warning` with a truncated message instead.
**N-4: Edit-flow test needs re-typed secrets.** In `ServiceConfigEditor`, `testInput.secrets`
comes from `draftSecrets` which starts empty for existing services. Testing an existing authed
service (e.g. Prometheus) without re-entering its API key fails the test
("Grafana API key is required") because the stored encrypted secret is not sent. This is an
inherent consequence of the no-persistence design (test operates on request-body plaintext;
secrets are encrypted at rest). The "Leave blank to keep the current value" hint implies retyping
is expected. Non-blocking UX note.
**N-5: CT-120 page-level gating/field-clear not explicitly tested.** `ServiceTestPanel.test.tsx`
covers the presentational panel (button, pill states, callbacks). Because the panel is stateless
(the parent owns `testResult`/`saveAnyway` and the gating/field-clear logic), there is no explicit
test that (a) the Create/Save button is disabled until the test passes, or (b) editing a field
clears `testPassed` at the integration level. The logic is correct by source inspection, but the
test coverage is panel-only. Recommend a page-level test asserting the confirm button enables only
after a passing test.
**N-6: Documented design deviation (improvement).** `ServiceTestPanel` is **presentational**
(parent owns state) rather than the design's stateful component (which used `useEffect([input])`).
This is a deliberate, docstring-documented change to avoid the React `setState`-in-effect footgun.
All of CT-114..CT-118 are still satisfied via parent wiring. Informational only.
---
## 7. Residual risks
- The edit-flow "must retype secrets to test" behavior (N-4) may surprise operators; document it
in the UI or user docs.
- The weak no-secret-logs test (N-2) gives false confidence in CT-113 coverage.
- Frontend integration-level gating is proven by source, not by an automated test (N-5).
---
## 8. Conclusion
The `service-credential-tester` change is **functionally complete and correct**: all 21
requirements pass against source, and all five gates are green. The only blockers to archive are
lifecycle hygiene — the missing `apply-progress.md` and the 29 unchecked tasks in `tasks.md`. Once
the task checklist is reconciled to reflect the (verified-done) work and `apply-progress.md` is
added, the change is archive-ready. No code fixes are required; the non-blocking findings are
test-strength and UX-doc improvements.
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.