Skip to content

Commit ab83310

Browse files
authored
[Automatic Import V2] Validations for integration and data stream names (#260748)
This PR adds validations for Integration and Data stream names. Adds more checks to see if the custom integrations names are currently used already.
1 parent 79c390d commit ab83310

10 files changed

Lines changed: 452 additions & 24 deletions

File tree

x-pack/platform/plugins/shared/automatic_import_v2/public/common/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,14 @@ export type {
5353
export { getLangSmithOptions } from './lib/lang_smith';
5454
export type { LangSmithOptions } from './lib/lang_smith';
5555

56-
export { generateId, normalizeTitleName } from './lib/helper_functions';
56+
export {
57+
generateId,
58+
normalizeTitleName,
59+
isValidNameFormat,
60+
isNotPurelyNumeric,
61+
startsWithLetter,
62+
meetsMinLength,
63+
meetsMaxLength,
64+
MIN_NAME_LENGTH,
65+
MAX_NAME_LENGTH,
66+
} from './lib/helper_functions';

x-pack/platform/plugins/shared/automatic_import_v2/public/common/lib/helper_functions.test.ts

Lines changed: 179 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,17 @@
55
* 2.0.
66
*/
77

8-
import { generateId } from './helper_functions';
8+
import {
9+
generateId,
10+
normalizeTitleName,
11+
isValidNameFormat,
12+
isNotPurelyNumeric,
13+
startsWithLetter,
14+
meetsMinLength,
15+
meetsMaxLength,
16+
MIN_NAME_LENGTH,
17+
MAX_NAME_LENGTH,
18+
} from './helper_functions';
919

1020
describe('helper_functions', () => {
1121
describe('generateId', () => {
@@ -36,4 +46,172 @@ describe('helper_functions', () => {
3646
expect(id).not.toContain('-');
3747
});
3848
});
49+
50+
describe('normalizeTitleName', () => {
51+
it('should convert to lowercase', () => {
52+
expect(normalizeTitleName('MyIntegration')).toBe('myintegration');
53+
});
54+
55+
it('should replace spaces with underscores', () => {
56+
expect(normalizeTitleName('My Integration')).toBe('my_integration');
57+
});
58+
59+
it('should collapse multiple spaces into single underscore', () => {
60+
expect(normalizeTitleName('My Integration')).toBe('my_integration');
61+
});
62+
63+
it('should collapse multiple underscores into single underscore', () => {
64+
expect(normalizeTitleName('My___Integration')).toBe('my_integration');
65+
});
66+
67+
it('should collapse mixed spaces and underscores into single underscore', () => {
68+
expect(normalizeTitleName('My _ Integration')).toBe('my_integration');
69+
});
70+
71+
it('should trim leading and trailing whitespace', () => {
72+
expect(normalizeTitleName(' My Integration ')).toBe('my_integration');
73+
});
74+
75+
it('should remove leading underscores', () => {
76+
expect(normalizeTitleName('_my_integration')).toBe('my_integration');
77+
expect(normalizeTitleName('__my_integration')).toBe('my_integration');
78+
});
79+
80+
it('should remove trailing underscores', () => {
81+
expect(normalizeTitleName('my_integration_')).toBe('my_integration');
82+
expect(normalizeTitleName('my_integration__')).toBe('my_integration');
83+
});
84+
85+
it('should handle trailing spaces that become underscores', () => {
86+
expect(normalizeTitleName('my integration ')).toBe('my_integration');
87+
expect(normalizeTitleName(' my integration ')).toBe('my_integration');
88+
});
89+
});
90+
91+
describe('isValidNameFormat', () => {
92+
it('should return true for empty string', () => {
93+
expect(isValidNameFormat('')).toBe(true);
94+
});
95+
96+
it('should return true for alphanumeric characters', () => {
97+
expect(isValidNameFormat('MyIntegration123')).toBe(true);
98+
});
99+
100+
it('should return true for names with spaces', () => {
101+
expect(isValidNameFormat('My Integration')).toBe(true);
102+
});
103+
104+
it('should return true for names with underscores', () => {
105+
expect(isValidNameFormat('My_Integration')).toBe(true);
106+
});
107+
108+
it('should return false for names with special characters', () => {
109+
expect(isValidNameFormat('My-Integration')).toBe(false);
110+
expect(isValidNameFormat('My@Integration')).toBe(false);
111+
expect(isValidNameFormat('My!Integration')).toBe(false);
112+
expect(isValidNameFormat('My.Integration')).toBe(false);
113+
});
114+
115+
it('should return false for names with unicode characters', () => {
116+
expect(isValidNameFormat('Integración')).toBe(false);
117+
expect(isValidNameFormat('インテグレーション')).toBe(false);
118+
});
119+
});
120+
121+
describe('isNotPurelyNumeric', () => {
122+
it('should return true for empty string', () => {
123+
expect(isNotPurelyNumeric('')).toBe(true);
124+
});
125+
126+
it('should return true for names with letters', () => {
127+
expect(isNotPurelyNumeric('Integration123')).toBe(true);
128+
expect(isNotPurelyNumeric('a')).toBe(true);
129+
});
130+
131+
it('should return true for names starting with numbers but containing letters', () => {
132+
expect(isNotPurelyNumeric('123Integration')).toBe(true);
133+
});
134+
135+
it('should return false for purely numeric names', () => {
136+
expect(isNotPurelyNumeric('123')).toBe(false);
137+
expect(isNotPurelyNumeric('1')).toBe(false);
138+
});
139+
140+
it('should return false for numeric names with spaces or underscores only', () => {
141+
expect(isNotPurelyNumeric('123 456')).toBe(false);
142+
expect(isNotPurelyNumeric('123_456')).toBe(false);
143+
});
144+
145+
it('should return true for names with letters and underscores/spaces', () => {
146+
expect(isNotPurelyNumeric('my_123')).toBe(true);
147+
expect(isNotPurelyNumeric('123 abc')).toBe(true);
148+
});
149+
});
150+
151+
describe('startsWithLetter', () => {
152+
it('should return true for empty string', () => {
153+
expect(startsWithLetter('')).toBe(true);
154+
});
155+
156+
it('should return true for names starting with a letter', () => {
157+
expect(startsWithLetter('MyIntegration')).toBe(true);
158+
expect(startsWithLetter('a')).toBe(true);
159+
expect(startsWithLetter('A123')).toBe(true);
160+
});
161+
162+
it('should return false for names starting with a number', () => {
163+
expect(startsWithLetter('123Integration')).toBe(false);
164+
expect(startsWithLetter('1abc')).toBe(false);
165+
});
166+
167+
it('should return false for names starting with underscore', () => {
168+
expect(startsWithLetter('_integration')).toBe(false);
169+
});
170+
171+
it('should return true for names with leading spaces (trimmed before check)', () => {
172+
// Leading spaces are trimmed, so ' integration' becomes 'integration' which starts with a letter
173+
expect(startsWithLetter(' integration')).toBe(true);
174+
});
175+
});
176+
177+
describe('meetsMinLength', () => {
178+
it('should return true for empty string', () => {
179+
expect(meetsMinLength('')).toBe(true);
180+
});
181+
182+
it('should return true for names with 2 or more characters', () => {
183+
expect(meetsMinLength('ab')).toBe(true);
184+
expect(meetsMinLength('abc')).toBe(true);
185+
expect(meetsMinLength('my integration')).toBe(true);
186+
});
187+
188+
it('should return false for single character names', () => {
189+
expect(meetsMinLength('a')).toBe(false);
190+
});
191+
});
192+
193+
describe('meetsMaxLength', () => {
194+
it('should return true for empty string', () => {
195+
expect(meetsMaxLength('')).toBe(true);
196+
});
197+
198+
it('should return true for names within limit', () => {
199+
expect(meetsMaxLength('ab')).toBe(true);
200+
expect(meetsMaxLength('a'.repeat(256))).toBe(true);
201+
});
202+
203+
it('should return false for names exceeding 256 characters', () => {
204+
expect(meetsMaxLength('a'.repeat(257))).toBe(false);
205+
});
206+
});
207+
208+
describe('constants', () => {
209+
it('should have correct MIN_NAME_LENGTH', () => {
210+
expect(MIN_NAME_LENGTH).toBe(2);
211+
});
212+
213+
it('should have correct MAX_NAME_LENGTH', () => {
214+
expect(MAX_NAME_LENGTH).toBe(256);
215+
});
216+
});
39217
});

x-pack/platform/plugins/shared/automatic_import_v2/public/common/lib/helper_functions.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,74 @@ export function generateId(): string {
1414
return uuidv4().replace(/-/g, '').slice(0, 12);
1515
}
1616

17+
// Validation constants matching elastic-package requirements
18+
export const MIN_NAME_LENGTH = 2;
19+
export const MAX_NAME_LENGTH = 256;
20+
21+
/**
22+
* Normalizes a title/name for use as an integration or data stream identifier.
23+
* - Trims leading/trailing whitespace
24+
* - Converts to lowercase
25+
* - Replaces spaces with underscores
26+
* - Collapses multiple consecutive underscores into one
27+
* - Removes leading/trailing underscores for compatibility
28+
*/
1729
export const normalizeTitleName = (value: string): string =>
18-
value.trim().toLowerCase().replace(/[ _]+/g, '_');
30+
value
31+
.trim()
32+
.toLowerCase()
33+
.replace(/[ _]+/g, '_')
34+
.replace(/^_+|_+$/g, '');
35+
36+
/**
37+
* Validates that a name contains only valid characters for Elastic integrations.
38+
* Allowed: lowercase letters, numbers, underscores, and spaces (spaces will be converted to underscores).
39+
* @returns true if valid, false otherwise
40+
*/
41+
export const isValidNameFormat = (value: string): boolean => {
42+
if (!value) return true;
43+
return /^[a-zA-Z0-9_ ]+$/.test(value.trim());
44+
};
45+
46+
/**
47+
* Validates that a name starts with a letter (required for integration names).
48+
* After normalization, names must start with a letter or underscore, but we encourage
49+
* starting with a letter for better compatibility.
50+
* @returns true if starts with a letter, false otherwise
51+
*/
52+
export const startsWithLetter = (value: string): boolean => {
53+
if (!value) return true;
54+
const trimmed = value.trim();
55+
return /^[a-zA-Z]/.test(trimmed);
56+
};
57+
58+
/**
59+
* Validates that a name is not purely numeric (after removing spaces/underscores).
60+
* Names must contain at least one letter.
61+
* @returns true if valid (contains at least one letter), false if purely numeric
62+
*/
63+
export const isNotPurelyNumeric = (value: string): boolean => {
64+
if (!value) return true;
65+
const alphanumericOnly = value.replace(/[_ ]/g, '');
66+
return /[a-zA-Z]/.test(alphanumericOnly);
67+
};
68+
69+
/**
70+
* Validates that a name meets the minimum length requirement (2 characters after normalization).
71+
* @returns true if valid, false otherwise
72+
*/
73+
export const meetsMinLength = (value: string): boolean => {
74+
if (!value) return true;
75+
const normalized = normalizeTitleName(value);
76+
return normalized.length >= MIN_NAME_LENGTH;
77+
};
78+
79+
/**
80+
* Validates that a name does not exceed the maximum length (256 characters).
81+
* @returns true if valid, false otherwise
82+
*/
83+
export const meetsMaxLength = (value: string): boolean => {
84+
if (!value) return true;
85+
const normalized = normalizeTitleName(value);
86+
return normalized.length <= MAX_NAME_LENGTH;
87+
};

x-pack/platform/plugins/shared/automatic_import_v2/public/components/integration_management/forms/integration_form.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ import {
1515
} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
1616
import { createFormSchema, REQUIRED_FIELDS } from './integration_form_validation';
1717
import type { IntegrationFormData } from './types';
18-
import { useKibana, getInstalledPackages } from '../../../common';
18+
import { useKibana, getInstalledPackages, getAllIntegrations } from '../../../common';
1919
import * as i18n from './translations';
2020
import { DEFAULT_DATA_STREAM_VALUES, DEFAULT_INTEGRATION_VALUES } from './constants';
21+
import { normalizeTitleName } from '../../../common/lib/helper_functions';
2122

2223
export interface IntegrationFormProviderProps {
2324
children?: React.ReactNode;
@@ -35,17 +36,35 @@ export const IntegrationFormProvider: React.FC<IntegrationFormProviderProps> = (
3536
const { http, notifications } = useKibana().services;
3637
const [packageNames, setPackageNames] = useState<Set<string>>();
3738

38-
// Load installed package names for duplicate title validation
39+
// Load installed package names and existing AIV2 integration IDs for duplicate title validation
3940
useEffect(() => {
4041
const abortController = new AbortController();
4142
const deps = { http, abortSignal: abortController.signal };
4243
(async () => {
4344
try {
44-
const packagesResponse = await getInstalledPackages(deps);
45+
const [packagesResponse, aiv2Integrations] = await Promise.all([
46+
getInstalledPackages(deps),
47+
getAllIntegrations(deps),
48+
]);
4549
if (abortController.signal.aborted) return;
50+
51+
const allNames = new Set<string>();
52+
53+
// Add installed package IDs
4654
if (packagesResponse?.items?.length) {
47-
setPackageNames(new Set(packagesResponse.items.map((pkg) => pkg.id)));
55+
packagesResponse.items.forEach((pkg) => allNames.add(pkg.id));
56+
}
57+
58+
// Add AIV2 integration IDs (normalized to match how new titles are converted)
59+
if (aiv2Integrations?.length) {
60+
aiv2Integrations.forEach((integration) => {
61+
// Add both the raw integrationId and the normalized title
62+
allNames.add(integration.integrationId);
63+
allNames.add(normalizeTitleName(integration.title));
64+
});
4865
}
66+
67+
setPackageNames(allNames);
4968
} catch (e) {
5069
if (!abortController.signal.aborted) {
5170
notifications?.toasts.addError(e, {

0 commit comments

Comments
 (0)