diff --git a/docs/release-control/v6/internal/VMWARE_VCENTER_PHASE1_API_RUNTIME_SPEC.md b/docs/release-control/v6/internal/VMWARE_VCENTER_PHASE1_API_RUNTIME_SPEC.md index 441cad1f8..b9cc18f95 100644 --- a/docs/release-control/v6/internal/VMWARE_VCENTER_PHASE1_API_RUNTIME_SPEC.md +++ b/docs/release-control/v6/internal/VMWARE_VCENTER_PHASE1_API_RUNTIME_SPEC.md @@ -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 diff --git a/docs/release-control/v6/internal/VMWARE_VCENTER_PHASE1_ONBOARDING_SPEC.md b/docs/release-control/v6/internal/VMWARE_VCENTER_PHASE1_ONBOARDING_SPEC.md index 935fafa06..c9cf5fba4 100644 --- a/docs/release-control/v6/internal/VMWARE_VCENTER_PHASE1_ONBOARDING_SPEC.md +++ b/docs/release-control/v6/internal/VMWARE_VCENTER_PHASE1_ONBOARDING_SPEC.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 7853290d3..662717164 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 2d4a32328..b795684fc 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -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=`, `PULSE_MULTI_TENANT_ENABLED=true`, and an explicit diff --git a/frontend-modern/src/api/__tests__/responseUtils.test.ts b/frontend-modern/src/api/__tests__/responseUtils.test.ts index 20bc4512c..76bc23903 100644 --- a/frontend-modern/src/api/__tests__/responseUtils.test.ts +++ b/frontend-modern/src/api/__tests__/responseUtils.test.ts @@ -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(['a', 'b'])).toEqual(['a', 'b']); - }); - - it('returns empty array for non-array values', () => { - expect(arrayOrEmpty(null)).toEqual([]); - expect(arrayOrEmpty({ items: ['a'] })).toEqual([]); - }); -}); - -describe('arrayOrUndefined', () => { - it('returns arrays unchanged', () => { - expect(arrayOrUndefined(['a', 'b'])).toEqual(['a', 'b']); - }); - - it('returns undefined for non-array values', () => { - expect(arrayOrUndefined(null)).toBeUndefined(); - expect(arrayOrUndefined({ items: ['a'] })).toBeUndefined(); - }); -}); - -describe('objectArrayFieldOrEmpty', () => { - it('returns object array fields unchanged', () => { - expect(objectArrayFieldOrEmpty({ items: ['a', 'b'] }, 'items')).toEqual(['a', 'b']); - }); - - it('returns empty array for missing or invalid object fields', () => { - expect(objectArrayFieldOrEmpty(null, 'items')).toEqual([]); - expect(objectArrayFieldOrEmpty({ 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', + ); }); }); diff --git a/frontend-modern/src/api/responseUtils.ts b/frontend-modern/src/api/responseUtils.ts index 265446fa1..2e3e7b8d1 100644 --- a/frontend-modern/src/api/responseUtils.ts +++ b/frontend-modern/src/api/responseUtils.ts @@ -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; +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 | 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; } diff --git a/frontend-modern/src/components/Settings/VMwareSettingsPanel.tsx b/frontend-modern/src/components/Settings/VMwareSettingsPanel.tsx index eda034521..f01bf01d5 100644 --- a/frontend-modern/src/components/Settings/VMwareSettingsPanel.tsx +++ b/frontend-modern/src/components/Settings/VMwareSettingsPanel.tsx @@ -373,6 +373,25 @@ export const VMwareSettingsPanel: Component = (props)

+ + {(failure) => ( + +

{failure().message}

+ +

{failure().guidance}

+
+ + } + icon={} + /> + )} +
+