diff --git a/frontend-modern/browser-verification/42faa8f482b1cf9c.json b/frontend-modern/browser-verification/42faa8f482b1cf9c.json new file mode 100644 index 0000000000..5715ddb684 --- /dev/null +++ b/frontend-modern/browser-verification/42faa8f482b1cf9c.json @@ -0,0 +1,43 @@ +{ + "version": 1, + "result": "passed", + "base_sha": "ac45a69738d06d526b08b96d6c47b51431739088", + "content_sha256": { + "frontend-modern/src/utils/connectionErrorPresentation.ts": "1f6bcf499896a091958c8fd840f26f94f335aa58200631db1b76c1ec86ab8584" + }, + "changed_paths": [ + "frontend-modern/src/utils/connectionErrorPresentation.ts" + ], + "routes": [ + "Existing Settings / Infrastructure connected-systems ledger and API platform probe, plus saved Proxmox connection editor; production consumers in an offline app-runtime fixture" + ], + "states": [ + "Untrusted TLS certificate and changed fingerprint with independently checked identity guidance", + "403 denial with misleading upstream authentication text preserves intended resource limits", + "401 rejected authentication makes replacement conditional on confirmed invalidity", + "595 server failure with quoted 401/403 is not presented as invalid credentials", + "Ordinary synthetic recovery clears old errors; saved SSL verification, fingerprint and collection opt-ins preserved" + ], + "interactions": [ + "Desktop keyboard Tab/Enter and phone touch submit the existing address-only probe, with no credential route selected on failure", + "Both production consuming surfaces show the full safe hint, with wrapped text and no horizontal overflow", + "Same-address next probe and ledger reload clear stale guidance without any settings write", + "Manage opens the saved Proxmox form: Verify SSL remains checked, pin unchanged, VMs on and containers off; dirty name survives ledger reload" + ], + "viewports": [ + { + "width": 1280, + "height": 1000 + }, + { + "width": 390, + "height": 1000 + } + ], + "verified_at": "2026-10-14T17:30:38Z", + "engines": [ + "Playwright 1.56.1 / chromium 141.0.7390.37", + "Playwright 1.56.1 / webkit 26.0" + ], + "scope": "Ten HTTP-fixture consumer journeys; all sixteen final captures opened natively. Exact runtime hashes match this source. Not native/installed trust recovery, permissions acceptance, full authentication qualification or release availability." +} diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/ConnectionEditor.test.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/ConnectionEditor.test.tsx index 358c89d3ed..a31cb040e7 100644 --- a/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/ConnectionEditor.test.tsx +++ b/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/ConnectionEditor.test.tsx @@ -22,6 +22,45 @@ describe('ConnectionEditor', () => { vi.clearAllMocks(); }); + it.each([ + [ + 'certificate fingerprint mismatch: expected fixture-old, got fixture-new', + 'TLS fingerprint changed.', + 'Keep TLS verification enabled.', + ], + [ + 'API error 403: authentication failed', + 'Permission denied.', + 'Keep intentional access limits', + ], + ])( + 'renders safe probe guidance and clears it on ordinary recovery: %s', + async (raw, headline, hint) => { + mockedProbe.mockRejectedValueOnce(new Error(raw)); + mockedProbe.mockResolvedValueOnce({ candidates: [], probedMs: 10 }); + const renderSlot = vi.fn(() =>
); + render(() => {}} />); + const input = screen.getByLabelText('API endpoint') as HTMLInputElement; + fireEvent.input(input, { target: { value: 'https://pve.lab:8006' } }); + fireEvent.click(screen.getByRole('button', { name: /probe api endpoint/i })); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain(headline); + expect(alert.textContent).toContain(hint); + expect(alert.textContent).not.toContain('credentials connected'); + expect(alert.textContent).not.toContain('disable TLS'); + expect(input.value).toBe('https://pve.lab:8006'); + expect(mockedProbe).toHaveBeenCalledTimes(1); + expect(renderSlot).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: /probe api endpoint/i })); + await screen.findByText('No supported API-backed platform detected at that address.'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(mockedProbe).toHaveBeenCalledTimes(2); + expect(input.value).toBe('https://pve.lab:8006'); + expect(renderSlot).not.toHaveBeenCalled(); + }, + ); + it('runs a probe and dispatches the detected type into the credential slot', async () => { const response: ProbeResponse = { candidates: [ diff --git a/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.branchcov0722am.test.ts b/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.branchcov0722am.test.ts index c1cef7d215..7f21cc8661 100644 --- a/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.branchcov0722am.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.branchcov0722am.test.ts @@ -279,6 +279,34 @@ describe('connectionToRow (standalone buildRow arms)', () => { const row = connectionToRow(connectionFixture({ lastError: null })); expect(row.lastErrorMessage).toBeUndefined(); }); + + it.each([ + [ + 'certificate fingerprint mismatch: expected fixture-old, got fixture-new', + 'TLS fingerprint changed.', + 'Independently verify its identity', + ], + [ + 'API error 403: authentication failed', + 'Permission denied.', + 'Keep intentional access limits', + ], + ])('presents safe ledger guidance without mutating the connection: %s', (raw, headline, hint) => { + const connection = connectionFixture({ + state: 'unauthorized', + scope: { vms: true, containers: false }, + lastError: { message: raw, at: '2026-07-22T12:00:00Z' }, + }); + const original = JSON.stringify(connection); + const row = connectionToRow(connection); + expect(row.lastErrorMessage).toContain(headline); + expect(row.lastErrorMessage).toContain(hint); + expect(row.lastErrorMessage).not.toContain('disable TLS'); + expect(row.lastErrorMessage).not.toContain('credentials connected'); + expect(row.statusLabel).toBe('Unauthorized'); + expect(JSON.stringify(connection)).toBe(original); + expect(row.connection).toBe(connection); + }); }); // ---- useConnectionsLedger (systemToRow & row-derivation arms) --------------- diff --git a/frontend-modern/src/utils/__tests__/connectionErrorPresentation.test.ts b/frontend-modern/src/utils/__tests__/connectionErrorPresentation.test.ts index 54dd56ea45..8cf808ac88 100644 --- a/frontend-modern/src/utils/__tests__/connectionErrorPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/connectionErrorPresentation.test.ts @@ -56,6 +56,90 @@ describe('humanizeConnectionError', () => { expect(result?.headline).toBe('Permission denied'); }); + describe('trust-preserving recovery guidance', () => { + it.each([ + ['x509: certificate signed by unknown authority', 'TLS certificate not trusted'], + ['x509: certificate has expired or is not yet valid', 'TLS certificate expired'], + ['remote error: tls: handshake failure', 'TLS handshake failed'], + [ + 'certificate fingerprint mismatch: expected fixture-old, got fixture-new', + 'TLS fingerprint changed', + ], + ['truenas tls pinning failed: fingerprint mismatch', 'TLS fingerprint changed'], + [ + 'TLS fingerprint changed - independently verify the certificate before updating the saved fingerprint', + 'TLS fingerprint changed', + ], + ['x509: certificate is valid for old.lab, not new.lab', 'TLS certificate validation failed'], + [ + 'TLS certificate error - check SSL settings or add fingerprint', + 'TLS certificate validation failed', + ], + ['TLS certificate validation failed', 'TLS certificate validation failed'], + ])('keeps verification and independently checked identity for %s', (raw, headline) => { + const result = humanizeConnectionError(raw); + expect(result?.headline).toBe(headline); + expect(result?.hint).toContain('Keep TLS verification enabled.'); + expect(result?.hint).toContain('hostname, expiry and trusted chain'); + expect(result?.hint).toContain( + 'Independently verify its identity before adding or replacing', + ); + expect(result?.hint).not.toMatch(/disable|temporarily|switch.*HTTP/i); + }); + + it.each([ + 'API error 403: Forbidden', + 'HTTP status 403: authentication failed; quoted upstream 401', + 'authentication error: API error 403: invalid credentials', + 'permission denied', + ])('does not infer a successful login or prescribe broader access for %s', (raw) => { + const result = humanizeConnectionError(raw); + expect(result?.headline).toBe('Permission denied'); + expect(result?.hint).toContain('intended resource'); + expect(result?.hint).toContain('any proxy policy'); + expect(result?.hint).toContain('Keep intentional access limits'); + expect(result?.hint).toContain('do not replace credentials or broaden access just to test'); + expect(result?.hint).not.toMatch(/credentials connected|logged in|Administrator/); + }); + + it.each(['401 Unauthorized', 'authentication failed', 'invalid API token'])( + 'makes credential replacement conditional for %s', + (raw) => { + const result = humanizeConnectionError(raw); + expect(result?.headline).toBe('Authentication failed'); + expect(result?.hint).toContain('user, realm and token ID'); + expect(result?.hint).toContain('Replace it only if invalidity is confirmed'); + }, + ); + + it.each([500, 502, 595])( + 'does not misclassify HTTP %i provider auth text as a 401', + (status) => { + const result = humanizeConnectionError( + `API error ${status}: Authentication failed; provider mentioned 401 and 403`, + ); + expect(result?.headline).toBe('Server error'); + expect(result?.hint).not.toMatch(/token|credential/); + }, + ); + + it('ignores status-shaped numbers in the endpoint and poll identity', () => { + expect( + humanizeConnectionError( + 'poll_nodes failed on 401: Get "https://pve.lab:403/api/500": something novel', + ), + ).toEqual({ headline: 'something novel', hint: null }); + }); + + it.each([400, 429])( + 'keeps unclassified HTTP %i responses instead of inferring auth failure', + (status) => { + const raw = `API error ${status}: provider mentioned 401 Unauthorized`; + expect(humanizeConnectionError(raw)).toEqual({ headline: raw, hint: null }); + }, + ); + }); + it('classifies access violations as blocked connections', () => { const result = humanizeConnectionError( 'poll_nodes failed on delly: Get "https://delly:8006/api2/json/nodes": Access violation', diff --git a/frontend-modern/src/utils/connectionErrorPresentation.ts b/frontend-modern/src/utils/connectionErrorPresentation.ts index 23d9424e57..60ebd918f1 100644 --- a/frontend-modern/src/utils/connectionErrorPresentation.ts +++ b/frontend-modern/src/utils/connectionErrorPresentation.ts @@ -28,8 +28,29 @@ interface HumanizedConnectionError { const TIMEOUT_HINT = 'Check the host is reachable, the port is correct, and the network path is open.'; const TLS_HINT = - 'Verify the certificate or pin a fingerprint in TLS settings, or disable TLS verification temporarily for testing.'; -const AUTH_HINT = 'Re-check the API token or username/password.'; + "Keep TLS verification enabled. Check the certificate's hostname, expiry and trusted chain. Independently verify its identity before adding or replacing a saved fingerprint."; +const AUTH_HINT = + 'Check the configured user, realm and token ID, and whether the credential has expired or been revoked. Replace it only if invalidity is confirmed.'; +const DENIED_HINT = + 'Check read permissions for the intended resource and any proxy policy. Keep intentional access limits; do not replace credentials or broaden access just to test.'; + +const HTTP_STATUS_PRESENTATIONS: Record = { + 401: { headline: 'Authentication failed', hint: AUTH_HINT }, + 403: { headline: 'Permission denied', hint: DENIED_HINT }, + 404: { + headline: 'Endpoint not found', + hint: 'Pulse reached the host but the API path is missing. Check the URL path or the platform version.', + }, +}; +const SERVER_ERROR: HumanizedConnectionError = { + headline: 'Server error', + hint: 'The platform returned a server error. Check the platform logs for the underlying cause.', +}; + +// Use the first explicit response status, not a number in an endpoint or a +// quoted provider explanation. A 403/5xx mentioning authentication is not a 401. +const HTTP_STATUS_PATTERN = + /\b(?:API error|HTTP(?: error| status(?: code)?)?|status(?: code)?)[\s:]+([45]\d{2})\b|^([45]\d{2})\b/i; const HUMANIZED_PATTERNS: { match: RegExp; headline: string; hint: string | null }[] = [ { @@ -52,6 +73,11 @@ const HUMANIZED_PATTERNS: { match: RegExp; headline: string; hint: string | null headline: 'No network route to host', hint: 'The host cannot be reached from this Pulse instance. Check VPN, firewall, or VLAN configuration.', }, + { + match: /(?:certificate |tls (?:pinning failed: )?)?fingerprint (?:mismatch|changed)/i, + headline: 'TLS fingerprint changed', + hint: TLS_HINT, + }, { match: /x509:.*signed by unknown authority|certificate signed by unknown/i, headline: 'TLS certificate not trusted', @@ -68,9 +94,9 @@ const HUMANIZED_PATTERNS: { match: RegExp; headline: string; hint: string | null hint: TLS_HINT, }, { - match: /\b401\b|unauthorized|authentication failed|invalid (?:credentials|api token)/i, - headline: 'Authentication failed', - hint: AUTH_HINT, + match: /x509:|TLS certificate (?:error|validation failed)/i, + headline: 'TLS certificate validation failed', + hint: TLS_HINT, }, { match: /access violation/i, @@ -78,19 +104,22 @@ const HUMANIZED_PATTERNS: { match: RegExp; headline: string; hint: string | null hint: 'The request was blocked before Pulse could read inventory. Check proxy, firewall, or network policy settings.', }, { - match: /\b403\b|forbidden|permission denied/i, + match: /forbidden|permission denied/i, headline: 'Permission denied', - hint: 'The credentials connected, but the user/token lacks the required role.', + hint: DENIED_HINT, }, { - match: /\b404\b|not found/i, - headline: 'Endpoint not found', - hint: 'Pulse reached the host but the API path is missing. Check the URL path or the platform version.', + match: /unauthorized|authentication failed|invalid (?:credentials|api token)/i, + headline: 'Authentication failed', + hint: AUTH_HINT, }, { - match: /\b5\d{2}\b|server error|internal error/i, - headline: 'Server error', - hint: 'The platform returned a server error. Check the platform logs for the underlying cause.', + match: /not found/i, + ...HTTP_STATUS_PRESENTATIONS[404], + }, + { + match: /server error|internal error/i, + ...SERVER_ERROR, }, ]; @@ -102,15 +131,23 @@ export const humanizeConnectionError = (raw?: string | null): HumanizedConnectio const trimmed = raw.trim(); if (!trimmed) return null; + const cleaned = stripImplementationPrefixes(trimmed); + const statusMatch = cleaned.match(HTTP_STATUS_PATTERN); + if (statusMatch) { + const status = Number(statusMatch[1] ?? statusMatch[2]); + const presentation = HTTP_STATUS_PRESENTATIONS[status] ?? (status >= 500 ? SERVER_ERROR : null); + if (presentation) return { ...presentation }; + return { headline: cleaned, hint: null }; + } + for (const pattern of HUMANIZED_PATTERNS) { - if (pattern.match.test(trimmed)) { + if (pattern.match.test(cleaned)) { return { headline: pattern.headline, hint: pattern.hint }; } } // Unknown error: fall back to the raw text with the most common // implementation-leaking prefixes peeled off so it's at least readable. - const cleaned = stripImplementationPrefixes(trimmed); return { headline: cleaned || 'Connection error', hint: null }; };