mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-22 23:00:42 +00:00
Integrate reviewed trust-preserving connection guidance
Change-source: pulse-maintainer
This commit is contained in:
commit
cc8514de4f
5 changed files with 246 additions and 15 deletions
43
frontend-modern/browser-verification/42faa8f482b1cf9c.json
Normal file
43
frontend-modern/browser-verification/42faa8f482b1cf9c.json
Normal 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."
|
||||
}
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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) ---------------
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue