Integrate reviewed trust-preserving connection guidance

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot] 2026-10-14 18:51:55 +01:00
commit cc8514de4f
5 changed files with 246 additions and 15 deletions

View file

@ -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."
}

View file

@ -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(() => <div />);
render(() => <ConnectionEditor renderCredentialSlot={renderSlot} onClose={() => {}} />);
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: [

View file

@ -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) ---------------

View file

@ -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',

View file

@ -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<number, HumanizedConnectionError> = {
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 };
};