diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/bootstrap/TestSuiteBootstrap.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/bootstrap/TestSuiteBootstrap.java index e5507bee3ad0..63cc226de6a4 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/bootstrap/TestSuiteBootstrap.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/bootstrap/TestSuiteBootstrap.java @@ -28,8 +28,11 @@ import jakarta.validation.Validator; import java.io.IOException; import java.lang.reflect.Field; +import java.nio.file.Path; +import java.nio.file.Paths; import java.time.Duration; import java.util.Map; +import java.util.TimeZone; import java.util.concurrent.atomic.AtomicBoolean; import org.apache.hc.client5.http.auth.AuthScope; import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; @@ -162,6 +165,9 @@ public void launcherSessionOpened(LauncherSession session) { cacheProvider = System.getProperty("cacheProvider", "none"); LOG.info("=== TestSuiteBootstrap: Starting test infrastructure ==="); + System.setProperty("user.timezone", "UTC"); + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + LOG.info("Test JVM timezone set to {}", TimeZone.getDefault().getID()); LOG.info("Database type: {}", databaseType); LOG.info("Search type: {}", searchType); LOG.info("RDF enabled: {}", rdfEnabled); @@ -505,8 +511,10 @@ private void startApplication() throws Exception { config.setDataSourceFactory(dataSourceFactory); String projectRoot = System.getProperty("user.dir"); - if (projectRoot.endsWith("openmetadata-integration-tests")) { - projectRoot = projectRoot.substring(0, projectRoot.lastIndexOf("/")); + Path projectRootPath = Paths.get(projectRoot); + if (projectRootPath.endsWith("openmetadata-integration-tests") + && projectRootPath.getParent() != null) { + projectRoot = projectRootPath.getParent().toString(); } String flyWayMigrationScriptsLocation = projectRoot + "/bootstrap/sql/migrations/flyway/" + DATABASE_CONTAINER.getDriverClassName(); diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/PipelineResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/PipelineResourceIT.java index 0e713ea15ad9..7e6443b47f48 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/PipelineResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/PipelineResourceIT.java @@ -213,6 +213,36 @@ void post_pipelineWithTasks_200_OK(TestNamespace ns) { assertEquals(2, pipeline.getTasks().size()); } + @Test + void post_pipelineWithInvalidTaskName_4xx(TestNamespace ns) { + PipelineService service = PipelineServiceTestFactory.createAirflow(ns); + + CreatePipeline request = new CreatePipeline(); + request.setName(ns.prefix("pipeline_invalid_task")); + request.setService(service.getFullyQualifiedName()); + request.setTasks(List.of(new Task().withName("task createEntity(request), + "Creating pipeline with invalid task name should fail"); + } + + @Test + void post_pipelineWithEmptyTaskName_4xx(TestNamespace ns) { + PipelineService service = PipelineServiceTestFactory.createAirflow(ns); + + CreatePipeline request = new CreatePipeline(); + request.setName(ns.prefix("pipeline_empty_task_name")); + request.setService(service.getFullyQualifiedName()); + request.setTasks(List.of(new Task().withName(""))); + + assertThrows( + Exception.class, + () -> createEntity(request), + "Creating pipeline with empty task name should fail"); + } + @Test void post_pipelineWithSourceUrl_200_OK(TestNamespace ns) { OpenMetadataClient client = SdkClients.adminClient(); diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/SearchIndexResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/SearchIndexResourceIT.java index 5903ff1fad82..4ac53f45b216 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/SearchIndexResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/SearchIndexResourceIT.java @@ -234,6 +234,25 @@ void post_searchIndexWithFields_200_OK(TestNamespace ns) { assertEquals(2, searchIndex.getFields().size()); } + @Test + void post_searchIndexWithInvalidFieldName_4xx(TestNamespace ns) { + SearchService service = SearchServiceTestFactory.createElasticSearch(ns); + + CreateSearchIndex request = new CreateSearchIndex(); + request.setName(ns.prefix("searchindex_invalid_field")); + request.setService(service.getFullyQualifiedName()); + request.setFields( + List.of( + new SearchIndexField() + .withName("title createEntity(request), + "Creating search index with invalid field name should fail"); + } + @Test void put_searchIndexFields_200_OK(TestNamespace ns) { OpenMetadataClient client = SdkClients.adminClient(); diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TableResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TableResourceIT.java index 294bd3926f94..4da0d410be7a 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TableResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TableResourceIT.java @@ -220,6 +220,38 @@ protected Table createEntity(CreateTable createRequest) { return SdkClients.adminClient().tables().create(createRequest); } + @Test + void post_tableWithInvalidConstraintOrPartitionColumnName_4xx(TestNamespace ns) { + CreateTable invalidConstraintRequest = createMinimalRequest(ns); + invalidConstraintRequest.setName(ns.prefix("table_invalid_constraint_column")); + invalidConstraintRequest.setTableConstraints( + List.of( + new TableConstraint() + .withConstraintType(TableConstraint.ConstraintType.UNIQUE) + .withColumns(List.of("name createEntity(invalidConstraintRequest), + "Creating table with invalid constraint column name should fail"); + + CreateTable invalidPartitionRequest = createMinimalRequest(ns); + invalidPartitionRequest.setName(ns.prefix("table_invalid_partition_column")); + invalidPartitionRequest.setTablePartition( + new TablePartition() + .withColumns( + List.of( + new PartitionColumnDetails() + .withColumnName("name|invalid") + .withIntervalType(PartitionIntervalTypes.COLUMN_VALUE) + .withInterval("daily")))); + + assertThrows( + Exception.class, + () -> createEntity(invalidPartitionRequest), + "Creating table with invalid partition column name should fail"); + } + @Override protected Table getEntity(String id) { return SdkClients.adminClient().tables().get(id); diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TopicResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TopicResourceIT.java index b4c1b8ee13e4..d3f6b7780fe9 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TopicResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TopicResourceIT.java @@ -247,6 +247,28 @@ void post_topicWithMessageSchema_200_OK(TestNamespace ns) { assertNotNull(topic.getMessageSchema().getSchemaFields()); } + @Test + void post_topicWithInvalidSchemaFieldName_4xx(TestNamespace ns) { + MessagingService service = MessagingServiceTestFactory.createKafka(ns); + + MessageSchema schema = + new MessageSchema() + .withSchemaType(SchemaType.JSON) + .withSchemaFields( + List.of(new Field().withName("field|invalid").withDataType(FieldDataType.STRING))); + + CreateTopic request = new CreateTopic(); + request.setName(ns.prefix("topic_invalid_schema_field")); + request.setService(service.getFullyQualifiedName()); + request.setPartitions(1); + request.setMessageSchema(schema); + + assertThrows( + Exception.class, + () -> createEntity(request), + "Creating topic with invalid schema field name should fail"); + } + @Test void post_topicWithCleanupPolicies_200_OK(TestNamespace ns) { OpenMetadataClient client = SdkClients.adminClient(); diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/data/pipeline.json b/openmetadata-spec/src/main/resources/json/schema/entity/data/pipeline.json index 67106e460959..44bd6a02a27c 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/data/pipeline.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/data/pipeline.json @@ -107,7 +107,9 @@ "properties": { "name": { "description": "Name that identifies this task instance uniquely.", - "type": "string" + "type": "string", + "minLength": 1, + "pattern": "^((?!::)[^><\"|\\x00-\\x1f])*$" }, "displayName": { "description": "Display Name that identifies this Task. It could be title or label from the pipeline services.", diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/data/searchIndex.json b/openmetadata-spec/src/main/resources/json/schema/entity/data/searchIndex.json index d35d2e74224c..6ebcd116b538 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/data/searchIndex.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/data/searchIndex.json @@ -97,7 +97,7 @@ "type": "string", "minLength": 1, "maxLength": 256, - "pattern": "^((?!::).)*$" + "pattern": "^((?!::)[^><\"|\\x00-\\x1f])*$" }, "searchIndexField": { "type": "object", diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/data/table.json b/openmetadata-spec/src/main/resources/json/schema/entity/data/table.json index c39ee7b56ba4..c6754c0b3cf7 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/data/table.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/data/table.json @@ -208,7 +208,8 @@ "description": "List of column names corresponding to the constraint.", "type": "array", "items": { - "type": "string" + "type": "string", + "pattern": "^((?!::)[^><\"|\\x00-\\x1f])*$" } }, "referredColumns": { @@ -235,7 +236,7 @@ "description": "Local name (not fully qualified name) of the column. ColumnName is `-` when the column is not named in struct dataType. For example, BigQuery supports struct with unnamed fields.", "type": "string", "minLength": 1, - "pattern": "^((?!::).)*$" + "pattern": "^((?!::)[^><\"|\\x00-\\x1f])*$" }, "partitionIntervalTypes": { "javaType": "org.openmetadata.schema.type.PartitionIntervalTypes", @@ -273,7 +274,8 @@ "properties": { "columnName": { "description": "List of column names corresponding to the partition.", - "type": "string" + "type": "string", + "pattern": "^((?!::)[^><\"|\\x00-\\x1f])*$" }, "intervalType": { "$ref": "#/definitions/partitionIntervalTypes" diff --git a/openmetadata-spec/src/main/resources/json/schema/type/basic.json b/openmetadata-spec/src/main/resources/json/schema/type/basic.json index 37b6e0aa8f8f..22996b882adb 100644 --- a/openmetadata-spec/src/main/resources/json/schema/type/basic.json +++ b/openmetadata-spec/src/main/resources/json/schema/type/basic.json @@ -124,13 +124,13 @@ "type": "string", "minLength": 1, "maxLength": 256, - "pattern": "^((?!::).)*$" + "pattern": "^((?!::)[^><\"|\\x00-\\x1f])*$" }, "testCaseEntityName": { "description": "Name that identifies a test definition and test case.", "type": "string", "minLength": 1, - "pattern": "^((?!::).)*$" + "pattern": "^((?!::)[^><\"|\\x00-\\x1f])*$" }, "fullyQualifiedEntityName": { "description": "A unique name that identifies an entity. Example for table 'DatabaseService.Database.Schema.Table'.", diff --git a/openmetadata-spec/src/main/resources/json/schema/type/schema.json b/openmetadata-spec/src/main/resources/json/schema/type/schema.json index 6d67da26a9e7..06fde346fbe6 100644 --- a/openmetadata-spec/src/main/resources/json/schema/type/schema.json +++ b/openmetadata-spec/src/main/resources/json/schema/type/schema.json @@ -66,7 +66,8 @@ "description": "Local name (not fully qualified name) of the field. ", "type": "string", "minLength": 1, - "maxLength": 128 + "maxLength": 128, + "pattern": "^((?!::)[^><\"|\\x00-\\x1f])*$" }, "field": { "type": "object", @@ -139,4 +140,4 @@ "default": [] } } -} \ No newline at end of file +} diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/PipelineValidation.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/PipelineValidation.spec.ts new file mode 100644 index 000000000000..970f96c12dc8 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/PipelineValidation.spec.ts @@ -0,0 +1,88 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, test, type APIRequestContext } from '@playwright/test'; +import { getDefaultAdminAPIContext, uuid } from '../../utils/common'; + +test.use({ storageState: 'playwright/.auth/admin.json' }); + +test.describe('Pipeline entity name validation', () => { + let apiContext: APIRequestContext; + let afterAction: () => Promise; + let serviceFqn = ''; + + test.beforeAll(async ({ browser }) => { + const response = await getDefaultAdminAPIContext(browser); + apiContext = response.apiContext; + afterAction = response.afterAction; + + const serviceName = `pw-pipeline-validation-service-${uuid()}`; + const serviceResponse = await apiContext.post( + '/api/v1/services/pipelineServices', + { + data: { + name: serviceName, + serviceType: 'Dagster', + connection: { + config: { + type: 'Dagster', + host: 'admin', + token: 'admin', + timeout: '1000', + supportsMetadataExtraction: true, + }, + }, + }, + } + ); + + expect(serviceResponse.status()).toBe(201); + const serviceData = await serviceResponse.json(); + serviceFqn = serviceData.fullyQualifiedName; + }); + + test.afterAll(async () => { + if (serviceFqn) { + await apiContext.delete( + `/api/v1/services/pipelineServices/name/${encodeURIComponent( + serviceFqn + )}?recursive=true&hardDelete=true` + ); + } + await afterAction(); + }); + + test('should reject pipeline creation when task name is empty', async () => { + const response = await apiContext.post('/api/v1/pipelines', { + data: { + name: `pw-pipeline-empty-task-${uuid()}`, + service: serviceFqn, + tasks: [{ name: '' }], + }, + }); + + expect(response.status()).toBe(400); + }); + + test('should reject pipeline creation when task name contains reserved FQN characters', async () => { + const response = await apiContext.post('/api/v1/pipelines', { + data: { + name: `pw-pipeline-invalid-task-${uuid()}`, + service: serviceFqn, + tasks: [{ name: 'task { }); it('EntityName regex should fail for the invalid entity name', () => { - // conatines :: in the name should fail + // contains reserved FQN characters - should fail + expect(ENTITY_NAME_REGEX.test('name>bad')).toEqual(false); + expect(ENTITY_NAME_REGEX.test('name { + expect(ENTITY_NAME_REGEX.test('name:value')).toEqual(true); + expect(ENTITY_NAME_REGEX.test('a:b:c:d')).toEqual(true); + }); + describe('TAG_NAME_REGEX', () => { it('should match English letters', () => { expect(TAG_NAME_REGEX.test('Hello')).toEqual(true); diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/regex.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/regex.constants.ts index be9ee2e167f7..cc3b64dcf819 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/regex.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/regex.constants.ts @@ -17,10 +17,11 @@ export const UrlEntityCharRegEx = /[#.%;?/\\]/g; export const EMAIL_REG_EX = /^\S+@\S+\.\S+$/; /** - * strings that contain a combination of letters, alphanumeric characters, hyphens, - * spaces, periods, single quotes, ampersands, and parentheses, with support for Unicode characters. + * Validates entity names. Blocks reserved FQN separator characters (::, >, <, ", |) + * and ASCII control characters. Supports Unicode characters. */ -export const ENTITY_NAME_REGEX = /^((?!::).)*$/; +// eslint-disable-next-line no-control-regex +export const ENTITY_NAME_REGEX = /^(?!.*::)[^><"|\u0000-\u001f]*$/; /** * Matches any string that does NOT contain the following: