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:
Imri Paran 2024-04-10 17:00:00 +02:00 committed by GitHub
parent 79728cbffa
commit b2ce491ff1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 490 additions and 34 deletions

View File

@ -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"
}
]
}
]
}
]
}

View File

@ -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"
]
]
}
}
]
}

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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]]:
"""

View File

@ -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);
}
}
}

View File

@ -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

View File

@ -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 =

View File

@ -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());
}
}

View File

@ -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();
}

View File

@ -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()));

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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"