diff --git a/frontend/packages/console-shared/src/utils/__tests__/annotations.spec.ts b/frontend/packages/console-shared/src/utils/__tests__/annotations.spec.ts new file mode 100644 index 00000000000..4081580f993 --- /dev/null +++ b/frontend/packages/console-shared/src/utils/__tests__/annotations.spec.ts @@ -0,0 +1,83 @@ +import { parseJSONAnnotation, parseJSONArrayAnnotation } from '../annotations'; + +describe('parseJSONAnnotation', () => { + it('should parse valid JSON', () => { + const annotations = { key: '["a","b"]' }; + expect(parseJSONAnnotation(annotations, 'key')).toEqual(['a', 'b']); + }); + + it('should return defaultReturn when annotation is missing', () => { + expect(parseJSONAnnotation({}, 'missing', undefined, [])).toEqual([]); + }); + + it('should return defaultReturn on invalid JSON and call onError', () => { + const onError = jest.fn(); + expect(parseJSONAnnotation({ key: '{bad' }, 'key', onError, 'fallback')).toBe('fallback'); + expect(onError).toHaveBeenCalled(); + }); + + it('should return undefined when annotation is missing and no default', () => { + expect(parseJSONAnnotation({}, 'missing')).toBeUndefined(); + }); +}); + +describe('parseJSONArrayAnnotation', () => { + it('should return parsed array for valid JSON array of strings', () => { + const annotations = { key: '["a","b","c"]' }; + expect(parseJSONArrayAnnotation(annotations, 'key')).toEqual(['a', 'b', 'c']); + }); + + it('should return empty array when annotation is missing', () => { + expect(parseJSONArrayAnnotation({}, 'missing')).toEqual([]); + }); + + it('should return empty array when annotations is null', () => { + expect(parseJSONArrayAnnotation(null, 'key')).toEqual([]); + }); + + it('should return empty array for invalid JSON and call onError', () => { + const onError = jest.fn(); + expect(parseJSONArrayAnnotation({ key: '{bad json' }, 'key', onError)).toEqual([]); + expect(onError).toHaveBeenCalled(); + }); + + it('should return empty array when parsed value is a string, not an array', () => { + const onError = jest.fn(); + expect(parseJSONArrayAnnotation({ key: '"just a string"' }, 'key', onError)).toEqual([]); + expect(onError).toHaveBeenCalled(); + }); + + it('should return empty array when parsed value is an object, not an array', () => { + const onError = jest.fn(); + expect(parseJSONArrayAnnotation({ key: '{"a":"b"}' }, 'key', onError)).toEqual([]); + expect(onError).toHaveBeenCalled(); + }); + + it('should return empty array when parsed value is a number', () => { + const onError = jest.fn(); + expect(parseJSONArrayAnnotation({ key: '42' }, 'key', onError)).toEqual([]); + expect(onError).toHaveBeenCalled(); + }); + + it('should return empty array when array contains non-string elements', () => { + const onError = jest.fn(); + expect(parseJSONArrayAnnotation({ key: '[1, 2, 3]' }, 'key', onError)).toEqual([]); + expect(onError).toHaveBeenCalled(); + }); + + it('should return empty array for mixed array', () => { + const onError = jest.fn(); + expect(parseJSONArrayAnnotation({ key: '["a", 1, "b"]' }, 'key', onError)).toEqual([]); + expect(onError).toHaveBeenCalled(); + }); + + it('should return empty array for empty string annotation', () => { + expect(parseJSONArrayAnnotation({ key: '' }, 'key')).toEqual([]); + }); + + it('should not call onError when annotation is simply missing', () => { + const onError = jest.fn(); + parseJSONArrayAnnotation({}, 'missing', onError); + expect(onError).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/packages/console-shared/src/utils/annotations.ts b/frontend/packages/console-shared/src/utils/annotations.ts index 019b22221b5..da09ee984ed 100644 --- a/frontend/packages/console-shared/src/utils/annotations.ts +++ b/frontend/packages/console-shared/src/utils/annotations.ts @@ -15,3 +15,27 @@ export const parseJSONAnnotation = ( return defaultReturn; } }; + +/** + * Safely parse a JSON annotation that is expected to contain an array of strings. + * Returns an empty array if the annotation is missing, malformed, or not an array of strings. + */ +export const parseJSONArrayAnnotation = ( + annotations: ObjectMetadata['annotations'], + annotationKey: string, + onError?: (err: Error) => void, +): string[] => { + const parsed = parseJSONAnnotation(annotations, annotationKey, onError); + if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== 'string')) { + if (parsed != null) { + const error = new Error( + `Expected annotation "${annotationKey}" to be an array of strings, got ${typeof parsed}`, + ); + onError?.(error); + // eslint-disable-next-line no-console + console.warn(error.message); + } + return []; + } + return parsed; +}; diff --git a/frontend/packages/operator-lifecycle-manager/src/components/index.tsx b/frontend/packages/operator-lifecycle-manager/src/components/index.tsx index ff6808b4487..272f62b69ce 100644 --- a/frontend/packages/operator-lifecycle-manager/src/components/index.tsx +++ b/frontend/packages/operator-lifecycle-manager/src/components/index.tsx @@ -78,7 +78,7 @@ export const defaultChannelFor = (packageManifest: PackageManifestKind) => { export const defaultChannelNameFor = (pkg: PackageManifestKind) => pkg.status.defaultChannel || pkg?.status?.channels?.[0]?.name; export const installModesFor = (pkg: PackageManifestKind) => (channel: string) => - pkg.status.channels.find((ch) => ch.name === channel)?.currentCSVDesc?.installModes || []; + pkg?.status?.channels?.find((ch) => ch.name === channel)?.currentCSVDesc?.installModes ?? []; export const supportedInstallModesFor = (pkg: PackageManifestKind) => (channel: string) => installModesFor(pkg)(channel).filter(({ supported }) => supported); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/operator-hub/operator-hub-page.tsx b/frontend/packages/operator-lifecycle-manager/src/components/operator-hub/operator-hub-page.tsx index 5a52ccea0ce..9b7c835718b 100644 --- a/frontend/packages/operator-lifecycle-manager/src/components/operator-hub/operator-hub-page.tsx +++ b/frontend/packages/operator-lifecycle-manager/src/components/operator-hub/operator-hub-page.tsx @@ -23,7 +23,7 @@ import { import { fromRequirements } from '@console/internal/module/k8s/selector'; import { isCatalogTypeEnabled, useIsDeveloperCatalogEnabled } from '@console/shared'; import { ErrorBoundaryFallbackPage, withFallback } from '@console/shared/src/components/error'; -import { parseJSONAnnotation } from '@console/shared/src/utils/annotations'; +import { parseJSONArrayAnnotation } from '@console/shared/src/utils/annotations'; import { iconFor } from '..'; import { CloudCredentialModel, @@ -119,10 +119,10 @@ export const OperatorHubList: React.FC = ({ currentCSVDesc?.annotations ?? {}; const [parsedInfraFeatures, validSubscription] = ANNOTATIONS_WITH_JSON.map( (annotationKey) => - parseJSONAnnotation(currentCSVAnnotations, annotationKey, () => + parseJSONArrayAnnotation(currentCSVAnnotations, annotationKey, () => // eslint-disable-next-line no-console console.warn(`Error parsing annotation in PackageManifest ${pkg.metadata.name}`), - ) ?? [], + ), ); const validSubscriptionFilters = validSubscription.reduce( diff --git a/frontend/packages/operator-lifecycle-manager/src/utils.ts b/frontend/packages/operator-lifecycle-manager/src/utils.ts index 41dbe4b5dd5..e810d8bb901 100644 --- a/frontend/packages/operator-lifecycle-manager/src/utils.ts +++ b/frontend/packages/operator-lifecycle-manager/src/utils.ts @@ -1,14 +1,14 @@ import { ObjectMetadata } from '@console/internal/module/k8s'; -import { parseJSONAnnotation } from '@console/shared/src/utils/annotations'; +import { parseJSONArrayAnnotation } from '@console/shared/src/utils/annotations'; import { INTERNAL_OBJECTS_ANNOTATION, OPERATOR_PLUGINS_ANNOTATION } from './const'; import { SubscriptionKind, SubscriptionState } from './types'; export const getClusterServiceVersionPlugins = ( annotations: ObjectMetadata['annotations'], -): string[] => parseJSONAnnotation(annotations, OPERATOR_PLUGINS_ANNOTATION) ?? []; +): string[] => parseJSONArrayAnnotation(annotations, OPERATOR_PLUGINS_ANNOTATION); export const getInternalObjects = (annotations: ObjectMetadata['annotations']): string[] => - parseJSONAnnotation(annotations, INTERNAL_OBJECTS_ANNOTATION) ?? []; + parseJSONArrayAnnotation(annotations, INTERNAL_OBJECTS_ANNOTATION); export const isCatalogSourceTrusted = (catalogSource: string): boolean => catalogSource === 'redhat-operators';