diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts index 52a8207cc8..e5a56764e3 100644 --- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts +++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts @@ -115,8 +115,98 @@ describe('FieldFormatEntityPolicy', () => { await expect(policy.enforce(data)).rejects.toThrow(/tags.*"Hello World"/i); }); - it('rejects bad link icon value', async () => { - data.metadata.links.push({ url: 'https://foo', icon: 'some icon' }); - await expect(policy.enforce(data)).rejects.toThrow(/links.*"some icon"/i); + it('accepts missing links', async () => { + delete data.metadata.links; + await expect(policy.enforce(data)).resolves.toBe(data); + }); + + it('accepts empty links array', async () => { + data.metadata.links = []; + await expect(policy.enforce(data)).resolves.toBe(data); + }); + + it('accepts multiple links', async () => { + data.metadata.links = [{ url: 'http://foo' }, { url: 'https://bar' }]; + await expect(policy.enforce(data)).resolves.toBe(data); + }); + + it('rejects missing link url value', async () => { + data.metadata.links = [{}]; + await expect(policy.enforce(data)).rejects.toThrow(/links.0/); + }); + + it('rejects a single bad missing url value', async () => { + data.metadata.links = [{ url: 'http://good' }, { url: '' }]; + await expect(policy.enforce(data)).rejects.toThrow(/links.1/); + }); + + it('rejects empty link url value', async () => { + data.metadata.links = [{ url: '' }]; + await expect(policy.enforce(data)).rejects.toThrow(/links.0/); + }); + + it('rejects bad link url value', async () => { + data.metadata.links = [{ url: 'invalid' }]; + await expect(policy.enforce(data)).rejects.toThrow(/links.0/); + }); + + it('accepts missing link title', async () => { + data.metadata.links = [{ url: 'http://foo', icon: 'dashboard' }]; + await expect(policy.enforce(data)).resolves.toBe(data); + }); + + it('rejects empty link title', async () => { + data.metadata.links = [{ url: 'http://foo', title: '' }]; + await expect(policy.enforce(data)).rejects.toThrow(/links.0/); + }); + + it.each([[123], [{}], [[]]])( + 'rejects bad link title %s', + async (title: unknown) => { + data.metadata.links = [{ url: 'http://foo', title }]; + await expect(policy.enforce(data)).rejects.toThrow(/links.0/); + }, + ); + + it('rejects a single bad link title', async () => { + data.metadata.links = [ + { url: 'http://foo', title: 'good' }, + { url: 'http://foo', title: '' }, + ]; + await expect(policy.enforce(data)).rejects.toThrow(/links.1/); + }); + + it('accepts missing link icon', async () => { + data.metadata.links = [{ url: 'http://foo', title: 'foo' }]; + await expect(policy.enforce(data)).resolves.toBe(data); + }); + + it('rejects empty link icon', async () => { + data.metadata.links = [{ url: 'http://foo', icon: '' }]; + await expect(policy.enforce(data)).rejects.toThrow(/links.0/); + }); + + it.each([['dashboard'], ['admin-dashboard'], ['foo_dashboard']])( + 'accepts valid link icon', + async icon => { + data.metadata.links = [{ url: 'http://foo', icon }]; + await expect(policy.enforce(data)).resolves.toBe(data); + }, + ); + + it.each([[123], [{}], [[]], ['abc xyz']])( + 'rejects bad link icon value %s', + async (icon: unknown) => { + data.metadata.links = [{ url: 'http://foo', icon }]; + await expect(policy.enforce(data)).rejects.toThrow(/links.0/); + }, + ); + + it('rejects a single bad link icon value', async () => { + data.metadata.links = [ + { url: 'http://foo', icon: 'good' }, + { url: 'http://foo', icon: 'not good' }, + ]; + await expect(policy.enforce(data)).rejects.toThrow(/links.1/); }); }); diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts index 09ccca6506..d854a3d514 100644 --- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts @@ -83,6 +83,12 @@ export class FieldFormatEntityPolicy implements EntityPolicy { expectation = 'a string that is a sequence of [a-zA-Z][a-z0-9A-Z], at most 63 characters in total'; break; + case 'isValidUrl': + expectation = 'a string that is a valid url'; + break; + case 'isValidString': + expectation = 'a non empty string'; + break; default: expectation = undefined; break; @@ -137,7 +143,9 @@ export class FieldFormatEntityPolicy implements EntityPolicy { const links = entity.metadata.links ?? []; for (let i = 0; i < links.length; ++i) { - optional(`links.${i}`, links[i]?.icon, this.validators.isValidEntityName); + require(`links.${i}`, links[i]?.url, this.validators.isValidLinkUrl); + optional(`links.${i}`, links[i]?.title, this.validators.isValidLinkTitle); + optional(`links.${i}`, links[i]?.icon, this.validators.isValidLinkIcon); } return entity; diff --git a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts index 23289d83aa..c84bdbdd38 100644 --- a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts +++ b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts @@ -214,7 +214,7 @@ describe('SchemaValidEntityPolicy', () => { it.each([['invalid type'], [123], [{}], [{ url: 'https://foo' }]])( 'rejects bad links type %s', - async (val: any) => { + async (val: unknown) => { data.metadata.links = val; await expect(policy.enforce(data)).rejects.toThrow(/links/); }, diff --git a/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts index 129fdd2b62..bef997d54a 100644 --- a/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts +++ b/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts @@ -161,4 +161,44 @@ describe('CommonValidatorFunctions', () => { ])(`isValidDnsLabel %p ? %p`, (value, result) => { expect(CommonValidatorFunctions.isValidDnsLabel(value)).toBe(result); }); + + it.each([ + [null, false], + [7, false], + ['', false], + ['abc', false], + [' abc', false], + ['', false], + [{}, false], + ['http://foo', true], + ['https://www.foo.com/', true], + ['https://foo.com:8080', true], + ['https://foo:8080/page', true], + ['https://foo:8080/sub/page', true], + ['https://foo:8080/sub/page?query', true], + ['https://foo:8080/sub/page/?query=value', true], + ['https://foo:8080/sub/page/?query=value&', true], + ['https://foo:8080/sub/page/?query=value&another=val', true], + ['https://foo.com/page#fragment', true], + ['ftp://ftp.some.domain.com/path', true], + ['xyz://custom-protocol:4444/path', true], + ])(`isValidUrl %p ? %p`, (value, result) => { + expect(CommonValidatorFunctions.isValidUrl(value)).toBe(result); + }); + + it.each([ + [null, false], + [true, false], + [7, false], + [{}, false], + ['', false], + [' ', false], + [' ', false], + ['abc', true], + [' abc ', true], + ['abc xyz', true], + ['abc xyz abc.', true], + ])(`isValidString %p ? %p`, (value, result) => { + expect(CommonValidatorFunctions.isValidString(value)).toBe(result); + }); }); diff --git a/packages/catalog-model/src/validation/CommonValidatorFunctions.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts index 90b4e1a611..a53a102c00 100644 --- a/packages/catalog-model/src/validation/CommonValidatorFunctions.ts +++ b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts @@ -92,4 +92,35 @@ export class CommonValidatorFunctions { /^[a-z0-9]+(\-[a-z0-9]+)*$/.test(value) ); } + + /** + * Checks that the value is a valid URL. + * + * @param value The value to check + */ + static isValidUrl(value: unknown): boolean { + if (typeof value !== 'string') { + return false; + } + + try { + const url = new URL(value); + return !!url; + } catch (_) { + return false; + } + } + + /** + * Checks that the value is a non empty string value. + * + * @param value The value to check + */ + static isValidString(value: unknown): boolean { + return ( + typeof value === 'string' && + value.length >= 1 && + /^(?!\s*$).+/.test(value) + ); + } } diff --git a/packages/catalog-model/src/validation/makeValidator.ts b/packages/catalog-model/src/validation/makeValidator.ts index 63341f5c33..e910b25f2a 100644 --- a/packages/catalog-model/src/validation/makeValidator.ts +++ b/packages/catalog-model/src/validation/makeValidator.ts @@ -28,6 +28,9 @@ const defaultValidators: Validators = { isValidAnnotationKey: KubernetesValidatorFunctions.isValidAnnotationKey, isValidAnnotationValue: KubernetesValidatorFunctions.isValidAnnotationValue, isValidTag: CommonValidatorFunctions.isValidDnsLabel, + isValidLinkUrl: CommonValidatorFunctions.isValidUrl, + isValidLinkTitle: CommonValidatorFunctions.isValidString, + isValidLinkIcon: KubernetesValidatorFunctions.isValidObjectName, }; export function makeValidator(overrides: Partial = {}): Validators { diff --git a/packages/catalog-model/src/validation/types.ts b/packages/catalog-model/src/validation/types.ts index a4475c4809..9c7c173ea3 100644 --- a/packages/catalog-model/src/validation/types.ts +++ b/packages/catalog-model/src/validation/types.ts @@ -24,4 +24,7 @@ export type Validators = { isValidAnnotationKey(value: unknown): boolean; isValidAnnotationValue(value: unknown): boolean; isValidTag(value: unknown): boolean; + isValidLinkUrl(value: unknown): boolean; + isValidLinkTitle(value: unknown): boolean; + isValidLinkIcon(value: unknown): boolean; };