mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-12-27 15:38:43 +00:00
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
This commit is contained in:
parent
79728cbffa
commit
b2ce491ff1
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]]:
|
||||
"""
|
||||
|
||||
@ -2948,4 +2948,13 @@ public abstract class EntityRepository<T extends EntityInterface> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -876,15 +876,6 @@ public class TableRepository extends EntityRepository<Table> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<JoinedWith> joinedWithList) {
|
||||
for (JoinedWith joinedWith : joinedWithList) {
|
||||
// Validate table
|
||||
|
||||
@ -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<TestCase> {
|
||||
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<TestCase> {
|
||||
|
||||
// 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<TestCase> {
|
||||
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<Object> 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<TestCase> {
|
||||
}
|
||||
}
|
||||
|
||||
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<TagLabel> tags = daoCollection.tagUsageDAO().getTags(table.getFullyQualifiedName());
|
||||
table.setTags(tags);
|
||||
return maskSampleData(testCase.getFailedRowsSample(), table, table.getColumns());
|
||||
}
|
||||
return sampleData;
|
||||
}
|
||||
|
||||
private void putUpdateTestSuite(TestSuite testSuite, List<ResultSummary> resultSummaries) {
|
||||
// Update test case result summary attribute for the test suite
|
||||
TestSuiteRepository testSuiteRepository =
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -275,6 +275,12 @@ public class TestCaseResolutionStatusResource
|
||||
authorizer.authorize(securityContext, operationContext, resourceContext);
|
||||
RestUtil.PatchResponse<TestCaseResolutionStatus> response =
|
||||
repository.patch(id, patch, securityContext.getUserPrincipal().getName());
|
||||
if (response
|
||||
.entity()
|
||||
.getTestCaseResolutionStatusType()
|
||||
.equals(TestCaseResolutionStatusTypes.Resolved)) {
|
||||
repository.deleteTestCaseFailedSamples(response.entity());
|
||||
}
|
||||
return response.toResponse();
|
||||
}
|
||||
|
||||
|
||||
@ -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<TestCase, TestCaseRepositor
|
||||
authorizer.authorize(securityContext, operationContext, resourceContext);
|
||||
PatchResponse<TestCase> 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<TestCase, TestCaseRepositor
|
||||
OperationContext operationContext =
|
||||
new OperationContext(Entity.TABLE, MetadataOperation.EDIT_TESTS);
|
||||
authorizer.authorize(securityContext, operationContext, resourceContext);
|
||||
if (testCaseResult.getTestCaseStatus() == TestCaseStatus.Success) {
|
||||
TestCase testCase = repository.findByName(fqn, Include.ALL);
|
||||
repository.deleteTestCaseFailedRowsSample(testCase.getId());
|
||||
}
|
||||
return repository
|
||||
.addTestCaseResult(
|
||||
securityContext.getUserPrincipal().getName(), uriInfo, fqn, testCaseResult)
|
||||
@ -915,6 +926,71 @@ public class TestCaseResource extends EntityResource<TestCase, TestCaseRepositor
|
||||
.toResponse();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}/failedRowsSample")
|
||||
@Operation(
|
||||
operationId = "addFailedRowsSample",
|
||||
summary = "Add failed rows sample data",
|
||||
description = "Add a sample of failed rows for this test case.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Successfully update the test case with failed rows sample data.",
|
||||
content =
|
||||
@Content(
|
||||
mediaType = "application/json",
|
||||
schema = @Schema(implementation = TestCase.class))),
|
||||
@ApiResponse(
|
||||
responseCode = "400",
|
||||
description = "Failed rows can only be added to a failed test case.")
|
||||
})
|
||||
public TestCase addFailedRowsData(
|
||||
@Context UriInfo uriInfo,
|
||||
@Context SecurityContext securityContext,
|
||||
@Parameter(description = "Id of the test case", schema = @Schema(type = "UUID"))
|
||||
@PathParam("id")
|
||||
UUID id,
|
||||
@Valid TableData tableData) {
|
||||
OperationContext operationContext =
|
||||
new OperationContext(entityType, MetadataOperation.EDIT_SAMPLE_DATA);
|
||||
authorizer.authorize(securityContext, operationContext, getResourceContextById(id));
|
||||
TestCase testCase = repository.find(id, Include.NON_DELETED);
|
||||
if (testCase.getTestCaseResult() == null
|
||||
|| !testCase.getTestCaseResult().getTestCaseStatus().equals(TestCaseStatus.Failed)) {
|
||||
throw new IllegalArgumentException("Failed rows can only be added to a failed test case.");
|
||||
}
|
||||
return addHref(uriInfo, repository.addFailedRowsSample(testCase, tableData));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}/failedRowsSample")
|
||||
@Operation(
|
||||
operationId = "getFailedRowsSample",
|
||||
summary = "Get failed rows sample data",
|
||||
description = "Get a sample of failed rows for this test case.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Successfully retrieved the test case with failed rows sample data.",
|
||||
content =
|
||||
@Content(
|
||||
mediaType = "application/json",
|
||||
schema = @Schema(implementation = TableData.class)))
|
||||
})
|
||||
public TableData getFailedRowsData(
|
||||
@Context UriInfo uriInfo,
|
||||
@Context SecurityContext securityContext,
|
||||
@Parameter(description = "Id of the table", schema = @Schema(type = "UUID")) @PathParam("id")
|
||||
UUID id) {
|
||||
OperationContext operationContext =
|
||||
new OperationContext(entityType, MetadataOperation.VIEW_SAMPLE_DATA);
|
||||
ResourceContext<?> 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<TestCase, TestCaseRepositor
|
||||
.withDisplayName(create.getDisplayName())
|
||||
.withParameterValues(create.getParameterValues())
|
||||
.withEntityLink(create.getEntityLink())
|
||||
.withComputePassedFailedRowCount(create.getComputePassedFailedRowCount())
|
||||
.withEntityFQN(entityLink.getFullyQualifiedFieldValue())
|
||||
.withTestSuite(getEntityReference(Entity.TEST_SUITE, create.getTestSuite()))
|
||||
.withTestDefinition(getEntityReference(Entity.TEST_DEFINITION, create.getTestDefinition()));
|
||||
|
||||
@ -39,12 +39,10 @@ public class PIIMasker {
|
||||
/* Private constructor for Utility class */
|
||||
}
|
||||
|
||||
public static Table getSampleData(Table table) {
|
||||
TableData sampleData = table.getSampleData();
|
||||
|
||||
public static TableData maskSampleData(TableData sampleData, Table table, List<Column> columns) {
|
||||
// If we don't have sample data, there's nothing to do
|
||||
if (sampleData == null) {
|
||||
return table;
|
||||
return null;
|
||||
}
|
||||
|
||||
List<Integer> 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;
|
||||
}
|
||||
|
||||
@ -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<TestCase, CreateTes
|
||||
.withOwner(USER1_REF)
|
||||
.withColumns(
|
||||
List.of(
|
||||
new Column().withName(C1).withDisplayName("c1").withDataType(BIGINT),
|
||||
new Column()
|
||||
.withName(C1)
|
||||
.withDisplayName("c1")
|
||||
.withName(C2)
|
||||
.withDisplayName("c2")
|
||||
.withDataType(ColumnDataType.VARCHAR)
|
||||
.withDataLength(10)))
|
||||
.withDataLength(10),
|
||||
new Column().withName(C3).withDisplayName("c3").withDataType(BIGINT)))
|
||||
.withOwner(USER1_REF);
|
||||
TEST_TABLE1 = tableResourceTest.createAndCheckEntity(tableReq, ADMIN_AUTH_HEADERS);
|
||||
tableReq =
|
||||
@ -509,21 +518,7 @@ public class TestCaseResourceTest extends EntityResourceTest<TestCase, CreateTes
|
||||
void test_sensitivePIITestCase(TestInfo test) throws IOException {
|
||||
// First, create a table with PII Sensitive tag in a column
|
||||
TableResourceTest tableResourceTest = new TableResourceTest();
|
||||
CreateTable tableReq =
|
||||
tableResourceTest
|
||||
.createRequest(test)
|
||||
.withName("sensitiveTableTest")
|
||||
.withDatabaseSchema(DATABASE_SCHEMA.getFullyQualifiedName())
|
||||
.withOwner(USER1_REF)
|
||||
.withColumns(
|
||||
List.of(
|
||||
new Column()
|
||||
.withName(C1)
|
||||
.withDisplayName("c1")
|
||||
.withDataType(ColumnDataType.VARCHAR)
|
||||
.withDataLength(10)
|
||||
.withTags(List.of(PII_SENSITIVE_TAG_LABEL))))
|
||||
.withOwner(USER1_REF);
|
||||
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);
|
||||
@ -569,6 +564,22 @@ public class TestCaseResourceTest extends EntityResourceTest<TestCase, CreateTes
|
||||
assertEquals(0, maskedTestCases.getData().get(0).getParameterValues().size());
|
||||
}
|
||||
|
||||
private CreateTable getSensitiveTableReq(TestInfo test, TableResourceTest tableResourceTest) {
|
||||
return tableResourceTest
|
||||
.createRequest(test)
|
||||
.withName(test.getDisplayName() + "_sensitiveTableTest")
|
||||
.withDatabaseSchema(DATABASE_SCHEMA.getFullyQualifiedName())
|
||||
.withOwner(USER1_REF)
|
||||
.withColumns(
|
||||
List.of(
|
||||
new Column()
|
||||
.withName(C1)
|
||||
.withDisplayName("c1")
|
||||
.withDataType(ColumnDataType.VARCHAR)
|
||||
.withDataLength(10)
|
||||
.withTags(List.of(PII_SENSITIVE_TAG_LABEL))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
void put_testCase_list_200(TestInfo test) throws IOException {
|
||||
@ -2238,4 +2249,140 @@ public class TestCaseResourceTest extends EntityResourceTest<TestCase, CreateTes
|
||||
} while (before != null);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void put_failedRowSample_200(TestInfo test) throws IOException, ParseException {
|
||||
CreateTestCase create =
|
||||
createRequest(test)
|
||||
.withEntityLink(TABLE_LINK)
|
||||
.withTestSuite(TEST_SUITE1.getFullyQualifiedName())
|
||||
.withTestDefinition(TEST_DEFINITION3.getFullyQualifiedName())
|
||||
.withParameterValues(
|
||||
List.of(
|
||||
new TestCaseParameterValue().withValue("100").withName("missingCountValue")));
|
||||
TestCase testCase = createAndCheckEntity(create, ADMIN_AUTH_HEADERS);
|
||||
List<String> columns = Arrays.asList(C1, C2, C3);
|
||||
|
||||
// Add 3 rows of sample data for 3 columns
|
||||
List<List<Object>> 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<String> columns = List.of(C1);
|
||||
// Add 3 rows of sample data
|
||||
List<List<Object>> 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<String> columns,
|
||||
List<List<Object>> rows,
|
||||
Map<String, String> 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<String, String> authHeaders)
|
||||
throws HttpResponseException {
|
||||
WebTarget target = getResource(testCaseId).path("/failedRowsSample");
|
||||
return TestUtils.put(target, data, TestCase.class, OK, authHeaders);
|
||||
}
|
||||
|
||||
public TableData getSampleData(UUID testCaseId, Map<String, String> authHeaders)
|
||||
throws HttpResponseException {
|
||||
WebTarget target = getResource(testCaseId).path("/failedRowsSample");
|
||||
return TestUtils.get(target, TableData.class, authHeaders);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user