mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-19 16:27:37 +00:00
Preserve VMware setup failure classifications
This commit is contained in:
parent
a3a3c9754b
commit
eff3dcd939
14 changed files with 475 additions and 637 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue