From b2ce491ff1c2e473222b006a9e1c1915ed537e87 Mon Sep 17 00:00:00 2001 From: Imri Paran Date: Wed, 10 Apr 2024 17:00:00 +0200 Subject: [PATCH] MINOR: Add failed rows sample to test case (#15682) * add failed sample data * format * fixed masking pii data in test failed rows sample * format * failedRowsSamples -> failedRowsSample * failedRowsSamples -> failedRowsSample * fixed tests * format * wip * added computePassedFailedRowCount to python client * comment for loggerLevel * format * fixed tests * tests for putting / deleting failed samples * format * format * added test case for pii test * changed method name to deleteTestCaseFailedRowsSample * added getComputePassedFailedRowCount --- .../sample_data/tests/testCaseResults.json | 19 ++ .../sample_data/tests/testSuites.json | 77 ++++++++ ingestion/pipelines/sample_data.yaml | 1 + .../processor/test_case_runner.py | 1 + .../ingestion/ometa/mixins/tests_mixin.py | 41 +++- .../ingestion/source/database/sample_data.py | 12 ++ .../service/jdbi3/EntityRepository.java | 9 + .../service/jdbi3/TableRepository.java | 9 - .../service/jdbi3/TestCaseRepository.java | 64 ++++++ .../TestCaseResolutionStatusRepository.java | 6 + .../TestCaseResolutionStatusResource.java | 6 + .../resources/dqtests/TestCaseResource.java | 77 ++++++++ .../service/security/mask/PIIMasker.java | 15 +- .../dqtests/TestCaseResourceTest.java | 183 ++++++++++++++++-- .../resources/json/schema/tests/testCase.json | 4 + 15 files changed, 490 insertions(+), 34 deletions(-) diff --git a/ingestion/examples/sample_data/tests/testCaseResults.json b/ingestion/examples/sample_data/tests/testCaseResults.json index e72754d795a..06b3d29d8a0 100644 --- a/ingestion/examples/sample_data/tests/testCaseResults.json +++ b/ingestion/examples/sample_data/tests/testCaseResults.json @@ -576,6 +576,25 @@ ] } ] + }, + { + "name": "zip.column_values_to_be_between_with_sample_rows", + "results": [ + { + "result": "Found min=1001, max=2789 vs. the expected min=90001, max=96162.", + "testCaseStatus": "Failed", + "testResultValues": [ + { + "name": "min", + "value": "1001" + }, + { + "name": "max", + "value": "2789" + } + ] + } + ] } ] } \ No newline at end of file diff --git a/ingestion/examples/sample_data/tests/testSuites.json b/ingestion/examples/sample_data/tests/testSuites.json index cf241c8e698..cfd2e6d7b8c 100644 --- a/ingestion/examples/sample_data/tests/testSuites.json +++ b/ingestion/examples/sample_data/tests/testSuites.json @@ -323,6 +323,83 @@ } ] } + }, + { + "name": "column_values_to_be_between_with_sample_rows", + "description": "example of failing test with sample rows", + "entityLink": "<#E::table::sample_data.ecommerce_db.shopify.dim_address::columns::zip>", + "testDefinitionName": "columnValuesToBeBetween", + "parameterValues": [ + { + "name": "min", + "value": 90001 + }, + { + "name": "max", + "value": 96162 + } + ], + "resolutions": {}, + "sampleFailedRows": { + "columns": [ + "address_id", + "shop_id", + "first_name", + "last_name", + "address1", + "address2", + "company", + "city", + "region", + "zip", + "country", + "phone" + ], + "rows": [ + [ + "bc35100e-2da5-48bb-bfc8-667dafe66532", + "70424951-bc97-4b20-9ce7-be37c4619361", + "Zachary", + "Brett", + "9054 Maria Circle Apt. 296", + "48348 Victoria Valleys Suite 144", + "Robinson Inc", + "Stephanieport", + "048 Moore Turnpike Apt. 061", + "1001", + "Latvia", + "(381)575-6692" + ], + [ + "facf92d7-05ea-43d2-ba2a-067d63dee60c", + "a8d30187-1409-4606-9259-322a4f6caf74", + "Amber", + "Albert", + "3170 Warren Orchard Apt. 834", + "3204 Brewer Shoal Suite 324", + "Davila-Snyder", + "Nicoleland", + "023 Paul Course", + "1002", + "Sweden", + "438-959-1151" + ], + [ + "bab9a506-e23d-4c53-9402-d070e7704376", + "e02e1fac-b650-4db8-8c9d-5fa5edf5d863", + "Heidi", + "Kelly", + "30942 Gonzalez Stravenue", + "3158 Watts Green", + "Moore PLC", + "West Erica", + "6294 Elliott Ville", + "2789", + "Saint Martin", + "(830)112-9566x8681" + ] + ] + } } ] } diff --git a/ingestion/pipelines/sample_data.yaml b/ingestion/pipelines/sample_data.yaml index b925f2f5ca7..169576ce2d7 100644 --- a/ingestion/pipelines/sample_data.yaml +++ b/ingestion/pipelines/sample_data.yaml @@ -13,6 +13,7 @@ sink: type: metadata-rest config: {} workflowConfig: +# loggerLevel: DEBUG # DEBUG, INFO, WARN or ERROR openMetadataServerConfig: hostPort: http://localhost:8585/api authProvider: openmetadata diff --git a/ingestion/src/metadata/data_quality/processor/test_case_runner.py b/ingestion/src/metadata/data_quality/processor/test_case_runner.py index 3f0db049dfc..6ad3219fd2f 100644 --- a/ingestion/src/metadata/data_quality/processor/test_case_runner.py +++ b/ingestion/src/metadata/data_quality/processor/test_case_runner.py @@ -210,6 +210,7 @@ class TestCaseRunner(Processor): if test_case_to_create.parameterValues else None, owner=None, + computePassedFailedRowCount=test_case_to_create.computePassedFailedRowCount, ) ) test_cases.append(test_case) diff --git a/ingestion/src/metadata/ingestion/ometa/mixins/tests_mixin.py b/ingestion/src/metadata/ingestion/ometa/mixins/tests_mixin.py index 5e771fafc99..a250eb4d0fb 100644 --- a/ingestion/src/metadata/ingestion/ometa/mixins/tests_mixin.py +++ b/ingestion/src/metadata/ingestion/ometa/mixins/tests_mixin.py @@ -14,6 +14,7 @@ Mixin class containing Tests specific methods To be used by OpenMetadata class """ +import traceback from datetime import datetime, timezone from typing import List, Optional, Type, Union from urllib.parse import quote @@ -30,7 +31,7 @@ from metadata.generated.schema.api.tests.createTestDefinition import ( CreateTestDefinitionRequest, ) from metadata.generated.schema.api.tests.createTestSuite import CreateTestSuiteRequest -from metadata.generated.schema.entity.data.table import Table +from metadata.generated.schema.entity.data.table import Table, TableData from metadata.generated.schema.tests.basic import TestCaseResult from metadata.generated.schema.tests.testCase import TestCase, TestCaseParameterValue from metadata.generated.schema.tests.testCaseResolutionStatus import ( @@ -323,3 +324,41 @@ class OMetaTestsMixin: response = self.client.post(path, data=data.json(encoder=show_secrets_encoder)) return TestCaseResolutionStatus(**response) + + def ingest_failed_rows_sample( + self, test_case: TestCase, failed_rows: TableData + ) -> Optional[TableData]: + """ + PUT sample failed data for a test case. + + :param test_case: The test case that failed + :param failed_rows: Data to add + """ + resp = None + try: + resp = self.client.put( + f"{self.get_suffix(TestCase)}/{test_case.id.__root__}/failedRowsSample", + data=failed_rows.json(), + ) + except Exception as exc: + logger.debug(traceback.format_exc()) + logger.warning( + f"Error trying to PUT sample data for {test_case.fullyQualifiedName.__root__}: {exc}" + ) + + if resp: + try: + return TableData(**resp["failedRowsSamples"]) + except UnicodeError as err: + logger.debug(traceback.format_exc()) + logger.warning( + f"Unicode Error parsing the sample data response from {test_case.fullyQualifiedName.__root__}: " + f"{err}" + ) + except Exception as exc: + logger.debug(traceback.format_exc()) + logger.warning( + f"Error trying to parse sample data results from {test_case.fullyQualifiedName.__root__}: {exc}" + ) + + return None diff --git a/ingestion/src/metadata/ingestion/source/database/sample_data.py b/ingestion/src/metadata/ingestion/source/database/sample_data.py index be6b9d1eb51..29b5e255a88 100644 --- a/ingestion/src/metadata/ingestion/source/database/sample_data.py +++ b/ingestion/src/metadata/ingestion/source/database/sample_data.py @@ -1410,6 +1410,18 @@ class SampleDataSource( ) # type: ignore ) yield Either(right=test_case_req) + if test_case.get("sampleFailedRows"): + test_case_entity = self.metadata.get_or_create_test_case( + test_case_fqn=f"{entity_link.get_table_or_column_fqn(test_case['entityLink'])}.{test_case['name']}", + ) + + self.metadata.ingest_failed_rows_sample( + test_case_entity, + TableData( + rows=test_case["sampleFailedRows"]["rows"], + columns=test_case["sampleFailedRows"]["columns"], + ), + ) def ingest_incidents(self) -> Iterable[Either[OMetaTestCaseResolutionStatus]]: """ diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java index f76e48ed94c..7b49fb9e4a6 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java @@ -2948,4 +2948,13 @@ public abstract class EntityRepository { return aboutEntity; } } + + // Validate if a given column exists in the table + public static void validateColumn(Table table, String columnName) { + boolean validColumn = + table.getColumns().stream().anyMatch(col -> col.getName().equals(columnName)); + if (!validColumn) { + throw new IllegalArgumentException("Invalid column name " + columnName); + } + } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TableRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TableRepository.java index 95f9d3af3ca..454460a6ac9 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TableRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TableRepository.java @@ -876,15 +876,6 @@ public class TableRepository extends EntityRepository { } } - // Validate if a given column exists in the table - public static void validateColumn(Table table, String columnName) { - boolean validColumn = - table.getColumns().stream().anyMatch(col -> col.getName().equals(columnName)); - if (!validColumn) { - throw new IllegalArgumentException("Invalid column name " + columnName); - } - } - private void validateColumnFQNs(List joinedWithList) { for (JoinedWith joinedWith : joinedWithList) { // Validate table diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseRepository.java index ecb06bde4d7..854715a5c6d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseRepository.java @@ -13,6 +13,9 @@ import static org.openmetadata.service.Entity.TEST_DEFINITION; import static org.openmetadata.service.Entity.TEST_SUITE; import static org.openmetadata.service.Entity.getEntityByName; import static org.openmetadata.service.Entity.getEntityReferenceByName; +import static org.openmetadata.service.Entity.populateEntityFieldTags; +import static org.openmetadata.service.exception.CatalogExceptionMessage.entityNotFound; +import static org.openmetadata.service.security.mask.PIIMasker.maskSampleData; import com.google.common.collect.ImmutableSet; import java.util.ArrayList; @@ -48,6 +51,8 @@ import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.FieldChange; import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.Relationship; +import org.openmetadata.schema.type.TableData; +import org.openmetadata.schema.type.TagLabel; import org.openmetadata.schema.type.TaskType; import org.openmetadata.schema.utils.EntityInterfaceUtil; import org.openmetadata.service.Entity; @@ -70,6 +75,7 @@ public class TestCaseRepository extends EntityRepository { private static final String PATCH_FIELDS = "owner,entityLink,testSuite,testDefinition,computePassedFailedRowCount"; public static final String TESTCASE_RESULT_EXTENSION = "testCase.testCaseResult"; + public static final String FAILED_ROWS_SAMPLE_EXTENSION = "testCase.failedRowsSample"; public TestCaseRepository() { super( @@ -266,6 +272,8 @@ public class TestCaseRepository extends EntityRepository { // If we delete the test case, we need to clean up the resolution ts daoCollection.testCaseResolutionStatusTimeSeriesDao().delete(test.getFullyQualifiedName()); + + deleteTestCaseFailedRowsSample(test.getId()); } @Override @@ -753,6 +761,38 @@ public class TestCaseRepository extends EntityRepository { return super.getTaskWorkflow(threadContext); } + @Transaction + public TestCase addFailedRowsSample(TestCase testCase, TableData tableData) { + EntityLink entityLink = EntityLink.parse(testCase.getEntityLink()); + Table table = Entity.getEntity(entityLink, "owner", ALL); + // Validate all the columns + for (String columnName : tableData.getColumns()) { + validateColumn(table, columnName); + } + // Make sure each row has number values for all the columns + for (List row : tableData.getRows()) { + if (row.size() != tableData.getColumns().size()) { + throw new IllegalArgumentException( + String.format( + "Number of columns is %d but row has %d sample values", + tableData.getColumns().size(), row.size())); + } + } + daoCollection + .entityExtensionDAO() + .insert( + testCase.getId(), + FAILED_ROWS_SAMPLE_EXTENSION, + "failedRowsSample", + JsonUtils.pojoToJson(tableData)); + setFieldsInternal(testCase, Fields.EMPTY_FIELDS); + return testCase.withFailedRowsSample(tableData); + } + + public void deleteTestCaseFailedRowsSample(UUID id) { + daoCollection.entityExtensionDAO().delete(id, FAILED_ROWS_SAMPLE_EXTENSION); + } + public static class TestCaseFailureResolutionTaskWorkflow extends FeedRepository.TaskWorkflow { final TestCaseResolutionStatusRepository testCaseResolutionStatusRepository; final CollectionDAO.DataQualityDataTimeSeriesDAO dataQualityDataTimeSeriesDao; @@ -908,6 +948,30 @@ public class TestCaseRepository extends EntityRepository { } } + public TableData getSampleData(TestCase testCase, boolean authorizePII) { + Table table = Entity.getEntity(EntityLink.parse(testCase.getEntityLink()), "owner", ALL); + // Validate the request content + TableData sampleData = + JsonUtils.readValue( + daoCollection + .entityExtensionDAO() + .getExtension(testCase.getId(), FAILED_ROWS_SAMPLE_EXTENSION), + TableData.class); + if (sampleData == null) { + throw new EntityNotFoundException( + entityNotFound(FAILED_ROWS_SAMPLE_EXTENSION, testCase.getId())); + } + // Set the column tags. Will be used to mask the sample data + if (!authorizePII) { + populateEntityFieldTags( + Entity.TABLE, table.getColumns(), table.getFullyQualifiedName(), true); + List tags = daoCollection.tagUsageDAO().getTags(table.getFullyQualifiedName()); + table.setTags(tags); + return maskSampleData(testCase.getFailedRowsSample(), table, table.getColumns()); + } + return sampleData; + } + private void putUpdateTestSuite(TestSuite testSuite, List resultSummaries) { // Update test case result summary attribute for the test suite TestSuiteRepository testSuiteRepository = diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResolutionStatusRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResolutionStatusRepository.java index 6eafdd0bf39..30a6ad5f9b7 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResolutionStatusRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResolutionStatusRepository.java @@ -330,4 +330,10 @@ public class TestCaseResolutionStatusRepository Severity severity = incidentSeverityClassifier.classifyIncidentSeverity(entity); incident.setSeverity(severity); } + + public void deleteTestCaseFailedSamples(TestCaseResolutionStatus entity) { + TestCaseRepository testCaseRepository = + (TestCaseRepository) Entity.getEntityRepository(Entity.TEST_CASE); + testCaseRepository.deleteTestCaseFailedRowsSample(entity.getTestCaseReference().getId()); + } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResolutionStatusResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResolutionStatusResource.java index d55c92f618e..3ffc4493712 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResolutionStatusResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResolutionStatusResource.java @@ -275,6 +275,12 @@ public class TestCaseResolutionStatusResource authorizer.authorize(securityContext, operationContext, resourceContext); RestUtil.PatchResponse response = repository.patch(id, patch, securityContext.getUserPrincipal().getName()); + if (response + .entity() + .getTestCaseResolutionStatusType() + .equals(TestCaseResolutionStatusTypes.Resolved)) { + repository.deleteTestCaseFailedSamples(response.entity()); + } return response.toResponse(); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResource.java index 3f1fd66df50..261c52424aa 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResource.java @@ -42,9 +42,11 @@ import org.openmetadata.schema.api.tests.CreateTestCase; import org.openmetadata.schema.tests.TestCase; import org.openmetadata.schema.tests.TestSuite; import org.openmetadata.schema.tests.type.TestCaseResult; +import org.openmetadata.schema.tests.type.TestCaseStatus; import org.openmetadata.schema.type.EntityHistory; import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.MetadataOperation; +import org.openmetadata.schema.type.TableData; import org.openmetadata.service.Entity; import org.openmetadata.service.jdbi3.Filter; import org.openmetadata.service.jdbi3.ListFilter; @@ -57,6 +59,7 @@ import org.openmetadata.service.search.SearchSortFilter; import org.openmetadata.service.security.Authorizer; import org.openmetadata.service.security.mask.PIIMasker; import org.openmetadata.service.security.policyevaluator.OperationContext; +import org.openmetadata.service.security.policyevaluator.ResourceContext; import org.openmetadata.service.security.policyevaluator.ResourceContextInterface; import org.openmetadata.service.security.policyevaluator.TestCaseResourceContext; import org.openmetadata.service.util.EntityUtil.Fields; @@ -626,6 +629,10 @@ public class TestCaseResource extends EntityResource response = repository.patch(uriInfo, id, securityContext.getUserPrincipal().getName(), patch); + if (response.entity().getTestCaseResult() != null + && response.entity().getTestCaseResult().getTestCaseStatus() == TestCaseStatus.Success) { + repository.deleteTestCaseFailedRowsSample(id); + } addHref(uriInfo, response.entity()); return response.toResponse(); } @@ -834,6 +841,10 @@ public class TestCaseResource extends EntityResource resourceContext = getResourceContextById(id); + TestCase testCase = repository.find(id, Include.NON_DELETED); + authorizer.authorize(securityContext, operationContext, resourceContext); + boolean authorizePII = authorizer.authorizePII(securityContext, resourceContext.getOwner()); + return repository.getSampleData(testCase, authorizePII); + } + @PUT @Path("/logicalTestCases") @Operation( @@ -983,6 +1059,7 @@ public class TestCaseResource extends EntityResource columns) { // If we don't have sample data, there's nothing to do if (sampleData == null) { - return table; + return null; } List columnsPositionToBeMasked; @@ -52,11 +50,11 @@ public class PIIMasker { // If the table itself is marked as PII, mask all the sample data if (hasPiiSensitiveTag(table)) { columnsPositionToBeMasked = - IntStream.range(0, table.getColumns().size()).boxed().collect(Collectors.toList()); + IntStream.range(0, columns.size()).boxed().collect(Collectors.toList()); } else { // Otherwise, mask only the PII columns columnsPositionToBeMasked = - table.getColumns().stream() + columns.stream() .collect( Collectors.toMap( Function.identity(), c -> sampleData.getColumns().indexOf(c.getName()))) @@ -80,6 +78,11 @@ public class PIIMasker { position -> sampleDataColumns.set(position, flagMaskedName(sampleDataColumns.get(position)))); + return sampleData; + } + + public static Table getSampleData(Table table) { + TableData sampleData = maskSampleData(table.getSampleData(), table, table.getColumns()); table.setSampleData(sampleData); return table; } diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/dqtests/TestCaseResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/dqtests/TestCaseResourceTest.java index 21fda04c416..ce66e97acc5 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/dqtests/TestCaseResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/dqtests/TestCaseResourceTest.java @@ -6,16 +6,20 @@ import static javax.ws.rs.core.Response.Status.FORBIDDEN; import static javax.ws.rs.core.Response.Status.NOT_FOUND; import static javax.ws.rs.core.Response.Status.OK; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.openmetadata.common.utils.CommonUtil.listOf; +import static org.openmetadata.schema.type.ColumnDataType.BIGINT; import static org.openmetadata.schema.type.MetadataOperation.EDIT_TESTS; import static org.openmetadata.service.Entity.ADMIN_USER_NAME; import static org.openmetadata.service.exception.CatalogExceptionMessage.permissionNotAllowed; +import static org.openmetadata.service.jdbi3.TestCaseRepository.FAILED_ROWS_SAMPLE_EXTENSION; import static org.openmetadata.service.security.SecurityUtil.authHeaders; import static org.openmetadata.service.security.SecurityUtil.getPrincipalName; +import static org.openmetadata.service.security.mask.PIIMasker.MASKED_VALUE; import static org.openmetadata.service.util.EntityUtil.fieldUpdated; import static org.openmetadata.service.util.TestUtils.ADMIN_AUTH_HEADERS; import static org.openmetadata.service.util.TestUtils.TEST_AUTH_HEADERS; @@ -29,6 +33,7 @@ import static org.openmetadata.service.util.TestUtils.assertListNull; import static org.openmetadata.service.util.TestUtils.assertResponse; import static org.openmetadata.service.util.TestUtils.assertResponseContains; import static org.openmetadata.service.util.TestUtils.dateToTimestamp; +import static org.openmetadata.service.util.TestUtils.patch; import java.io.IOException; import java.text.ParseException; @@ -70,6 +75,8 @@ import org.openmetadata.schema.tests.type.TestSummary; import org.openmetadata.schema.type.ChangeDescription; import org.openmetadata.schema.type.Column; import org.openmetadata.schema.type.ColumnDataType; +import org.openmetadata.schema.type.MetadataOperation; +import org.openmetadata.schema.type.TableData; import org.openmetadata.schema.type.TagLabel; import org.openmetadata.schema.type.TaskStatus; import org.openmetadata.service.Entity; @@ -111,11 +118,13 @@ public class TestCaseResourceTest extends EntityResourceTest", sensitiveTable.getFullyQualifiedName(), C1); @@ -569,6 +564,22 @@ public class TestCaseResourceTest extends EntityResourceTest columns = Arrays.asList(C1, C2, C3); + + // Add 3 rows of sample data for 3 columns + List> rows = + Arrays.asList( + Arrays.asList("c1Value1", 1, true), + Arrays.asList("c1Value2", null, false), + Arrays.asList("c1Value3", 3, true)); + + // Cannot set failed sample for a non-failing test case + assertResponse( + () -> putSampleData(testCase, columns, rows, ADMIN_AUTH_HEADERS), + BAD_REQUEST, + "Failed rows can only be added to a failed test case."); + + // Add failed test case, which will create a NEW incident + putTestCaseResult( + testCase.getFullyQualifiedName(), + new TestCaseResult() + .withResult("result") + .withTestCaseStatus(TestCaseStatus.Failed) + .withTimestamp(TestUtils.dateToTimestamp("2024-01-01")), + ADMIN_AUTH_HEADERS); + // Sample data can be put as an ADMIN + putSampleData(testCase, columns, rows, ADMIN_AUTH_HEADERS); + + // Sample data can be put as owner + rows.get(0).set(1, 2); // Change value 1 to 2 + putSampleData(testCase, columns, rows, authHeaders(USER1.getName())); + + // Sample data can't be put as non-owner, non-admin + assertResponse( + () -> putSampleData(testCase, columns, rows, authHeaders(USER2.getName())), + FORBIDDEN, + permissionNotAllowed(USER2.getName(), List.of(MetadataOperation.EDIT_SAMPLE_DATA))); + + // resolving test case deletes the sample data + TestCaseResult testCaseResult = + new TestCaseResult() + .withResult("tested") + .withTestCaseStatus(TestCaseStatus.Success) + .withTimestamp(TestUtils.dateToTimestamp("2021-09-09")); + putTestCaseResult(testCase.getFullyQualifiedName(), testCaseResult, ADMIN_AUTH_HEADERS); + assertResponse( + () -> getSampleData(testCase.getId(), ADMIN_AUTH_HEADERS), + NOT_FOUND, + FAILED_ROWS_SAMPLE_EXTENSION + " instance for " + testCase.getId() + " not found"); + } + + @Test + void test_sensitivePIISampleData(TestInfo test) throws IOException, ParseException { + // Create table with owner and a column tagged with PII.Sensitive + TableResourceTest tableResourceTest = new TableResourceTest(); + CreateTable tableReq = getSensitiveTableReq(test, tableResourceTest); + Table sensitiveTable = tableResourceTest.createAndCheckEntity(tableReq, ADMIN_AUTH_HEADERS); + String sensitiveColumnLink = + String.format("<#E::table::%s::columns::%s>", sensitiveTable.getFullyQualifiedName(), C1); + CreateTestCase create = + createRequest(test) + .withEntityLink(sensitiveColumnLink) + .withTestSuite(TEST_SUITE1.getFullyQualifiedName()) + .withTestDefinition(TEST_DEFINITION3.getFullyQualifiedName()) + .withParameterValues( + List.of( + new TestCaseParameterValue().withValue("100").withName("missingCountValue"))); + TestCase testCase = createAndCheckEntity(create, ADMIN_AUTH_HEADERS); + putTestCaseResult( + testCase.getFullyQualifiedName(), + new TestCaseResult() + .withResult("result") + .withTestCaseStatus(TestCaseStatus.Failed) + .withTimestamp(TestUtils.dateToTimestamp("2024-01-01")), + ADMIN_AUTH_HEADERS); + List columns = List.of(C1); + // Add 3 rows of sample data + List> rows = + Arrays.asList(List.of("c1Value1"), List.of("c1Value2"), List.of("c1Value3")); + // add sample data + putSampleData(testCase, columns, rows, ADMIN_AUTH_HEADERS); + // assert values are not masked for the table owner + TableData data = getSampleData(testCase.getId(), authHeaders(USER1.getName())); + assertFalse( + data.getRows().stream() + .flatMap(List::stream) + .map(r -> r == null ? "" : r) + .map(Object::toString) + .anyMatch(MASKED_VALUE::equals)); + // assert values are masked when is not the table owner + data = getSampleData(testCase.getId(), authHeaders(USER2.getName())); + assertEquals( + 3, + data.getRows().stream() + .flatMap(List::stream) + .map(r -> r == null ? "" : r) + .map(Object::toString) + .filter(MASKED_VALUE::equals) + .count()); + } + + private void putSampleData( + TestCase testCase, + List columns, + List> rows, + Map authHeaders) + throws IOException { + TableData tableData = new TableData().withColumns(columns).withRows(rows); + TestCase putResponse = putSampleData(testCase.getId(), tableData, authHeaders); + assertEquals(tableData, putResponse.getFailedRowsSample()); + + TableData data = getSampleData(testCase.getId(), ADMIN_AUTH_HEADERS); + assertEquals(tableData, data); + } + + public TestCase putSampleData(UUID testCaseId, TableData data, Map authHeaders) + throws HttpResponseException { + WebTarget target = getResource(testCaseId).path("/failedRowsSample"); + return TestUtils.put(target, data, TestCase.class, OK, authHeaders); + } + + public TableData getSampleData(UUID testCaseId, Map authHeaders) + throws HttpResponseException { + WebTarget target = getResource(testCaseId).path("/failedRowsSample"); + return TestUtils.get(target, TableData.class, authHeaders); + } } diff --git a/openmetadata-spec/src/main/resources/json/schema/tests/testCase.json b/openmetadata-spec/src/main/resources/json/schema/tests/testCase.json index 6cd01967226..7c08dc10b70 100644 --- a/openmetadata-spec/src/main/resources/json/schema/tests/testCase.json +++ b/openmetadata-spec/src/main/resources/json/schema/tests/testCase.json @@ -111,6 +111,10 @@ "description": "Reference to an ongoing Incident ID (stateId) for this test case.", "$ref": "../type/basic.json#/definitions/uuid" }, + "failedRowsSample": { + "description": "Sample of failed rows for this test case.", + "$ref": "../entity/data/table.json#/definitions/tableData" + }, "domain" : { "description": "Domain the test case belongs to. When not set, the test case inherits the domain from the table it belongs to.", "$ref": "../type/entityReference.json"