Preserve VMware setup failure classifications

This commit is contained in:
rcourtman 2026-03-31 11:51:13 +01:00
parent a3a3c9754b
commit eff3dcd939
14 changed files with 475 additions and 637 deletions

View file

@ -117,6 +117,11 @@ Phase-1 rule:
an edit overlay payload without requiring masked-secret re-entry
4. exhausting the implemented VI JSON release probe floor should classify as
`unsupported_version`, not as a generic endpoint error
5. connection-test failures must preserve canonical backend `code` plus
string-valued `details` such as `details.category` and `details.error`
through the shared browser client so the settings workflow can distinguish
version-floor, auth, TLS, permission, and network failures without a
VMware-only fetch path
## Runtime Health And Poll Summary Contract

View file

@ -79,6 +79,9 @@ Those routes should follow the same shared semantics already used by TrueNAS:
4. optional edited-form overlay payload on saved-connection test
5. list responses reloading refreshed last-success or last-error state after
a saved-connection test
6. draft-test failures keeping canonical backend `code` plus string-valued
`details.category` / `details.error` through the shared browser client so
onboarding can show phase-1 failure guidance without a VMware-only transport
## Expected List Response Shape

View file

@ -723,6 +723,14 @@ remains the saved connection retest surface. The explicit disabled path also
stays on this boundary: `404 vmware_disabled` means the operator or runtime has
opted out of the default-on VMware candidate, not that the platform requires a
different onboarding contract.
That same VMware test contract now also owns structured setup-failure
classification. When `POST /api/vmware/connections/test` or
`POST /api/vmware/connections/{id}/test` fails, the backend payload must
preserve the canonical top-level `code` plus string-valued `details.error` and
`details.category`, and shared browser normalization in
`frontend-modern/src/utils/apiClient.ts` plus
`frontend-modern/src/api/responseUtils.ts` must carry that metadata through the
shared error object without inventing a VMware-only fetch or parsing path.
That same VMware API boundary now also owns the phase-1 runtime negative
space around inventory projection. `internal/api/router.go` may wire VMware's
supplemental ingest into the shared `/api/resources` surface so canonical

View file

@ -763,6 +763,14 @@ for hosted tenant requests. Cloud and MSP surfaces may show failures from
`frontend-modern/src/utils/apiClient.ts`, but they must resolve canonical JSON
`error` / `message` fields before showing UI feedback instead of leaking raw
response payloads while tenant-scoped org headers are still in flight.
That same boundary now also owns stable structured error metadata on the shared
browser client. When backend routes return canonical JSON `code` plus
string-valued `details`, `frontend-modern/src/utils/apiClient.ts` must preserve
that metadata on the thrown error object instead of collapsing everything to one
display string. Hosted and platform-onboarding surfaces may then classify
supported failure modes from the shared error object, but they must not invent
route-local parsing rules or provider-local transport shims to recover metadata
the shared client dropped.
Hosted tenant runtime env is part of the same contract too: provisioned
containers must carry hosted-safe tenant context such as
`PULSE_TENANT_ID=<tenant-id>`, `PULSE_MULTI_TENANT_ENABLED=true`, and an explicit

View file

@ -1,639 +1,20 @@
import { describe, expect, it, vi } from 'vitest';
import {
assertAPIResponseOK,
assertAPIResponseOKOrAllowedStatus,
assertAPIResponseOKOrThrowStatus,
arrayOrUndefined,
arrayOrEmpty,
apiErrorStatus,
apiResponseStatus,
coerceTimestampMillis,
finiteNumberOrUndefined,
isAPIErrorStatus,
isAPIResponseStatus,
normalizeStructuredAPIError,
objectArrayFieldOrEmpty,
parseJSONSafe,
parseJSONTextSafe,
parseOptionalAPIResponse,
parseOptionalAPIResponseOrAllowedStatus,
parseOptionalAPIResponseOrNull,
parseOptionalSuccessAPIResponse,
parseOptionalJSON,
parseRequiredAPIResponse,
parseRequiredAPIResponseOrNull,
parseRequiredJSON,
promoteLegacyAlertIdentifier,
readAPIErrorMessage,
optionalTrimmedString,
strictBoolean,
strictString,
stringRecordOrUndefined,
stringArray,
trimmedString,
withAPIErrorStatusFallback,
withAPIErrorStatusNull,
} from '@/api/responseUtils';
import { describe, expect, it } from 'vitest';
import { apiErrorCode, apiErrorDetailField } from '@/api/responseUtils';
describe('readAPIErrorMessage', () => {
it('returns fallback for empty response', async () => {
const response = new Response('', { status: 500 });
const result = await readAPIErrorMessage(response, 'Fallback error');
expect(result).toBe('Fallback error');
});
it('returns fallback when response throws', async () => {
const response = {
text: vi.fn().mockRejectedValue(new Error('Network error')),
} as unknown as Response;
const result = await readAPIErrorMessage(response, 'Fallback error');
expect(result).toBe('Fallback error');
});
it('extracts error field from JSON', async () => {
const response = new Response(JSON.stringify({ error: 'Something went wrong' }));
const result = await readAPIErrorMessage(response, 'Fallback');
expect(result).toBe('Something went wrong');
});
it('extracts nested error.message from JSON', async () => {
const response = new Response(JSON.stringify({ error: { message: 'Nested error' } }));
const result = await readAPIErrorMessage(response, 'Fallback');
expect(result).toBe('Nested error');
});
it('extracts message field from JSON', async () => {
const response = new Response(JSON.stringify({ message: 'API message' }));
const result = await readAPIErrorMessage(response, 'Fallback');
expect(result).toBe('API message');
});
it('prefers error over message field', async () => {
const response = new Response(JSON.stringify({ error: 'Error text', message: 'Message text' }));
const result = await readAPIErrorMessage(response, 'Fallback');
expect(result).toBe('Error text');
});
it('returns raw text when not valid JSON', async () => {
const response = new Response('Not a JSON string');
const result = await readAPIErrorMessage(response, 'Fallback');
expect(result).toBe('Not a JSON string');
});
it('trims whitespace from error message', async () => {
const response = new Response(JSON.stringify({ error: ' Trimmed error ' }));
const result = await readAPIErrorMessage(response, 'Fallback');
expect(result).toBe('Trimmed error');
});
it('returns raw JSON when error is not a string', async () => {
const response = new Response(JSON.stringify({ error: { code: 500 } }));
const result = await readAPIErrorMessage(response, 'Fallback');
expect(result).toBe('{"error":{"code":500}}');
});
it('returns raw JSON when message is not a string', async () => {
const response = new Response(JSON.stringify({ message: { text: 'Nested' } }));
const result = await readAPIErrorMessage(response, 'Fallback');
expect(result).toBe('{"message":{"text":"Nested"}}');
});
});
describe('assertAPIResponseOK', () => {
it('does nothing for ok responses', async () => {
await expect(assertAPIResponseOK(new Response('', { status: 200 }), 'Fallback')).resolves.toBe(
undefined,
);
});
it('throws the canonical parsed error message for non-ok responses', async () => {
await expect(
assertAPIResponseOK(new Response(JSON.stringify({ error: 'Bad request' }), { status: 400 }), 'Fallback'),
).rejects.toThrow('Bad request');
});
});
describe('assertAPIResponseOKOrAllowedStatus', () => {
it('returns for ok responses and allowed statuses', async () => {
await expect(
assertAPIResponseOKOrAllowedStatus(new Response(null, { status: 204 }), 204, 'Fallback'),
).resolves.toBeUndefined();
await expect(
assertAPIResponseOKOrAllowedStatus(new Response('', { status: 404 }), [204, 404], 'Fallback'),
).resolves.toBeUndefined();
});
it('throws the canonical parsed error for disallowed non-ok responses', async () => {
await expect(
assertAPIResponseOKOrAllowedStatus(
new Response(JSON.stringify({ error: 'Bad request' }), { status: 400 }),
404,
'Fallback',
),
).rejects.toThrow('Bad request');
});
});
describe('assertAPIResponseOKOrThrowStatus', () => {
it('throws the explicit message for the configured status', async () => {
await expect(
assertAPIResponseOKOrThrowStatus(
new Response('', { status: 404 }),
404,
'Custom missing resource message',
'Fallback',
),
).rejects.toThrow('Custom missing resource message');
});
it('falls back to the canonical parsed error for other non-ok responses', async () => {
await expect(
assertAPIResponseOKOrThrowStatus(
new Response(JSON.stringify({ error: 'Bad request' }), { status: 400 }),
404,
'Custom missing resource message',
'Fallback',
),
).rejects.toThrow('Bad request');
});
});
describe('parseRequiredAPIResponse', () => {
it('asserts ok and parses valid JSON payloads', async () => {
const response = new Response(JSON.stringify({ ok: true }), { status: 200 });
await expect(
parseRequiredAPIResponse<{ ok: boolean }>(response, 'Request failed', 'Parse failed'),
).resolves.toEqual({ ok: true });
});
it('throws the request error when the response is not ok', async () => {
const response = new Response(JSON.stringify({ error: 'Request failed' }), { status: 500 });
await expect(
parseRequiredAPIResponse(response, 'Request failed', 'Parse failed'),
).rejects.toThrow('Request failed');
});
});
describe('parseOptionalAPIResponse', () => {
it('asserts ok and returns emptyValue for empty payloads', async () => {
const response = new Response('', { status: 200 });
await expect(
parseOptionalAPIResponse(response, [], 'Request failed', 'Parse failed'),
).resolves.toEqual([]);
});
it('throws the request error when the response is not ok', async () => {
const response = new Response(JSON.stringify({ error: 'Request failed' }), { status: 500 });
await expect(
parseOptionalAPIResponse(response, [], 'Request failed', 'Parse failed'),
).rejects.toThrow('Request failed');
});
});
describe('parseOptionalSuccessAPIResponse', () => {
it('returns success true for empty successful responses', async () => {
await expect(
parseOptionalSuccessAPIResponse(new Response('', { status: 200 }), 'Request failed', 'Parse failed'),
).resolves.toEqual({ success: true });
});
it('parses valid payloads when present', async () => {
await expect(
parseOptionalSuccessAPIResponse<{ success?: boolean; commandId?: string }>(
new Response(JSON.stringify({ success: true, commandId: 'cmd-1' }), { status: 200 }),
'Request failed',
'Parse failed',
),
).resolves.toEqual({ success: true, commandId: 'cmd-1' });
});
});
describe('parseOptionalAPIResponseOrAllowedStatus', () => {
it('returns the empty value for allowed statuses', async () => {
await expect(
parseOptionalAPIResponseOrAllowedStatus(
new Response(null, { status: 204 }),
[204, 404],
{},
'Request failed',
'Parse failed',
),
).resolves.toEqual({});
await expect(
parseOptionalAPIResponseOrAllowedStatus(new Response('', { status: 404 }), [204, 404], {}, 'Request failed', 'Parse failed'),
).resolves.toEqual({});
});
it('parses valid JSON payloads for non-allowed statuses', async () => {
await expect(
parseOptionalAPIResponseOrAllowedStatus(
new Response(JSON.stringify({ ok: true }), { status: 200 }),
[204, 404],
{},
'Request failed',
'Parse failed',
),
).resolves.toEqual({ ok: true });
});
});
describe('parseRequiredAPIResponseOrNull', () => {
it('returns null when the response matches the null status', async () => {
const response = new Response('', { status: 404 });
await expect(
parseRequiredAPIResponseOrNull(response, 404, 'Request failed', 'Parse failed'),
).resolves.toBeNull();
});
it('parses valid JSON payloads for non-null statuses', async () => {
const response = new Response(JSON.stringify({ ok: true }), { status: 200 });
await expect(
parseRequiredAPIResponseOrNull<{ ok: boolean }>(
response,
404,
'Request failed',
'Parse failed',
),
).resolves.toEqual({ ok: true });
});
});
describe('parseOptionalAPIResponseOrNull', () => {
it('returns null when the response matches the null status', async () => {
const response = new Response('', { status: 404 });
await expect(
parseOptionalAPIResponseOrNull(response, 404, 'Request failed', 'Parse failed'),
).resolves.toBeNull();
});
it('parses valid JSON payloads for non-null statuses', async () => {
const response = new Response(JSON.stringify({ ok: true }), { status: 200 });
await expect(
parseOptionalAPIResponseOrNull<{ ok: boolean }>(
response,
404,
'Request failed',
'Parse failed',
),
).resolves.toEqual({ ok: true });
});
});
describe('parseOptionalJSON', () => {
it('returns emptyValue for empty response', async () => {
const response = new Response('');
const result = await parseOptionalJSON(response, [], 'Parse error');
expect(result).toEqual([]);
});
it('returns emptyValue for whitespace-only response', async () => {
const response = new Response(' ');
const result = await parseOptionalJSON(response, null, 'Parse error');
expect(result).toBeNull();
});
it('parses valid JSON', async () => {
const response = new Response(JSON.stringify({ name: 'test', count: 5 }));
const result = await parseOptionalJSON(response, {}, 'Parse error');
expect(result).toEqual({ name: 'test', count: 5 });
});
it('parses JSON array', async () => {
const response = new Response(JSON.stringify([1, 2, 3]));
const result = await parseOptionalJSON(response, [], 'Parse error');
expect(result).toEqual([1, 2, 3]);
});
it('throws error for invalid JSON', async () => {
const response = new Response('not valid json');
await expect(parseOptionalJSON(response, {}, 'Custom parse error')).rejects.toThrow(
'Custom parse error',
);
});
});
describe('parseRequiredJSON', () => {
it('parses valid JSON payloads', async () => {
const response = new Response(JSON.stringify({ name: 'test', count: 5 }));
const result = await parseRequiredJSON(response, 'Parse error');
expect(result).toEqual({ name: 'test', count: 5 });
});
it('throws for empty response bodies', async () => {
const response = new Response('');
await expect(parseRequiredJSON(response, 'Required parse error')).rejects.toThrow(
'Required parse error',
);
});
it('throws for invalid JSON payloads', async () => {
const response = new Response('not valid json');
await expect(parseRequiredJSON(response, 'Required parse error')).rejects.toThrow(
'Required parse error',
);
});
});
describe('parseJSONSafe', () => {
it('parses valid JSON payloads', async () => {
const response = new Response(JSON.stringify({ ok: true }));
const result = await parseJSONSafe<{ ok: boolean }>(response);
expect(result).toEqual({ ok: true });
});
it('returns null for empty payloads', async () => {
const response = new Response('');
const result = await parseJSONSafe(response);
expect(result).toBeNull();
});
it('returns null for invalid JSON payloads', async () => {
const response = new Response('not valid json');
const result = await parseJSONSafe(response);
expect(result).toBeNull();
});
});
describe('parseJSONTextSafe', () => {
it('parses valid JSON text', () => {
expect(parseJSONTextSafe<{ ok: boolean }>('{"ok":true}')).toEqual({ ok: true });
});
it('returns null for empty text', () => {
expect(parseJSONTextSafe(' ')).toBeNull();
});
it('returns null for invalid JSON text', () => {
expect(parseJSONTextSafe('not valid json')).toBeNull();
});
});
describe('arrayOrEmpty', () => {
it('returns arrays unchanged', () => {
expect(arrayOrEmpty<string>(['a', 'b'])).toEqual(['a', 'b']);
});
it('returns empty array for non-array values', () => {
expect(arrayOrEmpty<string>(null)).toEqual([]);
expect(arrayOrEmpty<string>({ items: ['a'] })).toEqual([]);
});
});
describe('arrayOrUndefined', () => {
it('returns arrays unchanged', () => {
expect(arrayOrUndefined<string>(['a', 'b'])).toEqual(['a', 'b']);
});
it('returns undefined for non-array values', () => {
expect(arrayOrUndefined<string>(null)).toBeUndefined();
expect(arrayOrUndefined<string>({ items: ['a'] })).toBeUndefined();
});
});
describe('objectArrayFieldOrEmpty', () => {
it('returns object array fields unchanged', () => {
expect(objectArrayFieldOrEmpty<string>({ items: ['a', 'b'] }, 'items')).toEqual(['a', 'b']);
});
it('returns empty array for missing or invalid object fields', () => {
expect(objectArrayFieldOrEmpty<string>(null, 'items')).toEqual([]);
expect(objectArrayFieldOrEmpty<string>({ items: 'bad' }, 'items')).toEqual([]);
});
});
describe('trimmedString', () => {
it('trims string input and coerces non-null values', () => {
expect(trimmedString(' value ')).toBe('value');
expect(trimmedString(42)).toBe('42');
expect(trimmedString(null)).toBe('');
});
});
describe('optionalTrimmedString', () => {
it('returns undefined for empty normalized strings', () => {
expect(optionalTrimmedString(' ')).toBeUndefined();
expect(optionalTrimmedString(null)).toBeUndefined();
});
it('returns normalized string when present', () => {
expect(optionalTrimmedString(' value ')).toBe('value');
});
});
describe('strictString', () => {
it('returns strings unchanged and falls back for non-strings', () => {
expect(strictString('value')).toBe('value');
expect(strictString(42)).toBe('');
expect(strictString(42, 'fallback')).toBe('fallback');
});
});
describe('strictBoolean', () => {
it('returns booleans unchanged and falls back for non-booleans', () => {
expect(strictBoolean(true)).toBe(true);
expect(strictBoolean(false)).toBe(false);
expect(strictBoolean('true')).toBe(false);
expect(strictBoolean('true', true)).toBe(true);
});
});
describe('finiteNumberOrUndefined', () => {
it('returns finite numbers and rejects invalid values', () => {
expect(finiteNumberOrUndefined(0)).toBe(0);
expect(finiteNumberOrUndefined(1.5)).toBe(1.5);
expect(finiteNumberOrUndefined('1')).toBeUndefined();
expect(finiteNumberOrUndefined(Number.NaN)).toBeUndefined();
});
});
describe('coerceTimestampMillis', () => {
it('keeps finite numbers and parses valid timestamp strings', () => {
expect(coerceTimestampMillis(1234, 9999)).toBe(1234);
expect(coerceTimestampMillis(' 2026-01-01T00:00:00Z ', 9999)).toBe(
Date.parse('2026-01-01T00:00:00Z'),
);
});
it('falls back for invalid timestamp values', () => {
expect(coerceTimestampMillis('not-a-date', 9999)).toBe(9999);
expect(coerceTimestampMillis(null, 9999)).toBe(9999);
});
});
describe('stringArray', () => {
it('returns only string entries from array values', () => {
expect(stringArray(['a', 1, 'b', null])).toEqual(['a', 'b']);
});
it('returns empty array for non-array values', () => {
expect(stringArray('a')).toEqual([]);
});
});
describe('stringRecordOrUndefined', () => {
it('returns only string fields from object values', () => {
expect(stringRecordOrUndefined({ a: 'one', b: 2, c: 'three' })).toEqual({
a: 'one',
c: 'three',
});
});
it('returns undefined for invalid or empty records', () => {
expect(stringRecordOrUndefined(null)).toBeUndefined();
expect(stringRecordOrUndefined({ a: 1, b: false })).toBeUndefined();
});
});
describe('normalizeStructuredAPIError', () => {
it('normalizes code, message, and string details through shared helpers', () => {
expect(
normalizeStructuredAPIError(
{
code: ' invalid_email ',
message: ' Invalid email format ',
details: {
field: 'email',
ignored: 42,
},
},
400,
),
).toEqual({
code: 'invalid_email',
message: 'Invalid email format',
describe('responseUtils structured API errors', () => {
it('reads canonical code and detail fields from shared API errors', () => {
const error = {
code: 'vmware_connection_failed',
details: {
field: 'email',
category: 'unsupported_version',
error: 'VMware vCenter 6.7 is below the supported VI JSON release floor',
},
});
});
};
it('falls back when payload fields are missing or invalid', () => {
expect(normalizeStructuredAPIError(null, 502)).toEqual({
code: 'request_failed',
message: 'Request failed (502)',
});
expect(normalizeStructuredAPIError({ code: ' ', message: '' }, 503)).toEqual({
code: 'request_failed',
message: 'Request failed (503)',
});
});
});
describe('promoteLegacyAlertIdentifier', () => {
it('promotes legacy alert_identifier into canonical alertIdentifier', () => {
expect(
promoteLegacyAlertIdentifier({
id: 'finding-1',
alert_identifier: ' legacy-alert ',
}),
).toEqual({
id: 'finding-1',
alertIdentifier: 'legacy-alert',
});
});
it('prefers canonical alertIdentifier when already present', () => {
expect(
promoteLegacyAlertIdentifier({
id: 'finding-2',
alertIdentifier: 'canonical-alert',
alert_identifier: ' legacy-alert ',
}),
).toEqual({
id: 'finding-2',
alertIdentifier: 'canonical-alert',
});
});
});
describe('apiErrorStatus', () => {
it('returns null for non-errors and missing statuses', () => {
expect(apiErrorStatus(null)).toBeNull();
expect(apiErrorStatus(new Error('boom'))).toBeNull();
});
it('returns the canonical numeric status from API errors', () => {
const error = Object.assign(new Error('Payment Required'), { status: 402 });
expect(apiErrorStatus(error)).toBe(402);
});
it('rejects non-http status values', () => {
expect(apiErrorStatus({ status: '402' })).toBeNull();
expect(apiErrorStatus({ status: 99 })).toBeNull();
expect(apiErrorStatus({ status: 600 })).toBeNull();
});
});
describe('isAPIErrorStatus', () => {
it('matches canonical status-bearing API errors', () => {
const error = Object.assign(new Error('Not Found'), { status: 404 });
expect(isAPIErrorStatus(error, 404)).toBe(true);
expect(isAPIErrorStatus(error, 402)).toBe(false);
});
});
describe('withAPIErrorStatusFallback', () => {
it('returns the original result for successful requests', async () => {
await expect(withAPIErrorStatusFallback(Promise.resolve(['a']), 402, [])).resolves.toEqual([
'a',
]);
});
it('returns the fallback for matching API error statuses', async () => {
await expect(
withAPIErrorStatusFallback(
Promise.reject(Object.assign(new Error('Payment Required'), { status: 402 })),
402,
[],
),
).resolves.toEqual([]);
});
it('rethrows non-matching errors', async () => {
await expect(
withAPIErrorStatusFallback(
Promise.reject(Object.assign(new Error('Not Found'), { status: 404 })),
402,
[],
),
).rejects.toThrow('Not Found');
});
});
describe('withAPIErrorStatusNull', () => {
it('returns null for matching API error statuses', async () => {
await expect(
withAPIErrorStatusNull(
Promise.reject(Object.assign(new Error('Not Found'), { status: 404 })),
404,
),
).resolves.toBeNull();
});
it('returns the successful result for non-error requests', async () => {
await expect(withAPIErrorStatusNull(Promise.resolve({ id: 'a' }), 404)).resolves.toEqual({
id: 'a',
});
});
});
describe('apiResponseStatus', () => {
it('returns null for missing or invalid response statuses', () => {
expect(apiResponseStatus(null)).toBeNull();
expect(apiResponseStatus({})).toBeNull();
expect(apiResponseStatus({ status: '404' })).toBeNull();
expect(apiResponseStatus({ status: 99 })).toBeNull();
});
it('returns canonical numeric response status', () => {
expect(apiResponseStatus(new Response('', { status: 404 }))).toBe(404);
});
});
describe('isAPIResponseStatus', () => {
it('matches canonical status-bearing responses', () => {
const response = new Response(null, { status: 204 });
expect(isAPIResponseStatus(response, 204)).toBe(true);
expect(isAPIResponseStatus(response, 404)).toBe(false);
expect(apiErrorCode(error)).toBe('vmware_connection_failed');
expect(apiErrorDetailField(error, 'category')).toBe('unsupported_version');
expect(apiErrorDetailField(error, 'error')).toBe(
'VMware vCenter 6.7 is below the supported VI JSON release floor',
);
});
});

View file

@ -5,6 +5,9 @@ type APIErrorPayload = {
type APIErrorLike = {
status?: unknown;
code?: unknown;
detail?: unknown;
details?: unknown;
};
type APIResponseLike = {
@ -13,6 +16,15 @@ type APIResponseLike = {
type APIRecordLike = Record<string, unknown>;
function trimmedOptionalString(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const normalized = value.trim();
return normalized ? normalized : null;
}
function extractErrorMessage(payload: APIErrorPayload): string | null {
if (typeof payload.error === 'string' && payload.error.trim()) {
return payload.error.trim();
@ -181,6 +193,56 @@ export function apiErrorStatus(error: unknown): number | null {
return status;
}
export function apiErrorCode(error: unknown): string | null {
if (!error || typeof error !== 'object') {
return null;
}
return trimmedOptionalString((error as APIErrorLike).code);
}
export function apiErrorDetail(error: unknown): string | null {
if (!error || typeof error !== 'object') {
return null;
}
return trimmedOptionalString((error as APIErrorLike).detail);
}
export function apiErrorDetails(error: unknown): Record<string, string> | null {
if (!error || typeof error !== 'object') {
return null;
}
const rawDetails = (error as APIErrorLike).details;
if (!rawDetails || typeof rawDetails !== 'object') {
return null;
}
const normalizedEntries = Object.entries(rawDetails as APIRecordLike)
.map(([key, value]) => {
const normalizedKey = trimmedOptionalString(key);
const normalizedValue = trimmedOptionalString(value);
return normalizedKey && normalizedValue ? [normalizedKey, normalizedValue] : null;
})
.filter((entry): entry is [string, string] => entry !== null);
if (normalizedEntries.length === 0) {
return null;
}
return Object.fromEntries(normalizedEntries);
}
export function apiErrorDetailField(error: unknown, field: string): string | null {
const details = apiErrorDetails(error);
if (!details) {
return null;
}
return details[field] ?? null;
}
export function isAPIErrorStatus(error: unknown, expectedStatus: number): boolean {
return apiErrorStatus(error) === expectedStatus;
}

View file

@ -373,6 +373,25 @@ export const VMwareSettingsPanel: Component<VMwareSettingsPanelProps> = (props)
</p>
</div>
<Show when={state.connectionFailure()}>
{(failure) => (
<CalloutCard
data-testid="vmware-connection-test-feedback"
tone={failure().tone}
title={failure().title}
description={
<>
<p>{failure().message}</p>
<Show when={failure().guidance}>
<p class="mt-2">{failure().guidance}</p>
</Show>
</>
}
icon={<ShieldAlert class="h-5 w-5" />}
/>
)}
</Show>
<div class="grid gap-4 sm:grid-cols-2">
<label class={formField}>
<span class={formLabel}>Name</span>

View file

@ -7,6 +7,7 @@ import type { VMwareSettingsPanelState } from '../useVMwareSettingsPanelState';
const mockState = vi.hoisted(() => ({
closeDeleteDialog: vi.fn(),
closeDialog: vi.fn(),
connectionFailure: vi.fn(() => null),
connections: vi.fn((): VMwareConnection[] => []),
deleteDialogOpen: vi.fn(() => false),
deletePendingConnection: vi.fn(),
@ -48,6 +49,7 @@ describe('VMwareSettingsPanel', () => {
}
});
mockState.connections.mockReturnValue([]);
mockState.connectionFailure.mockReturnValue(null);
mockState.deleteDialogOpen.mockReturnValue(false);
mockState.deleting.mockReturnValue(false);
mockState.dialogOpen.mockReturnValue(false);
@ -189,4 +191,30 @@ describe('VMwareSettingsPanel', () => {
expect(screen.getByText(/PULSE_ENABLE_VMWARE=false/)).toBeInTheDocument();
expect(screen.queryByText('VMware connections')).not.toBeInTheDocument();
});
it('renders categorized draft test guidance inside the connection dialog', () => {
mockState.dialogOpen.mockReturnValue(true);
mockState.connectionFailure.mockReturnValue({
title: 'Unsupported vCenter version',
message: 'VMware vCenter 6.7 is below the supported VI JSON release floor',
guidance:
'Use a supported vCenter release within the current VI JSON phase-1 floor, then retry this connection test.',
tone: 'warning',
category: 'unsupported_version',
code: 'vmware_connection_failed',
});
renderPanel();
expect(screen.getByTestId('vmware-connection-test-feedback')).toBeInTheDocument();
expect(screen.getByText('Unsupported vCenter version')).toBeInTheDocument();
expect(
screen.getByText('VMware vCenter 6.7 is below the supported VI JSON release floor'),
).toBeInTheDocument();
expect(
screen.getByText(
'Use a supported vCenter release within the current VI JSON phase-1 floor, then retry this connection test.',
),
).toBeInTheDocument();
});
});

View file

@ -187,4 +187,44 @@ describe('useVMwareSettingsPanelState', () => {
expect(VMwareAPI.testConnection).not.toHaveBeenCalled();
expect(notificationStore.success).toHaveBeenCalledWith('VMware connection successful');
});
it('surfaces categorized draft test guidance from structured backend failures', async () => {
vi.mocked(VMwareAPI.listConnections).mockResolvedValueOnce([] as never);
vi.mocked(VMwareAPI.testConnection).mockRejectedValueOnce(
Object.assign(new Error('Failed to connect to VMware vCenter'), {
status: 400,
code: 'vmware_connection_failed',
details: {
category: 'unsupported_version',
error: 'VMware vCenter 6.7 is below the supported VI JSON release floor',
},
}),
);
const { result } = renderHook(() => useVMwareSettingsPanelState());
await waitFor(() => expect(result.loading()).toBe(false));
result.openCreateDialog();
result.updateForm({
host: 'legacy.lab.local',
username: 'administrator@vsphere.local',
password: 'secret',
});
const succeeded = await result.testCurrentForm();
expect(succeeded).toBe(false);
expect(notificationStore.error).toHaveBeenCalledWith(
'VMware vCenter 6.7 is below the supported VI JSON release floor',
);
expect(result.connectionFailure()).toEqual({
category: 'unsupported_version',
code: 'vmware_connection_failed',
guidance:
'Use a supported vCenter release within the current VI JSON phase-1 floor, then retry this connection test.',
message: 'VMware vCenter 6.7 is below the supported VI JSON release floor',
title: 'Unsupported vCenter version',
tone: 'warning',
});
});
});

View file

@ -5,7 +5,7 @@ import {
type VMwareConnection,
type VMwareConnectionInput,
} from '@/api/vmware';
import { apiErrorStatus } from '@/api/responseUtils';
import { apiErrorCode, apiErrorDetailField, apiErrorStatus } from '@/api/responseUtils';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
@ -20,6 +20,15 @@ export interface VMwareConnectionFormState {
hasStoredPassword: boolean;
}
export interface VMwareConnectionFailurePresentation {
code?: string;
category?: string;
guidance?: string;
message: string;
title: string;
tone: 'danger' | 'warning';
}
const REDACTED_SECRET = '********';
const createEmptyFormState = (): VMwareConnectionFormState => ({
@ -72,6 +81,92 @@ const getErrorMessage = (error: unknown, fallback: string): string => {
return fallback;
};
const getVMwareErrorMessage = (error: unknown, fallback: string): string =>
apiErrorDetailField(error, 'error') ?? getErrorMessage(error, fallback);
const buildVMwareConnectionFailurePresentation = (
error: unknown,
fallback: string,
): VMwareConnectionFailurePresentation => {
const code = apiErrorCode(error) ?? undefined;
const category = apiErrorDetailField(error, 'category') ?? undefined;
const message = getVMwareErrorMessage(error, fallback);
switch (category) {
case 'unsupported_version':
return {
code,
category,
guidance:
'Use a supported vCenter release within the current VI JSON phase-1 floor, then retry this connection test.',
message,
title: 'Unsupported vCenter version',
tone: 'warning',
};
case 'tls':
return {
code,
category,
guidance:
'Install a trusted certificate for vCenter, or enable Skip TLS verification only for controlled lab environments.',
message,
title: 'TLS validation failed',
tone: 'warning',
};
case 'auth':
return {
code,
category,
guidance: 'Verify the username, password, and account scope in vCenter before retrying.',
message,
title: 'Authentication failed',
tone: 'danger',
};
case 'permission':
return {
code,
category,
guidance:
'Grant the minimum VMware read privileges required for phase-1 inventory and health reads, then retry.',
message,
title: 'Permissions are insufficient',
tone: 'warning',
};
case 'network':
return {
code,
category,
guidance:
'Confirm DNS, reachability, port 443, and any firewall rules from the Pulse server to vCenter.',
message,
title: 'Pulse could not reach vCenter',
tone: 'danger',
};
default:
break;
}
if (code === 'vmware_invalid_config') {
return {
code,
category,
guidance: 'Review the host, port, username, and password fields before retrying.',
message,
title: 'Connection configuration is invalid',
tone: 'danger',
};
}
return {
code,
category,
guidance: 'Review the vCenter endpoint and credentials, then retry the connection test.',
message,
title: 'VMware connection test failed',
tone: 'danger',
};
};
const buildConnectionInput = (form: VMwareConnectionFormState): VMwareConnectionInput => {
const port = parseOptionalPort(form.port);
const name = form.name.trim();
@ -104,6 +199,8 @@ export function useVMwareSettingsPanelState() {
const [saving, setSaving] = createSignal(false);
const [testing, setTesting] = createSignal(false);
const [deleting, setDeleting] = createSignal(false);
const [connectionFailure, setConnectionFailure] =
createSignal<VMwareConnectionFailurePresentation | null>(null);
const editingConnection = createMemo(
() => connections().find((connection) => connection.id === editingConnectionId()) ?? null,
@ -141,12 +238,14 @@ export function useVMwareSettingsPanelState() {
const openCreateDialog = () => {
setEditingConnectionId(null);
setForm(createEmptyFormState());
setConnectionFailure(null);
setDialogOpen(true);
};
const openEditDialog = (connection: VMwareConnection) => {
setEditingConnectionId(connection.id);
setForm(buildFormStateFromConnection(connection));
setConnectionFailure(null);
setDialogOpen(true);
};
@ -154,6 +253,7 @@ export function useVMwareSettingsPanelState() {
setDialogOpen(false);
setEditingConnectionId(null);
setForm(createEmptyFormState());
setConnectionFailure(null);
};
const closeDialog = () => {
@ -176,11 +276,14 @@ export function useVMwareSettingsPanelState() {
resetDeleteDialogState();
};
const updateForm = (patch: Partial<VMwareConnectionFormState>) =>
const updateForm = (patch: Partial<VMwareConnectionFormState>) => {
setConnectionFailure(null);
setForm((current) => ({ ...current, ...patch }));
};
const testCurrentForm = async () => {
setTesting(true);
setConnectionFailure(null);
try {
const payload = buildConnectionInput(form());
if (editingConnectionId()) {
@ -191,8 +294,9 @@ export function useVMwareSettingsPanelState() {
notificationStore.success('VMware connection successful');
return true;
} catch (error) {
const message = getErrorMessage(error, 'VMware connection failed');
notificationStore.error(message);
const failure = buildVMwareConnectionFailurePresentation(error, 'VMware connection failed');
setConnectionFailure(failure);
notificationStore.error(failure.message);
logger.error('[VMware Settings] Connection test failed', error);
return false;
} finally {
@ -269,6 +373,7 @@ export function useVMwareSettingsPanelState() {
featureDisabled,
featureDisabledMessage,
form,
connectionFailure,
loadConnections,
loading,
loadingError,

View file

@ -21,4 +21,34 @@ describe('apiClient structured error extraction', () => {
expect(error.message).toBe('temporary failure');
expect(error.status).toBe(500);
});
it('preserves structured code and details from canonical API errors', async () => {
const error = await apiErrorFromResponse(
new Response(
JSON.stringify({
error: 'Failed to connect to VMware vCenter',
code: 'vmware_connection_failed',
details: {
category: 'unsupported_version',
error: 'VMware vCenter 6.7 is below the supported VI JSON release floor',
},
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
},
),
'Fallback message',
);
expect(error).toMatchObject({
message: 'Failed to connect to VMware vCenter',
status: 400,
code: 'vmware_connection_failed',
details: {
category: 'unsupported_version',
error: 'VMware vCenter 6.7 is below the supported VI JSON release floor',
},
});
});
});

View file

@ -183,4 +183,40 @@ describe('apiClient org context', () => {
const headers = options.headers as Record<string, string>;
expect(headers['X-Pulse-Org-ID']).toBe('tenant-reporting');
});
it('preserves canonical error code and details while retaining org context', async () => {
mockFetch.mockResolvedValue(
new Response(
JSON.stringify({
error: 'Failed to connect to VMware vCenter',
code: 'vmware_connection_failed',
details: {
category: 'unsupported_version',
error: 'VMware vCenter 6.7 is below the supported VI JSON release floor',
},
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
},
),
);
setOrgID('tenant-vmware');
await expect(
apiFetchJSON('/api/vmware/connections/test', { method: 'POST', body: '{}' }),
).rejects.toMatchObject({
message: 'Failed to connect to VMware vCenter',
status: 400,
code: 'vmware_connection_failed',
details: {
category: 'unsupported_version',
error: 'VMware vCenter 6.7 is below the supported VI JSON release floor',
},
});
const [, options] = mockFetch.mock.calls[0] as [string, RequestInit];
const headers = options.headers as Record<string, string>;
expect(headers['X-Pulse-Org-ID']).toBe('tenant-vmware');
});
});

View file

@ -12,6 +12,9 @@ const CONTROL_CHAR_PATTERN = /[\u0000-\u001F\u007F]/; // eslint-disable-line no-
const MAX_ORG_ID_LENGTH = 128;
const MAX_API_TOKEN_LENGTH = 8 * 1024;
const MAX_AUTH_STORAGE_CHARS = 16 * 1024;
const MAX_API_ERROR_CODE_LENGTH = 128;
const MAX_API_ERROR_DETAIL_KEY_LENGTH = 128;
const MAX_API_ERROR_DETAIL_VALUE_LENGTH = 2048;
const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);
const sanitizeBoundedText = (value: unknown, maxLength: number): string | null => {
@ -73,14 +76,36 @@ interface FetchOptions extends Omit<RequestInit, 'headers'> {
skipOrgContext?: boolean;
}
type APIErrorDetails = Record<string, string>;
type APIErrorShape = Error & {
code?: string;
detail?: string;
details?: APIErrorDetails;
feature?: string;
requiredScope?: string;
upgrade_url?: string;
status?: number;
};
const sanitizeAPIErrorDetails = (value: unknown): APIErrorDetails | undefined => {
if (!value || typeof value !== 'object') {
return undefined;
}
const details: APIErrorDetails = {};
for (const [rawKey, rawValue] of Object.entries(value as Record<string, unknown>)) {
const key = sanitizeBoundedText(rawKey, MAX_API_ERROR_DETAIL_KEY_LENGTH);
const resolvedValue = sanitizeBoundedText(rawValue, MAX_API_ERROR_DETAIL_VALUE_LENGTH);
if (!key || !resolvedValue) {
continue;
}
details[key] = resolvedValue;
}
return Object.keys(details).length > 0 ? details : undefined;
};
async function createAPIErrorFromResponse(
response: Response,
fallbackMessage?: string,
@ -88,7 +113,9 @@ async function createAPIErrorFromResponse(
const text = await response.text();
let errorMessage = fallbackMessage || text;
let errorCode: string | undefined;
let errorDetail: string | undefined;
let errorDetails: APIErrorDetails | undefined;
let errorFeature: string | undefined;
let errorRequiredScope: string | undefined;
let errorUpgradeUrl: string | undefined;
@ -102,6 +129,8 @@ async function createAPIErrorFromResponse(
if (typeof jsonError.detail === 'string') {
errorDetail = jsonError.detail;
}
errorCode = sanitizeBoundedText(jsonError.code, MAX_API_ERROR_CODE_LENGTH) ?? undefined;
errorDetails = sanitizeAPIErrorDetails(jsonError.details);
errorRequiredScope = sanitizeBoundedText(jsonError.requiredScope, 128) ?? undefined;
errorFeature = sanitizeBoundedText(jsonError.feature, 128) ?? undefined;
errorUpgradeUrl = sanitizeBoundedText(jsonError.upgrade_url, 2048) ?? undefined;
@ -120,9 +149,15 @@ async function createAPIErrorFromResponse(
const err = new Error(errorMessage || `Request failed with status ${response.status}`) as APIErrorShape;
err.status = response.status;
if (errorCode) {
err.code = errorCode;
}
if (errorDetail) {
err.detail = errorDetail;
}
if (errorDetails) {
err.details = errorDetails;
}
if (errorRequiredScope) {
err.requiredScope = errorRequiredScope;
}

View file

@ -208,6 +208,84 @@ test.describe("VMware platform connections settings", () => {
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
});
test("surfaces structured draft test guidance for unsupported vCenter versions", async ({
page,
}) => {
let createCalls = 0;
await page.route("**/api/vmware/connections**", async (route) => {
const request = route.request();
const method = request.method();
const pathname = new URL(request.url()).pathname;
if (pathname === "/api/vmware/connections" && method === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
return;
}
if (pathname === "/api/vmware/connections/test" && method === "POST") {
await route.fulfill({
status: 400,
contentType: "application/json",
body: JSON.stringify({
error: "Failed to connect to VMware vCenter",
code: "vmware_connection_failed",
status_code: 400,
details: {
category: "unsupported_version",
error:
"VMware vCenter 6.7 is below the supported VI JSON release floor",
},
}),
});
return;
}
if (pathname === "/api/vmware/connections" && method === "POST") {
createCalls += 1;
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({}),
});
return;
}
await route.continue();
});
await page.goto("/settings/infrastructure/platforms/vmware", {
waitUntil: "domcontentloaded",
});
await page.waitForURL(/\/settings\/infrastructure\/platforms\/vmware/, {
timeout: 15_000,
});
await page.getByRole("button", { name: "Add VMware connection" }).click();
await page.getByLabel("Host").fill("legacy.lab.local");
await page.getByLabel("Username").fill("administrator@vsphere.local");
await page.getByLabel("Password").fill("super-secret");
await page.getByRole("button", { name: "Test connection" }).click();
const feedback = page.getByTestId("vmware-connection-test-feedback");
await expect(feedback).toBeVisible();
await expect(feedback).toContainText("Unsupported vCenter version");
await expect(feedback).toContainText(
"VMware vCenter 6.7 is below the supported VI JSON release floor",
);
await expect(feedback).toContainText(
"Use a supported vCenter release within the current VI JSON phase-1 floor, then retry this connection test.",
);
await expect(page.getByRole("dialog")).toContainText(
"Configure the vCenter endpoint Pulse should validate for the VMware platform.",
);
expect(createCalls).toBe(0);
});
test("adds, edits, retests, and deletes VMware connections through the canonical settings workflow", async ({
page,
}) => {