diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 61e2cc9e1..e28dc6fe2 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -2,7 +2,7 @@ name: Bug report about: Create a report to help us improve title: "[BUG]" -labels: 'bug, untriaged, Beta' +labels: 'bug, untriaged' assignees: '' --- diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 81432d759..7f15c5fc6 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -29,28 +29,28 @@ jobs: - name: Assemble anomaly-detection run: | - ./gradlew assemble -Dopensearch.version=1.2.0-SNAPSHOT - echo "Creating ./src/test/resources/org/opensearch/ad/bwc/anomaly-detection/1.2.0.0-SNAPSHOT ..." - mkdir -p ./src/test/resources/org/opensearch/ad/bwc/anomaly-detection/1.2.0.0-SNAPSHOT - echo "Copying ./build/distributions/*.zip to ./src/test/resources/org/opensearch/ad/bwc/anomaly-detection/1.2.0.0-SNAPSHOT ..." + ./gradlew assemble -Dopensearch.version=1.3.0-SNAPSHOT + echo "Creating ./src/test/resources/org/opensearch/ad/bwc/anomaly-detection/1.3.0.0-SNAPSHOT ..." + mkdir -p ./src/test/resources/org/opensearch/ad/bwc/anomaly-detection/1.3.0.0-SNAPSHOT + echo "Copying ./build/distributions/*.zip to ./src/test/resources/org/opensearch/ad/bwc/anomaly-detection/1.3.0.0-SNAPSHOT ..." ls ./build/distributions/ - cp ./build/distributions/*.zip ./src/test/resources/org/opensearch/ad/bwc/anomaly-detection/1.2.0.0-SNAPSHOT - echo "Copied ./build/distributions/*.zip to ./src/test/resources/org/opensearch/ad/bwc/anomaly-detection/1.2.0.0-SNAPSHOT ..." - ls ./src/test/resources/org/opensearch/ad/bwc/anomaly-detection/1.2.0.0-SNAPSHOT + cp ./build/distributions/*.zip ./src/test/resources/org/opensearch/ad/bwc/anomaly-detection/1.3.0.0-SNAPSHOT + echo "Copied ./build/distributions/*.zip to ./src/test/resources/org/opensearch/ad/bwc/anomaly-detection/1.3.0.0-SNAPSHOT ..." + ls ./src/test/resources/org/opensearch/ad/bwc/anomaly-detection/1.3.0.0-SNAPSHOT - name: Build and Run Tests run: | - ./gradlew build -Dopensearch.version=1.2.0-SNAPSHOT + ./gradlew build -Dopensearch.version=1.3.0-SNAPSHOT - name: Publish to Maven Local run: | - ./gradlew publishToMavenLocal -Dopensearch.version=1.2.0-SNAPSHOT + ./gradlew publishToMavenLocal -Dopensearch.version=1.3.0-SNAPSHOT - name: Multi Nodes Integration Testing run: | ./gradlew integTest -PnumNodes=3 - name: Pull and Run Docker run: | plugin=`ls build/distributions/*.zip` - version=1.2.0-SNAPSHOT - plugin_version=1.2.0.0-SNAPSHOT + version=1.3.0-SNAPSHOT + plugin_version=1.3.0.0-SNAPSHOT echo Using OpenSearch $version with AD $plugin_version cd .. if docker pull opensearchstaging/opensearch:$version diff --git a/.github/workflows/link-check-workflow.yml b/.github/workflows/link-check-workflow.yml index 5e025cf2c..2fd67552c 100644 --- a/.github/workflows/link-check-workflow.yml +++ b/.github/workflows/link-check-workflow.yml @@ -15,7 +15,7 @@ jobs: id: lychee uses: lycheeverse/lychee-action@master with: - args: --accept=200,403,429 --exclude=localhost "**/*.html" "**/*.md" "**/*.txt" "**/*.json" + args: --accept=200,403,429 --exclude=localhost **/*.html **/*.md **/*.txt **/*.json env: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - name: Fail if there were link errors diff --git a/.gitignore b/.gitignore index 0ac89afe9..7ed43c682 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,3 @@ out/ .classpath .vscode bin/ -src/test/resources/job-scheduler \ No newline at end of file diff --git a/.whitesource b/.whitesource new file mode 100644 index 000000000..db4b0fec8 --- /dev/null +++ b/.whitesource @@ -0,0 +1,15 @@ +{ + "scanSettings": { + "configMode": "AUTO", + "configExternalURL": "", + "projectToken": "", + "baseBranches": [] + }, + "checkRunSettings": { + "vulnerableCheckRunConclusionLevel": "failure", + "displayMode": "diff" + }, + "issueSettings": { + "minSeverityLevel": "LOW" + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index d578547e4..03542f0d2 100644 --- a/build.gradle +++ b/build.gradle @@ -16,7 +16,7 @@ import org.opensearch.gradle.testclusters.StandaloneRestIntegTestTask buildscript { ext { opensearch_group = "org.opensearch" - opensearch_version = System.getProperty("opensearch.version", "1.2.0-SNAPSHOT") + opensearch_version = System.getProperty("opensearch.version", "1.3.0-SNAPSHOT") // 1.2.0 -> 1.2.0.0, and 1.2.0-SNAPSHOT -> 1.2.0.0-SNAPSHOT opensearch_build = opensearch_version.replaceAll(/(\.\d)([^\d]*)$/, '$1.0$2') common_utils_version = System.getProperty("common_utils.version", opensearch_build) @@ -112,7 +112,7 @@ configurations.all { force "org.objenesis:objenesis:3.0.1" force "net.bytebuddy:byte-buddy:1.9.15" force "net.bytebuddy:byte-buddy-agent:1.9.15" - force "com.google.code.gson:gson:2.8.6" + force "com.google.code.gson:gson:2.8.9" force "junit:junit:4.12" } } @@ -300,7 +300,7 @@ String bwcFilePath = "src/test/resources/org/opensearch/ad/bwc/" testClusters { "${baseName}$i" { testDistribution = "ARCHIVE" - versions = ["7.10.2","1.2.0-SNAPSHOT"] + versions = ["7.10.2","1.3.0-SNAPSHOT"] numberOfNodes = 3 plugin(provider(new Callable(){ @Override @@ -522,11 +522,7 @@ List jacocoExclusions = [ // TODO: fix unstable code coverage caused by null NodeClient issue // https://github.com/opensearch-project/anomaly-detection/issues/241 'org.opensearch.ad.task.ADBatchTaskRunner', - - // TODO: add tests for multi-category API - 'org.opensearch.ad.transport.SearchTopAnomalyResult*', - 'org.opensearch.ad.rest.RestSearchTopAnomalyResultAction', - 'org.opensearch.ad.model.AnomalyResultBucket', + //TODO: custom result index caused coverage drop 'org.opensearch.ad.indices.AnomalyDetectionIndices', 'org.opensearch.ad.transport.handler.AnomalyResultBulkIndexHandler' @@ -572,7 +568,7 @@ dependencies { compile "org.opensearch.client:opensearch-rest-client:${opensearch_version}" compile group: 'com.google.guava', name: 'guava', version:'29.0-jre' compile group: 'org.apache.commons', name: 'commons-math3', version: '3.6.1' - compile group: 'com.google.code.gson', name: 'gson', version: '2.8.6' + compile group: 'com.google.code.gson', name: 'gson', version: '2.8.9' compile group: 'com.yahoo.datasketches', name: 'sketches-core', version: '0.13.4' compile group: 'com.yahoo.datasketches', name: 'memory', version: '0.12.2' compile group: 'commons-lang', name: 'commons-lang', version: '2.6' @@ -589,6 +585,7 @@ dependencies { // used for serializing/deserializing rcf models. compile group: 'io.protostuff', name: 'protostuff-core', version: '1.7.4' compile group: 'io.protostuff', name: 'protostuff-runtime', version: '1.7.4' + compileOnly group: 'org.apache.commons', name: 'commons-lang3', version: '3.12.0' compile "org.jacoco:org.jacoco.agent:0.8.5" compile ("org.jacoco:org.jacoco.ant:0.8.5") { @@ -606,7 +603,6 @@ dependencies { testImplementation group: 'org.powermock', name: 'powermock-api-support', version: '2.0.2' testImplementation group: 'org.powermock', name: 'powermock-reflect', version: '2.0.7' testImplementation group: 'org.objenesis', name: 'objenesis', version: '3.0.1' - testImplementation group: 'org.javassist', name: 'javassist', version: '3.27.0-GA' testCompile group: 'net.bytebuddy', name: 'byte-buddy', version: '1.9.15' testCompile group: 'net.bytebuddy', name: 'byte-buddy-agent', version: '1.9.15' testCompileOnly 'org.apiguardian:apiguardian-api:1.1.0' diff --git a/src/main/java/org/opensearch/ad/constant/CommonErrorMessages.java b/src/main/java/org/opensearch/ad/constant/CommonErrorMessages.java index bb4f3a35f..1d5b35859 100644 --- a/src/main/java/org/opensearch/ad/constant/CommonErrorMessages.java +++ b/src/main/java/org/opensearch/ad/constant/CommonErrorMessages.java @@ -13,6 +13,7 @@ import static org.opensearch.ad.constant.CommonName.CUSTOM_RESULT_INDEX_PREFIX; import static org.opensearch.ad.model.AnomalyDetector.MAX_RESULT_INDEX_NAME_SIZE; +import static org.opensearch.ad.rest.handler.AbstractAnomalyDetectorActionHandler.MAX_DETECTOR_NAME_SIZE; import java.util.Locale; @@ -51,6 +52,7 @@ public class CommonErrorMessages { public static final String BUG_RESPONSE = "We might have bugs."; public static final String INDEX_NOT_FOUND = "index does not exist"; public static final String NOT_EXISTENT_VALIDATION_TYPE = "The given validation type doesn't exist"; + public static final String UNSUPPORTED_PROFILE_TYPE = "Unsupported profile types"; private static final String TOO_MANY_CATEGORICAL_FIELD_ERR_MSG_FORMAT = "We can have only %d categorical field/s."; @@ -73,8 +75,10 @@ public static String getTooManyCategoricalFieldErr(int limit) { public static String HISTORICAL_ANALYSIS_CANCELLED = "Historical analysis cancelled by user"; public static String HC_DETECTOR_TASK_IS_UPDATING = "HC detector task is updating"; public static String NEGATIVE_TIME_CONFIGURATION = "should be non-negative"; + public static String INVALID_TIME_CONFIGURATION_UNITS = "Time unit %s is not supported"; public static String INVALID_DETECTOR_NAME = "Valid characters for detector name are a-z, A-Z, 0-9, -(hyphen), _(underscore) and .(period)"; + public static String DUPLICATE_FEATURE_AGGREGATION_NAMES = "Detector has duplicate feature aggregation query names: "; public static String FAIL_TO_GET_DETECTOR = "Fail to get detector"; public static String FAIL_TO_GET_DETECTOR_INFO = "Fail to get detector info"; @@ -96,4 +100,7 @@ public static String getTooManyCategoricalFieldErr(int limit) { public static String INVALID_CHAR_IN_RESULT_INDEX_NAME = "Result index name has invalid character. Valid characters are a-z, 0-9, -(hyphen) and _(underscore)"; public static String INVALID_RESULT_INDEX_MAPPING = "Result index mapping is not correct for index: "; + public static String INVALID_DETECTOR_NAME_SIZE = "Name should be shortened. The maximum limit is " + + MAX_DETECTOR_NAME_SIZE + + " characters."; } diff --git a/src/main/java/org/opensearch/ad/model/ADEntityTaskProfile.java b/src/main/java/org/opensearch/ad/model/ADEntityTaskProfile.java index 4233e42c1..2b46372d2 100644 --- a/src/main/java/org/opensearch/ad/model/ADEntityTaskProfile.java +++ b/src/main/java/org/opensearch/ad/model/ADEntityTaskProfile.java @@ -14,6 +14,7 @@ import static org.opensearch.common.xcontent.XContentParserUtils.ensureExpectedToken; import java.io.IOException; +import java.util.Objects; import org.opensearch.common.io.stream.StreamInput; import org.opensearch.common.io.stream.StreamOutput; @@ -269,4 +270,38 @@ public String getAdTaskType() { public void setAdTaskType(String adTaskType) { this.adTaskType = adTaskType; } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + ADEntityTaskProfile that = (ADEntityTaskProfile) o; + return Objects.equals(shingleSize, that.shingleSize) + && Objects.equals(rcfTotalUpdates, that.rcfTotalUpdates) + && Objects.equals(thresholdModelTrained, that.thresholdModelTrained) + && Objects.equals(thresholdModelTrainingDataSize, that.thresholdModelTrainingDataSize) + && Objects.equals(modelSizeInBytes, that.modelSizeInBytes) + && Objects.equals(nodeId, that.nodeId) + && Objects.equals(taskId, that.taskId) + && Objects.equals(adTaskType, that.adTaskType) + && Objects.equals(entity, that.entity); + } + + @Override + public int hashCode() { + return Objects + .hash( + shingleSize, + rcfTotalUpdates, + thresholdModelTrained, + thresholdModelTrainingDataSize, + modelSizeInBytes, + nodeId, + entity, + taskId, + adTaskType + ); + } } diff --git a/src/main/java/org/opensearch/ad/model/AnomalyResultBucket.java b/src/main/java/org/opensearch/ad/model/AnomalyResultBucket.java index 07450d0c7..ca7e3e126 100644 --- a/src/main/java/org/opensearch/ad/model/AnomalyResultBucket.java +++ b/src/main/java/org/opensearch/ad/model/AnomalyResultBucket.java @@ -14,6 +14,8 @@ import java.io.IOException; import java.util.Map; +import org.apache.commons.lang.builder.ToStringBuilder; +import org.opensearch.ad.annotation.Generated; import org.opensearch.common.io.stream.StreamInput; import org.opensearch.common.io.stream.StreamOutput; import org.opensearch.common.io.stream.Writeable; @@ -22,6 +24,8 @@ import org.opensearch.search.aggregations.bucket.composite.CompositeAggregation.Bucket; import org.opensearch.search.aggregations.metrics.InternalMax; +import com.google.common.base.Objects; + /** * Represents a single bucket when retrieving top anomaly results for HC detectors */ @@ -72,6 +76,34 @@ public void writeTo(StreamOutput out) throws IOException { out.writeDouble(maxAnomalyGrade); } + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + AnomalyResultBucket that = (AnomalyResultBucket) o; + return Objects.equal(getKey(), that.getKey()) + && Objects.equal(getDocCount(), that.getDocCount()) + && Objects.equal(getMaxAnomalyGrade(), that.getMaxAnomalyGrade()); + } + + @Generated + @Override + public int hashCode() { + return Objects.hashCode(getKey(), getDocCount(), getMaxAnomalyGrade()); + } + + @Generated + @Override + public String toString() { + return new ToStringBuilder(this) + .append("key", key) + .append("docCount", docCount) + .append("maxAnomalyGrade", maxAnomalyGrade) + .toString(); + } + public Map getKey() { return key; } diff --git a/src/main/java/org/opensearch/ad/model/DetectorProfileName.java b/src/main/java/org/opensearch/ad/model/DetectorProfileName.java index 01ffde83c..2b8f220a3 100644 --- a/src/main/java/org/opensearch/ad/model/DetectorProfileName.java +++ b/src/main/java/org/opensearch/ad/model/DetectorProfileName.java @@ -15,6 +15,7 @@ import java.util.Set; import org.opensearch.ad.Name; +import org.opensearch.ad.constant.CommonErrorMessages; import org.opensearch.ad.constant.CommonName; public enum DetectorProfileName implements Name { @@ -68,7 +69,7 @@ public static DetectorProfileName getName(String name) { case CommonName.AD_TASK: return AD_TASK; default: - throw new IllegalArgumentException("Unsupported profile types"); + throw new IllegalArgumentException(CommonErrorMessages.UNSUPPORTED_PROFILE_TYPE); } } diff --git a/src/main/java/org/opensearch/ad/model/IntervalTimeConfiguration.java b/src/main/java/org/opensearch/ad/model/IntervalTimeConfiguration.java index b9a147bbb..f1fcab5f9 100644 --- a/src/main/java/org/opensearch/ad/model/IntervalTimeConfiguration.java +++ b/src/main/java/org/opensearch/ad/model/IntervalTimeConfiguration.java @@ -46,7 +46,7 @@ public IntervalTimeConfiguration(long interval, ChronoUnit unit) { ); } if (!SUPPORTED_UNITS.contains(unit)) { - throw new IllegalArgumentException(String.format(Locale.ROOT, "Timezone %s is not supported", unit)); + throw new IllegalArgumentException(String.format(Locale.ROOT, CommonErrorMessages.INVALID_TIME_CONFIGURATION_UNITS, unit)); } this.interval = interval; this.unit = unit; diff --git a/src/main/java/org/opensearch/ad/rest/handler/AbstractAnomalyDetectorActionHandler.java b/src/main/java/org/opensearch/ad/rest/handler/AbstractAnomalyDetectorActionHandler.java index 11afab837..0f0995b38 100644 --- a/src/main/java/org/opensearch/ad/rest/handler/AbstractAnomalyDetectorActionHandler.java +++ b/src/main/java/org/opensearch/ad/rest/handler/AbstractAnomalyDetectorActionHandler.java @@ -119,7 +119,7 @@ public abstract class AbstractAnomalyDetectorActionHandler>>( validateFeatureQueriesListener, anomalyDetector.getFeatureAttributes().size(), - String.format(Locale.ROOT, "Validation failed for feature(s) of detector %s", anomalyDetector.getName()), + String.format(Locale.ROOT, VALIDATION_FEATURE_FAILURE, anomalyDetector.getName()), false ); diff --git a/src/main/java/org/opensearch/ad/transport/SearchTopAnomalyResultRequest.java b/src/main/java/org/opensearch/ad/transport/SearchTopAnomalyResultRequest.java index 94023d308..4f6361608 100644 --- a/src/main/java/org/opensearch/ad/transport/SearchTopAnomalyResultRequest.java +++ b/src/main/java/org/opensearch/ad/transport/SearchTopAnomalyResultRequest.java @@ -48,6 +48,7 @@ public class SearchTopAnomalyResultRequest extends ActionRequest { private Instant endTime; public SearchTopAnomalyResultRequest(StreamInput in) throws IOException { + super(in); detectorId = in.readOptionalString(); taskId = in.readOptionalString(); historical = in.readBoolean(); diff --git a/src/main/java/org/opensearch/ad/transport/SearchTopAnomalyResultTransportAction.java b/src/main/java/org/opensearch/ad/transport/SearchTopAnomalyResultTransportAction.java index 87ddecbed..75b1f2d22 100644 --- a/src/main/java/org/opensearch/ad/transport/SearchTopAnomalyResultTransportAction.java +++ b/src/main/java/org/opensearch/ad/transport/SearchTopAnomalyResultTransportAction.java @@ -182,12 +182,12 @@ public class SearchTopAnomalyResultTransportAction extends private static final String defaultIndex = ALL_AD_RESULTS_INDEX_PATTERN; private static final String COUNT_FIELD = "_count"; private static final String BUCKET_SORT_FIELD = "bucket_sort"; - private static final String MULTI_BUCKETS_FIELD = "multi_buckets"; + public static final String MULTI_BUCKETS_FIELD = "multi_buckets"; private static final Logger logger = LogManager.getLogger(SearchTopAnomalyResultTransportAction.class); private final Client client; private Clock clock; - private enum OrderType { + public enum OrderType { SEVERITY("severity"), OCCURRENCE("occurrence"); diff --git a/src/main/java/org/opensearch/ad/util/RestHandlerUtils.java b/src/main/java/org/opensearch/ad/util/RestHandlerUtils.java index 3fa28d743..c11918653 100644 --- a/src/main/java/org/opensearch/ad/util/RestHandlerUtils.java +++ b/src/main/java/org/opensearch/ad/util/RestHandlerUtils.java @@ -27,6 +27,7 @@ import org.opensearch.action.search.ShardSearchFailure; import org.opensearch.ad.common.exception.AnomalyDetectionException; import org.opensearch.ad.common.exception.ResourceNotFoundException; +import org.opensearch.ad.constant.CommonErrorMessages; import org.opensearch.ad.model.AnomalyDetector; import org.opensearch.ad.model.Feature; import org.opensearch.common.Strings; @@ -151,7 +152,7 @@ private static String validateFeaturesConfig(List features) { errorMsgBuilder.append(". "); } if (duplicateFeatureAggNames.size() > 0) { - errorMsgBuilder.append("Detector has duplicate feature aggregation query names: "); + errorMsgBuilder.append(CommonErrorMessages.DUPLICATE_FEATURE_AGGREGATION_NAMES); errorMsgBuilder.append(String.join(", ", duplicateFeatureAggNames)); } return errorMsgBuilder.toString(); diff --git a/src/test/java/org/opensearch/action/admin/indices/mapping/get/IndexAnomalyDetectorActionHandlerTests.java b/src/test/java/org/opensearch/action/admin/indices/mapping/get/IndexAnomalyDetectorActionHandlerTests.java index a5bdd5aa3..cbc4dabce 100644 --- a/src/test/java/org/opensearch/action/admin/indices/mapping/get/IndexAnomalyDetectorActionHandlerTests.java +++ b/src/test/java/org/opensearch/action/admin/indices/mapping/get/IndexAnomalyDetectorActionHandlerTests.java @@ -32,6 +32,7 @@ import org.junit.BeforeClass; import org.junit.Ignore; import org.mockito.ArgumentCaptor; +import org.opensearch.OpenSearchStatusException; import org.opensearch.action.ActionListener; import org.opensearch.action.ActionRequest; import org.opensearch.action.ActionResponse; @@ -46,10 +47,12 @@ import org.opensearch.ad.AbstractADTest; import org.opensearch.ad.TestHelpers; import org.opensearch.ad.common.exception.ADValidationException; +import org.opensearch.ad.constant.CommonErrorMessages; import org.opensearch.ad.constant.CommonName; import org.opensearch.ad.feature.SearchFeatureDao; import org.opensearch.ad.indices.AnomalyDetectionIndices; import org.opensearch.ad.model.AnomalyDetector; +import org.opensearch.ad.model.Feature; import org.opensearch.ad.rest.handler.IndexAnomalyDetectorActionHandler; import org.opensearch.ad.task.ADTaskManager; import org.opensearch.ad.transport.IndexAnomalyDetectorResponse; @@ -66,6 +69,8 @@ import org.opensearch.threadpool.ThreadPool; import org.opensearch.transport.TransportService; +import com.google.common.collect.ImmutableList; + /** * * we need to put the test in the same package of GetFieldMappingsResponse @@ -703,4 +708,50 @@ public void testTenMultiEntityDetectorsUpdateExistingMultiEntityAd() throws IOEx assertTrue(value instanceof IllegalStateException); assertTrue(value.getMessage().contains("NodeClient has not been initialized")); } + + @SuppressWarnings("unchecked") + public void testCreateAnomalyDetectorWithDuplicateFeatureAggregationNames() throws IOException { + Feature featureOne = TestHelpers.randomFeature("featureName", "test-1"); + Feature featureTwo = TestHelpers.randomFeature("featureNameTwo", "test-1"); + AnomalyDetector anomalyDetector = TestHelpers.randomAnomalyDetector(ImmutableList.of(featureOne, featureTwo)); + SearchResponse mockResponse = mock(SearchResponse.class); + when(mockResponse.getHits()).thenReturn(TestHelpers.createSearchHits(9)); + doAnswer(invocation -> { + Object[] args = invocation.getArguments(); + assertTrue(String.format("The size of args is %d. Its content is %s", args.length, Arrays.toString(args)), args.length == 2); + assertTrue(args[0] instanceof SearchRequest); + assertTrue(args[1] instanceof ActionListener); + ActionListener listener = (ActionListener) args[1]; + listener.onResponse(mockResponse); + return null; + }).when(clientMock).search(any(SearchRequest.class), any()); + + handler = new IndexAnomalyDetectorActionHandler( + clusterService, + clientMock, + transportService, + channel, + anomalyDetectionIndices, + detectorId, + seqNo, + primaryTerm, + refreshPolicy, + anomalyDetector, + requestTimeout, + maxSingleEntityAnomalyDetectors, + maxMultiEntityAnomalyDetectors, + maxAnomalyFeatures, + method, + xContentRegistry(), + null, + adTaskManager, + searchFeatureDao + ); + ArgumentCaptor response = ArgumentCaptor.forClass(Exception.class); + handler.start(); + verify(channel).onFailure(response.capture()); + Exception value = response.getValue(); + assertTrue(value instanceof OpenSearchStatusException); + assertTrue(value.getMessage().contains(CommonErrorMessages.DUPLICATE_FEATURE_AGGREGATION_NAMES)); + } } diff --git a/src/test/java/org/opensearch/action/admin/indices/mapping/get/ValidateAnomalyDetectorActionHandlerTests.java b/src/test/java/org/opensearch/action/admin/indices/mapping/get/ValidateAnomalyDetectorActionHandlerTests.java index ebb3f4a00..c1d8707cd 100644 --- a/src/test/java/org/opensearch/action/admin/indices/mapping/get/ValidateAnomalyDetectorActionHandlerTests.java +++ b/src/test/java/org/opensearch/action/admin/indices/mapping/get/ValidateAnomalyDetectorActionHandlerTests.java @@ -121,7 +121,7 @@ public void setUp() throws Exception { @SuppressWarnings("unchecked") public void testValidateMoreThanThousandSingleEntityDetectorLimit() throws IOException { SearchResponse mockResponse = mock(SearchResponse.class); - int totalHits = 1001; + int totalHits = maxSingleEntityAnomalyDetectors + 1; when(mockResponse.getHits()).thenReturn(TestHelpers.createSearchHits(totalHits)); doAnswer(invocation -> { Object[] args = invocation.getArguments(); @@ -152,7 +152,6 @@ public void testValidateMoreThanThousandSingleEntityDetectorLimit() throws IOExc searchFeatureDao, ValidationAspect.DETECTOR.getName() ); - handler.start(); ArgumentCaptor response = ArgumentCaptor.forClass(Exception.class); verify(clientMock, never()).execute(eq(GetMappingsAction.INSTANCE), any(), any()); @@ -172,7 +171,7 @@ public void testValidateMoreThanThousandSingleEntityDetectorLimit() throws IOExc public void testValidateMoreThanTenMultiEntityDetectorsLimit() throws IOException { SearchResponse mockResponse = mock(SearchResponse.class); - int totalHits = 11; + int totalHits = maxMultiEntityAnomalyDetectors + 1; when(mockResponse.getHits()).thenReturn(TestHelpers.createSearchHits(totalHits)); @@ -204,7 +203,7 @@ public void testValidateMoreThanTenMultiEntityDetectorsLimit() throws IOExceptio xContentRegistry(), null, searchFeatureDao, - ValidationAspect.DETECTOR.getName() + "" ); handler.start(); diff --git a/src/test/java/org/opensearch/ad/ADIntegTestCase.java b/src/test/java/org/opensearch/ad/ADIntegTestCase.java index 0ad57e304..cff2a5619 100644 --- a/src/test/java/org/opensearch/ad/ADIntegTestCase.java +++ b/src/test/java/org/opensearch/ad/ADIntegTestCase.java @@ -136,6 +136,10 @@ public void createADResultIndex() throws IOException { createIndex(CommonName.ANOMALY_RESULT_INDEX_ALIAS, AnomalyDetectionIndices.getAnomalyResultMappings()); } + public void createCustomADResultIndex(String indexName) throws IOException { + createIndex(indexName, AnomalyDetectionIndices.getAnomalyResultMappings()); + } + public void createDetectionStateIndex() throws IOException { createIndex(CommonName.DETECTION_STATE_INDEX, AnomalyDetectionIndices.getDetectionStateMappings()); } diff --git a/src/test/java/org/opensearch/ad/AnomalyDetectorRestTestCase.java b/src/test/java/org/opensearch/ad/AnomalyDetectorRestTestCase.java index 474005e0d..9409faf24 100644 --- a/src/test/java/org/opensearch/ad/AnomalyDetectorRestTestCase.java +++ b/src/test/java/org/opensearch/ad/AnomalyDetectorRestTestCase.java @@ -17,6 +17,7 @@ import java.io.InputStream; import java.time.Instant; import java.util.ArrayList; +import java.util.Collections; import java.util.Map; import org.apache.http.HttpHeaders; @@ -367,6 +368,25 @@ public Response getSearchDetectorMatch(String name) throws IOException { ); } + public Response searchTopAnomalyResults(String detectorId, boolean historical, String bodyAsJsonString, RestClient client) + throws IOException { + return TestHelpers + .makeRequest( + client, + "POST", + TestHelpers.AD_BASE_DETECTORS_URI + + "/" + + detectorId + + "/" + + RestHandlerUtils.RESULTS + + "/" + + RestHandlerUtils.TOP_ANOMALIES, + Collections.singletonMap("historical", String.valueOf(historical)), + TestHelpers.toHttpEntity(bodyAsJsonString), + new ArrayList<>() + ); + } + public Response createUser(String name, String password, ArrayList backendRoles) throws IOException { JsonArray backendRolesString = new JsonArray(); for (int i = 0; i < backendRoles.size(); i++) { @@ -567,4 +587,16 @@ protected AnomalyDetector cloneDetector(AnomalyDetector anomalyDetector, String return detector; } + protected Response validateAnomalyDetector(AnomalyDetector detector, RestClient client) throws IOException { + return TestHelpers + .makeRequest( + client, + "POST", + TestHelpers.AD_BASE_DETECTORS_URI + "/_validate", + ImmutableMap.of(), + TestHelpers.toHttpEntity(detector), + null + ); + } + } diff --git a/src/test/java/org/opensearch/ad/HistoricalAnalysisIntegTestCase.java b/src/test/java/org/opensearch/ad/HistoricalAnalysisIntegTestCase.java index 2074ffa55..cb4bdc892 100644 --- a/src/test/java/org/opensearch/ad/HistoricalAnalysisIntegTestCase.java +++ b/src/test/java/org/opensearch/ad/HistoricalAnalysisIntegTestCase.java @@ -238,4 +238,18 @@ public ADTask startHistoricalAnalysis(Instant startTime, Instant endTime) throws AnomalyDetectorJobResponse response = client().execute(AnomalyDetectorJobAction.INSTANCE, request).actionGet(10000); return getADTask(response.getId()); } + + public ADTask startHistoricalAnalysis(String detectorId, Instant startTime, Instant endTime) throws IOException { + DetectionDateRange dateRange = new DetectionDateRange(startTime, endTime); + AnomalyDetectorJobRequest request = new AnomalyDetectorJobRequest( + detectorId, + dateRange, + true, + UNASSIGNED_SEQ_NO, + UNASSIGNED_PRIMARY_TERM, + START_JOB + ); + AnomalyDetectorJobResponse response = client().execute(AnomalyDetectorJobAction.INSTANCE, request).actionGet(10000); + return getADTask(response.getId()); + } } diff --git a/src/test/java/org/opensearch/ad/TestHelpers.java b/src/test/java/org/opensearch/ad/TestHelpers.java index abe97011a..796aff104 100644 --- a/src/test/java/org/opensearch/ad/TestHelpers.java +++ b/src/test/java/org/opensearch/ad/TestHelpers.java @@ -16,14 +16,7 @@ import static org.opensearch.common.xcontent.XContentParserUtils.ensureExpectedToken; import static org.opensearch.index.query.AbstractQueryBuilder.parseInnerQueryBuilder; import static org.opensearch.index.seqno.SequenceNumbers.UNASSIGNED_SEQ_NO; -import static org.opensearch.test.OpenSearchTestCase.buildNewFakeTransportAddress; -import static org.opensearch.test.OpenSearchTestCase.randomAlphaOfLength; -import static org.opensearch.test.OpenSearchTestCase.randomBoolean; -import static org.opensearch.test.OpenSearchTestCase.randomDouble; -import static org.opensearch.test.OpenSearchTestCase.randomDoubleBetween; -import static org.opensearch.test.OpenSearchTestCase.randomInt; -import static org.opensearch.test.OpenSearchTestCase.randomIntBetween; -import static org.opensearch.test.OpenSearchTestCase.randomLong; +import static org.opensearch.test.OpenSearchTestCase.*; import static org.powermock.api.mockito.PowerMockito.mock; import static org.powermock.api.mockito.PowerMockito.when; @@ -66,6 +59,7 @@ import org.opensearch.ad.constant.CommonName; import org.opensearch.ad.constant.CommonValue; import org.opensearch.ad.feature.Features; +import org.opensearch.ad.indices.AnomalyDetectionIndices; import org.opensearch.ad.ml.ThresholdingResult; import org.opensearch.ad.mock.model.MockSimpleLog; import org.opensearch.ad.model.ADTask; @@ -75,6 +69,7 @@ import org.opensearch.ad.model.AnomalyDetectorExecutionInput; import org.opensearch.ad.model.AnomalyDetectorJob; import org.opensearch.ad.model.AnomalyResult; +import org.opensearch.ad.model.AnomalyResultBucket; import org.opensearch.ad.model.DataByFeatureId; import org.opensearch.ad.model.DetectionDateRange; import org.opensearch.ad.model.DetectorInternalState; @@ -371,13 +366,37 @@ public static DetectionDateRange randomDetectionDateRange() { public static AnomalyDetector randomAnomalyDetectorUsingCategoryFields(String detectorId, List categoryFields) throws IOException { + return randomAnomalyDetectorUsingCategoryFields( + detectorId, + randomAlphaOfLength(5), + ImmutableList.of(randomAlphaOfLength(10).toLowerCase()), + categoryFields + ); + } + + public static AnomalyDetector randomAnomalyDetectorUsingCategoryFields( + String detectorId, + String timeField, + List indices, + List categoryFields + ) throws IOException { + return randomAnomalyDetectorUsingCategoryFields(detectorId, timeField, indices, categoryFields, null); + } + + public static AnomalyDetector randomAnomalyDetectorUsingCategoryFields( + String detectorId, + String timeField, + List indices, + List categoryFields, + String resultIndex + ) throws IOException { return new AnomalyDetector( detectorId, randomLong(), randomAlphaOfLength(20), randomAlphaOfLength(30), - randomAlphaOfLength(5), - ImmutableList.of(randomAlphaOfLength(10).toLowerCase()), + timeField, + indices, ImmutableList.of(randomFeature(true)), randomQuery(), randomIntervalTimeConfiguration(), @@ -388,7 +407,7 @@ public static AnomalyDetector randomAnomalyDetectorUsingCategoryFields(String de Instant.now(), categoryFields, randomUser(), - null + resultIndex ); } @@ -463,6 +482,12 @@ public static AnomalyDetector randomAnomalyDetectorWithInterval(TimeConfiguratio ); } + public static AnomalyResultBucket randomAnomalyResultBucket() { + Map map = new HashMap<>(); + map.put(randomAlphaOfLength(5), randomAlphaOfLength(5)); + return new AnomalyResultBucket(map, randomInt(), randomDouble()); + } + public static class AnomalyDetectorBuilder { private String detectorId = randomAlphaOfLength(10); private Long version = randomLong(); @@ -842,6 +867,31 @@ public static ResultWriteRequest randomResultWriteRequest(String detectorId, dou } public static AnomalyResult randomHCADAnomalyDetectResult(double score, double grade, String error) { + return randomHCADAnomalyDetectResult(null, null, score, grade, error, null, null); + } + + public static AnomalyResult randomHCADAnomalyDetectResult( + String detectorId, + String taskId, + double score, + double grade, + String error, + Long startTimeEpochMillis, + Long endTimeEpochMillis + ) { + return randomHCADAnomalyDetectResult(detectorId, taskId, null, score, grade, error, startTimeEpochMillis, endTimeEpochMillis); + } + + public static AnomalyResult randomHCADAnomalyDetectResult( + String detectorId, + String taskId, + Map entityAttrs, + double score, + double grade, + String error, + Long startTimeEpochMillis, + Long endTimeEpochMillis + ) { List relavantAttribution = new ArrayList(); relavantAttribution.add(new DataByFeatureId(randomAlphaOfLength(5), randomDoubleBetween(0, 1.0, true))); relavantAttribution.add(new DataByFeatureId(randomAlphaOfLength(5), randomDoubleBetween(0, 1.0, true))); @@ -857,18 +907,20 @@ public static AnomalyResult randomHCADAnomalyDetectResult(double score, double g expectedValuesList.add(new ExpectedValueList(randomDoubleBetween(0, 1.0, true), expectedValues)); return new AnomalyResult( - randomAlphaOfLength(5), - null, + detectorId == null ? randomAlphaOfLength(5) : detectorId, + taskId, score, grade, randomDouble(), ImmutableList.of(randomFeatureData(), randomFeatureData()), - Instant.now().truncatedTo(ChronoUnit.SECONDS), - Instant.now().truncatedTo(ChronoUnit.SECONDS), - Instant.now().truncatedTo(ChronoUnit.SECONDS), - Instant.now().truncatedTo(ChronoUnit.SECONDS), + startTimeEpochMillis == null ? Instant.now().truncatedTo(ChronoUnit.SECONDS) : Instant.ofEpochMilli(startTimeEpochMillis), + endTimeEpochMillis == null ? Instant.now().truncatedTo(ChronoUnit.SECONDS) : Instant.ofEpochMilli(endTimeEpochMillis), + startTimeEpochMillis == null ? Instant.now().truncatedTo(ChronoUnit.SECONDS) : Instant.ofEpochMilli(startTimeEpochMillis), + endTimeEpochMillis == null ? Instant.now().truncatedTo(ChronoUnit.SECONDS) : Instant.ofEpochMilli(endTimeEpochMillis), error, - Entity.createSingleAttributeEntity(randomAlphaOfLength(5), randomAlphaOfLength(5)), + entityAttrs == null + ? Entity.createSingleAttributeEntity(randomAlphaOfLength(5), randomAlphaOfLength(5)) + : Entity.createEntityByReordering(entityAttrs), randomUser(), CommonValue.NO_SCHEMA_VERSION, null, @@ -1001,6 +1053,44 @@ public static void createIndex(RestClient client, String indexName, HttpEntity d ); } + public static void createIndexWithHCADFields(RestClient client, String indexName, Map categoryFieldsAndTypes) + throws IOException { + StringBuilder indexMappings = new StringBuilder(); + indexMappings.append("{\"properties\":{"); + for (Map.Entry entry : categoryFieldsAndTypes.entrySet()) { + indexMappings.append("\"" + entry.getKey() + "\":{\"type\":\"" + entry.getValue() + "\"},"); + } + indexMappings.append("\"timestamp\":{\"type\":\"date\"}"); + indexMappings.append("}}"); + createEmptyIndex(client, indexName); + createIndexMapping(client, indexName, TestHelpers.toHttpEntity(indexMappings.toString())); + } + + public static void createEmptyAnomalyResultIndex(RestClient client) throws IOException { + createEmptyIndex(client, CommonName.ANOMALY_RESULT_INDEX_ALIAS); + createIndexMapping(client, CommonName.ANOMALY_RESULT_INDEX_ALIAS, toHttpEntity(AnomalyDetectionIndices.getAnomalyResultMappings())); + } + + public static void createEmptyIndex(RestClient client, String indexName) throws IOException { + TestHelpers.makeRequest(client, "PUT", "/" + indexName, ImmutableMap.of(), "", null); + } + + public static void createIndexMapping(RestClient client, String indexName, HttpEntity mappings) throws IOException { + TestHelpers.makeRequest(client, "POST", "/" + indexName + "/_mapping", ImmutableMap.of(), mappings, null); + } + + public static void ingestDataToIndex(RestClient client, String indexName, HttpEntity data) throws IOException { + TestHelpers + .makeRequest( + client, + "POST", + "/" + indexName + "/_doc/" + randomAlphaOfLength(5) + "?refresh=true", + ImmutableMap.of(), + data, + null + ); + } + public static GetResponse createGetResponse(ToXContentObject o, String id, String indexName) throws IOException { XContentBuilder content = o.toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS); return new GetResponse( @@ -1350,11 +1440,20 @@ public static Map parseStatsResult(String statsResult) throws IO } public static DetectorValidationIssue randomDetectorValidationIssue() { + DetectorValidationIssue issue = new DetectorValidationIssue( + ValidationAspect.DETECTOR, + DetectorValidationIssueType.NAME, + randomAlphaOfLength(5) + ); + return issue; + } + + public static DetectorValidationIssue randomDetectorValidationIssueWithSubIssues(Map subIssues) { DetectorValidationIssue issue = new DetectorValidationIssue( ValidationAspect.DETECTOR, DetectorValidationIssueType.NAME, randomAlphaOfLength(5), - null, + subIssues, null ); return issue; diff --git a/src/test/java/org/opensearch/ad/model/ADEntityTaskProfileTests.java b/src/test/java/org/opensearch/ad/model/ADEntityTaskProfileTests.java new file mode 100644 index 000000000..304f7f2d8 --- /dev/null +++ b/src/test/java/org/opensearch/ad/model/ADEntityTaskProfileTests.java @@ -0,0 +1,96 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.model; + +import java.io.IOException; +import java.util.Collection; +import java.util.TreeMap; + +import org.opensearch.ad.AnomalyDetectorPlugin; +import org.opensearch.ad.TestHelpers; +import org.opensearch.common.io.stream.BytesStreamOutput; +import org.opensearch.common.io.stream.NamedWriteableAwareStreamInput; +import org.opensearch.common.io.stream.NamedWriteableRegistry; +import org.opensearch.common.xcontent.ToXContent; +import org.opensearch.plugins.Plugin; +import org.opensearch.test.InternalSettingsPlugin; +import org.opensearch.test.OpenSearchSingleNodeTestCase; + +public class ADEntityTaskProfileTests extends OpenSearchSingleNodeTestCase { + + @Override + protected Collection> getPlugins() { + return pluginList(InternalSettingsPlugin.class, AnomalyDetectorPlugin.class); + } + + @Override + protected NamedWriteableRegistry writableRegistry() { + return getInstanceFromNode(NamedWriteableRegistry.class); + } + + public void testADEntityTaskProfileSerialization() throws IOException { + ADEntityTaskProfile entityTask = new ADEntityTaskProfile( + 1, + 23L, + false, + 1, + 2L, + "1234", + null, + "4321", + ADTaskType.HISTORICAL_HC_ENTITY.name() + ); + BytesStreamOutput output = new BytesStreamOutput(); + entityTask.writeTo(output); + NamedWriteableAwareStreamInput input = new NamedWriteableAwareStreamInput(output.bytes().streamInput(), writableRegistry()); + ADEntityTaskProfile parsedEntityTask = new ADEntityTaskProfile(input); + assertEquals(entityTask, parsedEntityTask); + } + + public void testParseADEntityTaskProfile() throws IOException { + TreeMap attributes = new TreeMap<>(); + String name1 = "host"; + String val1 = "server_2"; + String name2 = "service"; + String val2 = "app_4"; + attributes.put(name1, val1); + attributes.put(name2, val2); + Entity entity = Entity.createEntityFromOrderedMap(attributes); + ADEntityTaskProfile entityTask = new ADEntityTaskProfile( + 1, + 23L, + false, + 1, + 2L, + "1234", + entity, + "4321", + ADTaskType.HISTORICAL_HC_ENTITY.name() + ); + String adEntityTaskProfileString = TestHelpers + .xContentBuilderToString(entityTask.toXContent(TestHelpers.builder(), ToXContent.EMPTY_PARAMS)); + ADEntityTaskProfile parsedEntityTask = ADEntityTaskProfile.parse(TestHelpers.parser(adEntityTaskProfileString)); + assertEquals(entityTask, parsedEntityTask); + } + + public void testParseADEntityTaskProfileWithNullEntity() throws IOException { + ADEntityTaskProfile entityTask = new ADEntityTaskProfile( + 1, + 23L, + false, + 1, + 2L, + "1234", + null, + "4321", + ADTaskType.HISTORICAL_HC_ENTITY.name() + ); + String adEntityTaskProfileString = TestHelpers + .xContentBuilderToString(entityTask.toXContent(TestHelpers.builder(), ToXContent.EMPTY_PARAMS)); + ADEntityTaskProfile parsedEntityTask = ADEntityTaskProfile.parse(TestHelpers.parser(adEntityTaskProfileString)); + assertEquals(entityTask, parsedEntityTask); + } +} diff --git a/src/test/java/org/opensearch/ad/model/AnomalyDetectorTests.java b/src/test/java/org/opensearch/ad/model/AnomalyDetectorTests.java index e1e2ba43e..d96a3435c 100644 --- a/src/test/java/org/opensearch/ad/model/AnomalyDetectorTests.java +++ b/src/test/java/org/opensearch/ad/model/AnomalyDetectorTests.java @@ -26,6 +26,7 @@ import org.opensearch.ad.AbstractADTest; import org.opensearch.ad.TestHelpers; import org.opensearch.ad.common.exception.ADValidationException; +import org.opensearch.ad.constant.CommonErrorMessages; import org.opensearch.ad.constant.CommonName; import org.opensearch.ad.settings.AnomalyDetectorSettings; import org.opensearch.common.unit.TimeValue; @@ -67,6 +68,23 @@ public void testParseAnomalyDetectorWithCustomIndex() throws IOException { assertEquals("Parsing anomaly detector doesn't work", detector, parsedDetector); } + public void testAnomalyDetectorWithInvalidCustomIndex() throws Exception { + String resultIndex = CommonName.CUSTOM_RESULT_INDEX_PREFIX + "test@@"; + TestHelpers + .assertFailWith( + ADValidationException.class, + () -> (TestHelpers + .randomDetector( + ImmutableList.of(TestHelpers.randomFeature()), + randomAlphaOfLength(5), + randomIntBetween(1, 5), + randomAlphaOfLength(5), + ImmutableList.of(randomAlphaOfLength(5)), + resultIndex + )) + ); + } + public void testParseAnomalyDetectorWithoutParams() throws IOException { AnomalyDetector detector = TestHelpers.randomAnomalyDetector(TestHelpers.randomUiMetadata(), Instant.now()); String detectorString = TestHelpers.xContentBuilderToString(detector.toXContent(TestHelpers.builder())); @@ -229,6 +247,44 @@ public void testParseAnomalyDetectorWithIncorrectFeatureQuery() throws Exception TestHelpers.assertFailWith(ADValidationException.class, () -> AnomalyDetector.parse(TestHelpers.parser(detectorString))); } + public void testParseAnomalyDetectorWithInvalidDetectorIntervalUnits() { + String detectorString = "{\"name\":\"todagtCMkwpcaedpyYUM\",\"description\":" + + "\"ClrcaMpuLfeDSlVduRcKlqPZyqWDBf\",\"time_field\":\"dJRwh\",\"indices\":[\"eIrgWMqAED\"]," + + "\"feature_attributes\":[{\"feature_id\":\"lxYRN\",\"feature_name\":\"eqSeU\",\"feature_enabled\"" + + ":true,\"aggregation_query\":{\"aa\":{\"value_count\":{\"field\":\"ok\"}}}}],\"detection_interval\":" + + "{\"period\":{\"interval\":425,\"unit\":\"Millis\"}},\"window_delay\":{\"period\":{\"interval\":973," + + "\"unit\":\"Minutes\"}},\"shingle_size\":4,\"schema_version\":-1203962153,\"ui_metadata\":{\"JbAaV\":{\"feature_id\":" + + "\"rIFjS\",\"feature_name\":\"QXCmS\",\"feature_enabled\":false,\"aggregation_query\":{\"aa\":" + + "{\"value_count\":{\"field\":\"ok\"}}}}},\"last_update_time\":1568396089028}"; + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> AnomalyDetector.parse(TestHelpers.parser(detectorString)) + ); + assertEquals( + String.format(Locale.ROOT, CommonErrorMessages.INVALID_TIME_CONFIGURATION_UNITS, ChronoUnit.MILLIS), + exception.getMessage() + ); + } + + public void testParseAnomalyDetectorInvalidWindowDelayUnits() { + String detectorString = "{\"name\":\"todagtCMkwpcaedpyYUM\",\"description\":" + + "\"ClrcaMpuLfeDSlVduRcKlqPZyqWDBf\",\"time_field\":\"dJRwh\",\"indices\":[\"eIrgWMqAED\"]," + + "\"feature_attributes\":[{\"feature_id\":\"lxYRN\",\"feature_name\":\"eqSeU\",\"feature_enabled\"" + + ":true,\"aggregation_query\":{\"aa\":{\"value_count\":{\"field\":\"ok\"}}}}],\"detection_interval\":" + + "{\"period\":{\"interval\":425,\"unit\":\"Minutes\"}},\"window_delay\":{\"period\":{\"interval\":973," + + "\"unit\":\"Millis\"}},\"shingle_size\":4,\"schema_version\":-1203962153,\"ui_metadata\":{\"JbAaV\":{\"feature_id\":" + + "\"rIFjS\",\"feature_name\":\"QXCmS\",\"feature_enabled\":false,\"aggregation_query\":{\"aa\":" + + "{\"value_count\":{\"field\":\"ok\"}}}}},\"last_update_time\":1568396089028}"; + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> AnomalyDetector.parse(TestHelpers.parser(detectorString)) + ); + assertEquals( + String.format(Locale.ROOT, CommonErrorMessages.INVALID_TIME_CONFIGURATION_UNITS, ChronoUnit.MILLIS), + exception.getMessage() + ); + } + public void testParseAnomalyDetectorWithNullUiMetadata() throws IOException { AnomalyDetector detector = TestHelpers.randomAnomalyDetector(null, Instant.now()); String detectorString = TestHelpers.xContentBuilderToString(detector.toXContent(TestHelpers.builder(), ToXContent.EMPTY_PARAMS)); diff --git a/src/test/java/org/opensearch/ad/model/AnomalyResultBucketTests.java b/src/test/java/org/opensearch/ad/model/AnomalyResultBucketTests.java new file mode 100644 index 000000000..55e2ce28d --- /dev/null +++ b/src/test/java/org/opensearch/ad/model/AnomalyResultBucketTests.java @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.model; + +import static org.opensearch.ad.model.AnomalyResultBucket.*; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import org.opensearch.ad.AbstractADTest; +import org.opensearch.ad.TestHelpers; +import org.opensearch.common.io.stream.BytesStreamOutput; +import org.opensearch.common.io.stream.StreamInput; +import org.opensearch.common.xcontent.ToXContent; +import org.opensearch.common.xcontent.XContentBuilder; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.XContentParser; + +public class AnomalyResultBucketTests extends AbstractADTest { + + public void testSerializeAnomalyResultBucket() throws IOException { + AnomalyResultBucket anomalyResultBucket = TestHelpers.randomAnomalyResultBucket(); + BytesStreamOutput output = new BytesStreamOutput(); + anomalyResultBucket.writeTo(output); + StreamInput input = output.bytes().streamInput(); + AnomalyResultBucket parsedAnomalyResultBucket = new AnomalyResultBucket(input); + assertTrue(parsedAnomalyResultBucket.equals(anomalyResultBucket)); + } + + @SuppressWarnings("unchecked") + public void testToXContent() throws IOException { + Map key = new HashMap() { + { + put("test-field-1", "test-value-1"); + } + }; + int docCount = 5; + double maxAnomalyGrade = 0.5; + AnomalyResultBucket testBucket = new AnomalyResultBucket(key, docCount, maxAnomalyGrade); + XContentBuilder builder = XContentFactory.jsonBuilder(); + testBucket.toXContent(builder, ToXContent.EMPTY_PARAMS); + XContentParser parser = createParser(builder); + Map parsedMap = parser.map(); + + assertEquals(testBucket.getKey().get("test-field-1"), ((Map) parsedMap.get(KEY_FIELD)).get("test-field-1")); + assertEquals(testBucket.getDocCount(), parsedMap.get(DOC_COUNT_FIELD)); + assertEquals(maxAnomalyGrade, (Double) parsedMap.get(MAX_ANOMALY_GRADE_FIELD), 0.000001d); + } +} diff --git a/src/test/java/org/opensearch/ad/model/DetectorInternalStateTests.java b/src/test/java/org/opensearch/ad/model/DetectorInternalStateTests.java new file mode 100644 index 000000000..a91721db5 --- /dev/null +++ b/src/test/java/org/opensearch/ad/model/DetectorInternalStateTests.java @@ -0,0 +1,27 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.model; + +import java.io.IOException; +import java.time.Instant; + +import org.opensearch.ad.TestHelpers; +import org.opensearch.common.xcontent.ToXContent; +import org.opensearch.test.OpenSearchSingleNodeTestCase; + +public class DetectorInternalStateTests extends OpenSearchSingleNodeTestCase { + + public void testToXContentDetectorInternalState() throws IOException { + DetectorInternalState internalState = new DetectorInternalState.Builder() + .lastUpdateTime(Instant.ofEpochMilli(100L)) + .error("error-test") + .build(); + String internalStateString = TestHelpers + .xContentBuilderToString(internalState.toXContent(TestHelpers.builder(), ToXContent.EMPTY_PARAMS)); + DetectorInternalState parsedInternalState = DetectorInternalState.parse(TestHelpers.parser(internalStateString)); + assertEquals(internalState, parsedInternalState); + } +} diff --git a/src/test/java/org/opensearch/ad/model/DetectorProfileTests.java b/src/test/java/org/opensearch/ad/model/DetectorProfileTests.java index eed3ace49..10c41d685 100644 --- a/src/test/java/org/opensearch/ad/model/DetectorProfileTests.java +++ b/src/test/java/org/opensearch/ad/model/DetectorProfileTests.java @@ -12,17 +12,20 @@ package org.opensearch.ad.model; import java.io.IOException; +import java.util.Map; +import org.opensearch.ad.TestHelpers; +import org.opensearch.ad.constant.CommonErrorMessages; +import org.opensearch.ad.constant.CommonName; import org.opensearch.common.io.stream.BytesStreamOutput; import org.opensearch.common.io.stream.NamedWriteableAwareStreamInput; +import org.opensearch.common.xcontent.XContentParser; import org.opensearch.test.OpenSearchTestCase; public class DetectorProfileTests extends OpenSearchTestCase { - public void testParseDetectorProfile() throws IOException { - String detectorId = randomAlphaOfLength(10); - String[] runningEntities = new String[] { randomAlphaOfLength(5) }; - DetectorProfile detectorProfile = new DetectorProfile.Builder() + private DetectorProfile createRandomDetectorProfile() { + return new DetectorProfile.Builder() .state(DetectorState.INIT) .error(randomAlphaOfLength(5)) .modelProfile( @@ -52,12 +55,40 @@ public void testParseDetectorProfile() throws IOException { ) ) .build(); + } + public void testParseDetectorProfile() throws IOException { + DetectorProfile detectorProfile = createRandomDetectorProfile(); BytesStreamOutput output = new BytesStreamOutput(); detectorProfile.writeTo(output); NamedWriteableAwareStreamInput input = new NamedWriteableAwareStreamInput(output.bytes().streamInput(), writableRegistry()); - // StreamInput input = output.bytes().streamInput(); DetectorProfile parsedDetectorProfile = new DetectorProfile(input); assertEquals("Detector profile serialization doesn't work", detectorProfile, parsedDetectorProfile); } + + public void testMergeDetectorProfile() { + DetectorProfile detectorProfileOne = createRandomDetectorProfile(); + DetectorProfile detectorProfileTwo = createRandomDetectorProfile(); + String errorPreMerge = detectorProfileOne.getError(); + detectorProfileOne.merge(detectorProfileTwo); + assertTrue(detectorProfileOne.toString().contains(detectorProfileTwo.getError())); + assertFalse(detectorProfileOne.toString().contains(errorPreMerge)); + assertTrue(detectorProfileOne.toString().contains(detectorProfileTwo.getCoordinatingNode())); + } + + public void testDetectorProfileToXContent() throws IOException { + DetectorProfile detectorProfile = createRandomDetectorProfile(); + String detectorProfileString = TestHelpers.xContentBuilderToString(detectorProfile.toXContent(TestHelpers.builder())); + XContentParser parser = TestHelpers.parser(detectorProfileString); + Map parsedMap = parser.map(); + assertEquals(detectorProfile.getCoordinatingNode(), parsedMap.get("coordinating_node")); + assertEquals(detectorProfile.getState().toString(), parsedMap.get("state")); + assertTrue(parsedMap.get("models").toString().contains(detectorProfile.getModelProfile()[0].getModelId())); + } + + public void testDetectorProfileName() throws IllegalArgumentException { + DetectorProfileName.getName(CommonName.AD_TASK); + IllegalArgumentException exception = expectThrows(IllegalArgumentException.class, () -> DetectorProfileName.getName("abc")); + assertEquals(exception.getMessage(), CommonErrorMessages.UNSUPPORTED_PROFILE_TYPE); + } } diff --git a/src/test/java/org/opensearch/ad/model/EntityProfileTests.java b/src/test/java/org/opensearch/ad/model/EntityProfileTests.java index d3141e64e..b55ecc880 100644 --- a/src/test/java/org/opensearch/ad/model/EntityProfileTests.java +++ b/src/test/java/org/opensearch/ad/model/EntityProfileTests.java @@ -32,6 +32,7 @@ public void testMerge() { profile1.merge(profile2); assertEquals(profile1.getState(), EntityState.INIT); + assertTrue(profile1.toString().contains(EntityState.INIT.toString())); } public void testToXContent() throws IOException, JsonPathNotFoundException { diff --git a/src/test/java/org/opensearch/ad/rest/AnomalyDetectorRestApiIT.java b/src/test/java/org/opensearch/ad/rest/AnomalyDetectorRestApiIT.java index dbfbede8f..29c8bdddb 100644 --- a/src/test/java/org/opensearch/ad/rest/AnomalyDetectorRestApiIT.java +++ b/src/test/java/org/opensearch/ad/rest/AnomalyDetectorRestApiIT.java @@ -19,10 +19,13 @@ import java.io.IOException; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.stream.Collectors; import org.apache.http.entity.ContentType; import org.apache.http.nio.entity.NStringEntity; @@ -31,6 +34,7 @@ import org.opensearch.ad.AnomalyDetectorRestTestCase; import org.opensearch.ad.TestHelpers; import org.opensearch.ad.constant.CommonErrorMessages; +import org.opensearch.ad.constant.CommonName; import org.opensearch.ad.model.AnomalyDetector; import org.opensearch.ad.model.AnomalyDetectorExecutionInput; import org.opensearch.ad.model.AnomalyDetectorJob; @@ -1504,4 +1508,393 @@ public void testValidateAnomalyDetectorWithWrongCategoryField() throws Exception ); } + + public void testSearchTopAnomalyResultsWithInvalidInputs() throws IOException { + String indexName = randomAlphaOfLength(10).toLowerCase(); + Map categoryFieldsAndTypes = new HashMap() { + { + put("keyword-field", "keyword"); + put("ip-field", "ip"); + } + }; + String testIndexData = "{\"keyword-field\": \"field-1\", \"ip-field\": \"1.2.3.4\", \"timestamp\": 1}"; + TestHelpers.createIndexWithHCADFields(client(), indexName, categoryFieldsAndTypes); + TestHelpers.ingestDataToIndex(client(), indexName, TestHelpers.toHttpEntity(testIndexData)); + AnomalyDetector detector = createAnomalyDetector( + TestHelpers + .randomAnomalyDetectorUsingCategoryFields( + randomAlphaOfLength(10), + "timestamp", + ImmutableList.of(indexName), + categoryFieldsAndTypes.keySet().stream().collect(Collectors.toList()) + ), + true, + client() + ); + + // Missing start time + Exception missingStartTimeException = expectThrows( + IOException.class, + () -> { searchTopAnomalyResults(detector.getDetectorId(), false, "{\"end_time_ms\":2}", client()); } + ); + assertTrue(missingStartTimeException.getMessage().contains("Must set both start time and end time with epoch of milliseconds")); + + // Missing end time + Exception missingEndTimeException = expectThrows( + IOException.class, + () -> { searchTopAnomalyResults(detector.getDetectorId(), false, "{\"start_time_ms\":1}", client()); } + ); + assertTrue(missingEndTimeException.getMessage().contains("Must set both start time and end time with epoch of milliseconds")); + + // Start time > end time + Exception invalidTimeException = expectThrows( + IOException.class, + () -> { searchTopAnomalyResults(detector.getDetectorId(), false, "{\"start_time_ms\":2, \"end_time_ms\":1}", client()); } + ); + assertTrue(invalidTimeException.getMessage().contains("Start time should be before end time")); + + // Invalid detector ID + Exception invalidDetectorIdException = expectThrows( + IOException.class, + () -> { + searchTopAnomalyResults(detector.getDetectorId() + "-invalid", false, "{\"start_time_ms\":1, \"end_time_ms\":2}", client()); + } + ); + assertTrue(invalidDetectorIdException.getMessage().contains("Can't find detector with id")); + + // Invalid order field + Exception invalidOrderException = expectThrows(IOException.class, () -> { + searchTopAnomalyResults( + detector.getDetectorId(), + false, + "{\"start_time_ms\":1, \"end_time_ms\":2, \"order\":\"invalid-order\"}", + client() + ); + }); + assertTrue(invalidOrderException.getMessage().contains("Ordering by invalid-order is not a valid option")); + + // Negative size field + Exception negativeSizeException = expectThrows( + IOException.class, + () -> { + searchTopAnomalyResults(detector.getDetectorId(), false, "{\"start_time_ms\":1, \"end_time_ms\":2, \"size\":-1}", client()); + } + ); + assertTrue(negativeSizeException.getMessage().contains("Size must be a positive integer")); + + // Zero size field + Exception zeroSizeException = expectThrows( + IOException.class, + () -> { + searchTopAnomalyResults(detector.getDetectorId(), false, "{\"start_time_ms\":1, \"end_time_ms\":2, \"size\":0}", client()); + } + ); + assertTrue(zeroSizeException.getMessage().contains("Size must be a positive integer")); + + // Too large size field + Exception tooLargeSizeException = expectThrows( + IOException.class, + () -> { + searchTopAnomalyResults( + detector.getDetectorId(), + false, + "{\"start_time_ms\":1, \"end_time_ms\":2, \"size\":9999999}", + client() + ); + } + ); + assertTrue(tooLargeSizeException.getMessage().contains("Size cannot exceed")); + + // No existing task ID for detector + Exception noTaskIdException = expectThrows( + IOException.class, + () -> { searchTopAnomalyResults(detector.getDetectorId(), true, "{\"start_time_ms\":1, \"end_time_ms\":2}", client()); } + ); + assertTrue(noTaskIdException.getMessage().contains("No historical tasks found for detector ID " + detector.getDetectorId())); + + // Invalid category fields + Exception invalidCategoryFieldsException = expectThrows(IOException.class, () -> { + searchTopAnomalyResults( + detector.getDetectorId(), + false, + "{\"start_time_ms\":1, \"end_time_ms\":2, \"category_field\":[\"invalid-field\"]}", + client() + ); + }); + assertTrue( + invalidCategoryFieldsException + .getMessage() + .contains("Category field invalid-field doesn't exist for detector ID " + detector.getDetectorId()) + ); + + // Using detector with no category fields + AnomalyDetector detectorWithNoCategoryFields = createAnomalyDetector( + TestHelpers + .randomAnomalyDetectorUsingCategoryFields( + randomAlphaOfLength(10), + "timestamp", + ImmutableList.of(indexName), + ImmutableList.of() + ), + true, + client() + ); + Exception noCategoryFieldsException = expectThrows( + IOException.class, + () -> { + searchTopAnomalyResults( + detectorWithNoCategoryFields.getDetectorId(), + false, + "{\"start_time_ms\":1, \"end_time_ms\":2}", + client() + ); + } + ); + assertTrue( + noCategoryFieldsException + .getMessage() + .contains("No category fields found for detector ID " + detectorWithNoCategoryFields.getDetectorId()) + ); + } + + public void testSearchTopAnomalyResultsOnNonExistentResultIndex() throws IOException { + String indexName = randomAlphaOfLength(10).toLowerCase(); + Map categoryFieldsAndTypes = new HashMap() { + { + put("keyword-field", "keyword"); + put("ip-field", "ip"); + } + }; + String testIndexData = "{\"keyword-field\": \"test-value\"}"; + TestHelpers.createIndexWithHCADFields(client(), indexName, categoryFieldsAndTypes); + TestHelpers.ingestDataToIndex(client(), indexName, TestHelpers.toHttpEntity(testIndexData)); + AnomalyDetector detector = createAnomalyDetector( + TestHelpers + .randomAnomalyDetectorUsingCategoryFields( + randomAlphaOfLength(10), + "timestamp", + ImmutableList.of(indexName), + categoryFieldsAndTypes.keySet().stream().collect(Collectors.toList()) + ), + true, + client() + ); + + // Delete any existing result index + if (indexExists(CommonName.ANOMALY_RESULT_INDEX_ALIAS)) { + deleteIndex(CommonName.ANOMALY_RESULT_INDEX_ALIAS); + } + Response response = searchTopAnomalyResults( + detector.getDetectorId(), + false, + "{\"size\":3,\"category_field\":[\"keyword-field\"]," + "\"start_time_ms\":0, \"end_time_ms\":1}", + client() + ); + Map responseMap = entityAsMap(response); + @SuppressWarnings("unchecked") + List> buckets = (ArrayList>) XContentMapValues.extractValue("buckets", responseMap); + assertEquals(0, buckets.size()); + } + + public void testSearchTopAnomalyResultsOnEmptyResultIndex() throws IOException { + String indexName = randomAlphaOfLength(10).toLowerCase(); + Map categoryFieldsAndTypes = new HashMap() { + { + put("keyword-field", "keyword"); + put("ip-field", "ip"); + } + }; + String testIndexData = "{\"keyword-field\": \"test-value\"}"; + TestHelpers.createIndexWithHCADFields(client(), indexName, categoryFieldsAndTypes); + TestHelpers.ingestDataToIndex(client(), indexName, TestHelpers.toHttpEntity(testIndexData)); + AnomalyDetector detector = createAnomalyDetector( + TestHelpers + .randomAnomalyDetectorUsingCategoryFields( + randomAlphaOfLength(10), + "timestamp", + ImmutableList.of(indexName), + categoryFieldsAndTypes.keySet().stream().collect(Collectors.toList()) + ), + true, + client() + ); + + // Clear any existing result index, create an empty one + if (indexExists(CommonName.ANOMALY_RESULT_INDEX_ALIAS)) { + deleteIndex(CommonName.ANOMALY_RESULT_INDEX_ALIAS); + } + TestHelpers.createEmptyAnomalyResultIndex(client()); + Response response = searchTopAnomalyResults( + detector.getDetectorId(), + false, + "{\"size\":3,\"category_field\":[\"keyword-field\"]," + "\"start_time_ms\":0, \"end_time_ms\":1}", + client() + ); + Map responseMap = entityAsMap(response); + @SuppressWarnings("unchecked") + List> buckets = (ArrayList>) XContentMapValues.extractValue("buckets", responseMap); + assertEquals(0, buckets.size()); + } + + public void testSearchTopAnomalyResultsOnPopulatedResultIndex() throws IOException { + String indexName = randomAlphaOfLength(10).toLowerCase(); + Map categoryFieldsAndTypes = new HashMap() { + { + put("keyword-field", "keyword"); + put("ip-field", "ip"); + } + }; + String testIndexData = "{\"keyword-field\": \"field-1\", \"ip-field\": \"1.2.3.4\", \"timestamp\": 1}"; + TestHelpers.createIndexWithHCADFields(client(), indexName, categoryFieldsAndTypes); + TestHelpers.ingestDataToIndex(client(), indexName, TestHelpers.toHttpEntity(testIndexData)); + AnomalyDetector detector = createAnomalyDetector( + TestHelpers + .randomAnomalyDetectorUsingCategoryFields( + randomAlphaOfLength(10), + "timestamp", + ImmutableList.of(indexName), + categoryFieldsAndTypes.keySet().stream().collect(Collectors.toList()) + ), + true, + client() + ); + + // Ingest some sample results + if (!indexExists(CommonName.ANOMALY_RESULT_INDEX_ALIAS)) { + TestHelpers.createEmptyAnomalyResultIndex(client()); + } + Map entityAttrs1 = new HashMap() { + { + put("keyword-field", "field-1"); + put("ip-field", "1.2.3.4"); + } + }; + Map entityAttrs2 = new HashMap() { + { + put("keyword-field", "field-2"); + put("ip-field", "5.6.7.8"); + } + }; + Map entityAttrs3 = new HashMap() { + { + put("keyword-field", "field-2"); + put("ip-field", "5.6.7.8"); + } + }; + AnomalyResult anomalyResult1 = TestHelpers + .randomHCADAnomalyDetectResult(detector.getDetectorId(), null, entityAttrs1, 0.5, 0.8, null, 5L, 5L); + AnomalyResult anomalyResult2 = TestHelpers + .randomHCADAnomalyDetectResult(detector.getDetectorId(), null, entityAttrs2, 0.5, 0.5, null, 5L, 5L); + AnomalyResult anomalyResult3 = TestHelpers + .randomHCADAnomalyDetectResult(detector.getDetectorId(), null, entityAttrs3, 0.5, 0.2, null, 5L, 5L); + + TestHelpers.ingestDataToIndex(client(), CommonName.ANOMALY_RESULT_INDEX_ALIAS, TestHelpers.toHttpEntity(anomalyResult1)); + TestHelpers.ingestDataToIndex(client(), CommonName.ANOMALY_RESULT_INDEX_ALIAS, TestHelpers.toHttpEntity(anomalyResult2)); + TestHelpers.ingestDataToIndex(client(), CommonName.ANOMALY_RESULT_INDEX_ALIAS, TestHelpers.toHttpEntity(anomalyResult3)); + + // Sorting by severity + Response severityResponse = searchTopAnomalyResults( + detector.getDetectorId(), + false, + "{\"category_field\":[\"keyword-field\"]," + "\"start_time_ms\":0, \"end_time_ms\":10, \"order\":\"severity\"}", + client() + ); + Map severityResponseMap = entityAsMap(severityResponse); + @SuppressWarnings("unchecked") + List> severityBuckets = (ArrayList>) XContentMapValues + .extractValue("buckets", severityResponseMap); + assertEquals(2, severityBuckets.size()); + @SuppressWarnings("unchecked") + Map severityBucketKey1 = (Map) severityBuckets.get(0).get("key"); + @SuppressWarnings("unchecked") + Map severityBucketKey2 = (Map) severityBuckets.get(1).get("key"); + assertEquals("field-1", severityBucketKey1.get("keyword-field")); + assertEquals("field-2", severityBucketKey2.get("keyword-field")); + + // Sorting by occurrence + Response occurrenceResponse = searchTopAnomalyResults( + detector.getDetectorId(), + false, + "{\"category_field\":[\"keyword-field\"]," + "\"start_time_ms\":0, \"end_time_ms\":10, \"order\":\"occurrence\"}", + client() + ); + Map occurrenceResponseMap = entityAsMap(occurrenceResponse); + @SuppressWarnings("unchecked") + List> occurrenceBuckets = (ArrayList>) XContentMapValues + .extractValue("buckets", occurrenceResponseMap); + assertEquals(2, occurrenceBuckets.size()); + @SuppressWarnings("unchecked") + Map occurrenceBucketKey1 = (Map) occurrenceBuckets.get(0).get("key"); + @SuppressWarnings("unchecked") + Map occurrenceBucketKey2 = (Map) occurrenceBuckets.get(1).get("key"); + assertEquals("field-2", occurrenceBucketKey1.get("keyword-field")); + assertEquals("field-1", occurrenceBucketKey2.get("keyword-field")); + + // Sorting using all category fields + Response allFieldsResponse = searchTopAnomalyResults( + detector.getDetectorId(), + false, + "{\"category_field\":[\"keyword-field\", \"ip-field\"]," + "\"start_time_ms\":0, \"end_time_ms\":10, \"order\":\"severity\"}", + client() + ); + Map allFieldsResponseMap = entityAsMap(allFieldsResponse); + @SuppressWarnings("unchecked") + List> allFieldsBuckets = (ArrayList>) XContentMapValues + .extractValue("buckets", allFieldsResponseMap); + assertEquals(2, allFieldsBuckets.size()); + @SuppressWarnings("unchecked") + Map allFieldsBucketKey1 = (Map) allFieldsBuckets.get(0).get("key"); + @SuppressWarnings("unchecked") + Map allFieldsBucketKey2 = (Map) allFieldsBuckets.get(1).get("key"); + assertEquals("field-1", allFieldsBucketKey1.get("keyword-field")); + assertEquals("1.2.3.4", allFieldsBucketKey1.get("ip-field")); + assertEquals("field-2", allFieldsBucketKey2.get("keyword-field")); + assertEquals("5.6.7.8", allFieldsBucketKey2.get("ip-field")); + } + + public void testSearchTopAnomalyResultsWithCustomResultIndex() throws IOException { + String indexName = randomAlphaOfLength(10).toLowerCase(); + String customResultIndexName = CommonName.CUSTOM_RESULT_INDEX_PREFIX + randomAlphaOfLength(5).toLowerCase(); + Map categoryFieldsAndTypes = new HashMap() { + { + put("keyword-field", "keyword"); + put("ip-field", "ip"); + } + }; + String testIndexData = "{\"keyword-field\": \"field-1\", \"ip-field\": \"1.2.3.4\", \"timestamp\": 1}"; + TestHelpers.createIndexWithHCADFields(client(), indexName, categoryFieldsAndTypes); + TestHelpers.ingestDataToIndex(client(), indexName, TestHelpers.toHttpEntity(testIndexData)); + AnomalyDetector detector = createAnomalyDetector( + TestHelpers + .randomAnomalyDetectorUsingCategoryFields( + randomAlphaOfLength(10), + "timestamp", + ImmutableList.of(indexName), + categoryFieldsAndTypes.keySet().stream().collect(Collectors.toList()), + customResultIndexName + ), + true, + client() + ); + + Map entityAttrs = new HashMap() { + { + put("keyword-field", "field-1"); + put("ip-field", "1.2.3.4"); + } + }; + AnomalyResult anomalyResult = TestHelpers + .randomHCADAnomalyDetectResult(detector.getDetectorId(), null, entityAttrs, 0.5, 0.8, null, 5L, 5L); + TestHelpers.ingestDataToIndex(client(), customResultIndexName, TestHelpers.toHttpEntity(anomalyResult)); + + Response response = searchTopAnomalyResults(detector.getDetectorId(), false, "{\"start_time_ms\":0, \"end_time_ms\":10}", client()); + Map responseMap = entityAsMap(response); + @SuppressWarnings("unchecked") + List> buckets = (ArrayList>) XContentMapValues.extractValue("buckets", responseMap); + assertEquals(1, buckets.size()); + @SuppressWarnings("unchecked") + Map bucketKey1 = (Map) buckets.get(0).get("key"); + assertEquals("field-1", bucketKey1.get("keyword-field")); + assertEquals("1.2.3.4", bucketKey1.get("ip-field")); + } } diff --git a/src/test/java/org/opensearch/ad/rest/SecureADRestIT.java b/src/test/java/org/opensearch/ad/rest/SecureADRestIT.java index e45bc4663..530430199 100644 --- a/src/test/java/org/opensearch/ad/rest/SecureADRestIT.java +++ b/src/test/java/org/opensearch/ad/rest/SecureADRestIT.java @@ -50,6 +50,8 @@ public class SecureADRestIT extends AnomalyDetectorRestTestCase { RestClient fishClient; String goatUser = "goat"; RestClient goatClient; + String lionUser = "lion"; + RestClient lionClient; private String indexAllAccessRole = "index_all_access"; private String indexSearchAccessRole = "index_all_search"; @@ -94,9 +96,14 @@ public void setupSecureTests() throws IOException { .setSocketTimeout(60000) .build(); + createUser(lionUser, lionUser, new ArrayList<>(Arrays.asList("opensearch"))); + lionClient = new SecureRestClientBuilder(getClusterHosts().toArray(new HttpHost[0]), isHttps(), lionUser, lionUser) + .setSocketTimeout(60000) + .build(); + createRoleMapping("anomaly_read_access", new ArrayList<>(Arrays.asList(bobUser))); createRoleMapping("anomaly_full_access", new ArrayList<>(Arrays.asList(aliceUser, catUser, dogUser, elkUser, fishUser, goatUser))); - createRoleMapping(indexAllAccessRole, new ArrayList<>(Arrays.asList(aliceUser, bobUser, catUser, dogUser, fishUser))); + createRoleMapping(indexAllAccessRole, new ArrayList<>(Arrays.asList(aliceUser, bobUser, catUser, dogUser, fishUser, lionUser))); createRoleMapping(indexSearchAccessRole, new ArrayList<>(Arrays.asList(goatUser))); } @@ -109,6 +116,7 @@ public void deleteUserSetup() throws IOException { elkClient.close(); fishClient.close(); goatClient.close(); + lionClient.close(); deleteUser(aliceUser); deleteUser(bobUser); deleteUser(catUser); @@ -116,6 +124,7 @@ public void deleteUserSetup() throws IOException { deleteUser(elkUser); deleteUser(fishUser); deleteUser(goatUser); + deleteUser(lionUser); } public void testCreateAnomalyDetectorWithWriteAccess() throws IOException { @@ -378,4 +387,46 @@ public void testPreviewAnomalyDetectorWithNoReadPermissionOfIndex() throws IOExc ); Assert.assertTrue(exception.getMessage().contains("no permissions for [indices:data/read/search]")); } + + public void testValidateAnomalyDetectorWithWriteAccess() throws IOException { + // User Alice has AD full access, should be able to validate a detector + AnomalyDetector aliceDetector = createRandomAnomalyDetector(false, false, aliceClient); + Response validateResponse = validateAnomalyDetector(aliceDetector, aliceClient); + Assert.assertNotNull("User alice validated detector successfully", validateResponse); + } + + public void testValidateAnomalyDetectorWithNoADAccess() throws IOException { + // User Lion has no AD access at all, should not be able to validate a detector + AnomalyDetector detector = TestHelpers.randomAnomalyDetector(null, Instant.now()); + Exception exception = expectThrows(IOException.class, () -> { validateAnomalyDetector(detector, lionClient); }); + Assert.assertTrue(exception.getMessage().contains("no permissions for [cluster:admin/opendistro/ad/detector/validate]")); + + } + + public void testValidateAnomalyDetectorWithReadAccess() throws IOException { + // User Bob has AD read access, should still be able to validate a detector + AnomalyDetector detector = TestHelpers.randomAnomalyDetector(null, Instant.now()); + Response validateResponse = validateAnomalyDetector(detector, bobClient); + Assert.assertNotNull("User bob validated detector successfully", validateResponse); + } + + public void testValidateAnomalyDetectorWithNoReadPermissionOfIndex() throws IOException { + AnomalyDetector detector = TestHelpers.randomAnomalyDetector(null, Instant.now()); + enableFilterBy(); + // User elk has no read permission of index, can't validate detector + Exception exception = expectThrows(Exception.class, () -> { validateAnomalyDetector(detector, elkClient); }); + Assert.assertTrue(exception.getMessage().contains("no permissions for [indices:data/read/search]")); + } + + public void testValidateAnomalyDetectorWithNoBackendRole() throws IOException { + AnomalyDetector detector = TestHelpers.randomAnomalyDetector(null, Instant.now()); + enableFilterBy(); + // User Dog has AD full access, but has no backend role + // When filter by is enabled, we block validating Detectors + Exception exception = expectThrows(IOException.class, () -> { validateAnomalyDetector(detector, dogClient); }); + Assert + .assertTrue( + exception.getMessage().contains("Filter by backend roles is enabled and User dog does not have backend roles configured") + ); + } } diff --git a/src/test/java/org/opensearch/ad/transport/ADTaskProfileTests.java b/src/test/java/org/opensearch/ad/transport/ADTaskProfileTests.java index 303bc662e..a71025acb 100644 --- a/src/test/java/org/opensearch/ad/transport/ADTaskProfileTests.java +++ b/src/test/java/org/opensearch/ad/transport/ADTaskProfileTests.java @@ -31,6 +31,7 @@ import org.opensearch.common.io.stream.BytesStreamOutput; import org.opensearch.common.io.stream.NamedWriteableAwareStreamInput; import org.opensearch.common.io.stream.NamedWriteableRegistry; +import org.opensearch.common.xcontent.ToXContent; import org.opensearch.plugins.Plugin; import org.opensearch.test.InternalSettingsPlugin; import org.opensearch.test.OpenSearchSingleNodeTestCase; @@ -108,11 +109,28 @@ private void testADTaskProfileResponse(ADTaskProfileNodeResponse response) throw response.writeTo(output); NamedWriteableAwareStreamInput input = new NamedWriteableAwareStreamInput(output.bytes().streamInput(), writableRegistry()); ADTaskProfileNodeResponse parsedResponse = ADTaskProfileNodeResponse.readNodeResponse(input); - // if (response.getAdTaskProfile() != null) { - // assertTrue(response.getAdTaskProfile().equals(parsedResponse.getAdTaskProfile())); - // } else { - // assertNull(parsedResponse.getAdTaskProfile()); - // } + if (response.getAdTaskProfile() != null) { + assertTrue(response.getAdTaskProfile().equals(parsedResponse.getAdTaskProfile())); + } else { + assertNull(parsedResponse.getAdTaskProfile()); + } + } + + public void testADTaskProfileParse() throws IOException { + ADTaskProfile adTaskProfile = new ADTaskProfile( + randomAlphaOfLength(5), + randomInt(), + randomLong(), + randomBoolean(), + randomInt(), + randomLong(), + randomAlphaOfLength(5) + ); + String adTaskProfileString = TestHelpers + .xContentBuilderToString(adTaskProfile.toXContent(TestHelpers.builder(), ToXContent.EMPTY_PARAMS)); + ADTaskProfile parsedADTaskProfile = ADTaskProfile.parse(TestHelpers.parser(adTaskProfileString)); + assertEquals(adTaskProfile, parsedADTaskProfile); + assertEquals(parsedADTaskProfile.toString(), adTaskProfile.toString()); } @Ignore diff --git a/src/test/java/org/opensearch/ad/transport/GetAnomalyDetectorTransportActionTests.java b/src/test/java/org/opensearch/ad/transport/GetAnomalyDetectorTransportActionTests.java index 793665051..544ad1ce0 100644 --- a/src/test/java/org/opensearch/ad/transport/GetAnomalyDetectorTransportActionTests.java +++ b/src/test/java/org/opensearch/ad/transport/GetAnomalyDetectorTransportActionTests.java @@ -233,6 +233,7 @@ public void testGetAnomalyDetectorProfileResponse() throws IOException { Map map = TestHelpers.XContentBuilderToMap(builder); Map parsedInitProgress = (Map) (map.get(CommonName.INIT_PROGRESS)); Assert.assertEquals(initProgress.getPercentage(), parsedInitProgress.get(InitProgressProfile.PERCENTAGE).toString()); + assertTrue(initProgress.toString().contains("[percentage=99%,estimated_minutes_left=2,needed_shingles=2]")); Assert .assertEquals( String.valueOf(initProgress.getEstimatedMinutesLeft()), diff --git a/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultActionTests.java b/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultActionTests.java new file mode 100644 index 000000000..7669b8c5b --- /dev/null +++ b/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultActionTests.java @@ -0,0 +1,268 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.transport; + +import static org.opensearch.ad.settings.AnomalyDetectorSettings.BATCH_TASK_PIECE_INTERVAL_SECONDS; +import static org.opensearch.ad.settings.AnomalyDetectorSettings.MAX_BATCH_TASK_PER_NODE; + +import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; + +import org.junit.Before; +import org.opensearch.ad.HistoricalAnalysisIntegTestCase; +import org.opensearch.ad.TestHelpers; +import org.opensearch.ad.model.AnomalyDetector; +import org.opensearch.common.settings.Settings; +import org.opensearch.test.OpenSearchIntegTestCase; + +import com.google.common.collect.ImmutableList; + +// Only invalid test cases are covered here. This is due to issues with the lang-painless module not +// being installed on test clusters spun up in OpenSearchIntegTestCase classes (which this class extends), +// which is needed for executing the API on ingested data. Valid test cases are covered at the REST layer. +@OpenSearchIntegTestCase.ClusterScope(scope = OpenSearchIntegTestCase.Scope.TEST) +public class SearchTopAnomalyResultActionTests extends HistoricalAnalysisIntegTestCase { + + private String testIndex; + private String detectorId; + private String taskId; + private Instant startTime; + private Instant endTime; + private ImmutableList categoryFields; + private String type = "error"; + + @Override + protected Settings nodeSettings(int nodeOrdinal) { + return Settings + .builder() + .put(super.nodeSettings(nodeOrdinal)) + .put(BATCH_TASK_PIECE_INTERVAL_SECONDS.getKey(), 1) + .put(MAX_BATCH_TASK_PER_NODE.getKey(), 1) + .build(); + } + + @Override + @Before + public void setUp() throws Exception { + super.setUp(); + testIndex = "test_data"; + taskId = "test-task-id"; + startTime = Instant.now().minus(10, ChronoUnit.DAYS); + endTime = Instant.now(); + categoryFields = ImmutableList.of("test-field-1", "test-field-2"); + ingestTestData(); + createSystemIndices(); + createAndIndexDetector(); + } + + private void ingestTestData() { + ingestTestData(testIndex, startTime, 1, "test", 1); + } + + private void createSystemIndices() throws IOException { + createDetectorIndex(); + createADResultIndex(); + } + + private void createAndIndexDetector() throws IOException { + AnomalyDetector detector = TestHelpers + .randomAnomalyDetector( + ImmutableList.of(testIndex), + ImmutableList.of(TestHelpers.randomFeature(true)), + null, + Instant.now(), + 1, + false, + categoryFields + ); + detectorId = createDetector(detector); + + } + + public void testInstanceAndNameValid() { + assertNotNull(SearchTopAnomalyResultAction.INSTANCE.name()); + assertEquals(SearchTopAnomalyResultAction.INSTANCE.name(), SearchTopAnomalyResultAction.NAME); + } + + public void testInvalidOrder() { + SearchTopAnomalyResultRequest searchRequest = new SearchTopAnomalyResultRequest( + detectorId, + taskId, + false, + 1, + Arrays.asList(categoryFields.get(0)), + "invalid-order", + startTime, + endTime + ); + expectThrows( + IllegalArgumentException.class, + () -> client().execute(SearchTopAnomalyResultAction.INSTANCE, searchRequest).actionGet(10_000) + ); + } + + public void testNegativeSize() { + SearchTopAnomalyResultRequest searchRequest = new SearchTopAnomalyResultRequest( + detectorId, + taskId, + false, + -1, + Arrays.asList(categoryFields.get(0)), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + startTime, + endTime + ); + expectThrows( + IllegalArgumentException.class, + () -> client().execute(SearchTopAnomalyResultAction.INSTANCE, searchRequest).actionGet(10_000) + ); + } + + public void testZeroSize() { + SearchTopAnomalyResultRequest searchRequest = new SearchTopAnomalyResultRequest( + detectorId, + taskId, + false, + 0, + Arrays.asList(categoryFields.get(0)), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + startTime, + endTime + ); + expectThrows( + IllegalArgumentException.class, + () -> client().execute(SearchTopAnomalyResultAction.INSTANCE, searchRequest).actionGet(10_000) + ); + } + + public void testTooLargeSize() { + SearchTopAnomalyResultRequest searchRequest = new SearchTopAnomalyResultRequest( + detectorId, + taskId, + false, + 9999999, + Arrays.asList(categoryFields.get(0)), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + startTime, + endTime + ); + expectThrows( + IllegalArgumentException.class, + () -> client().execute(SearchTopAnomalyResultAction.INSTANCE, searchRequest).actionGet(10_000) + ); + } + + public void testMissingStartTime() { + SearchTopAnomalyResultRequest searchRequest = new SearchTopAnomalyResultRequest( + detectorId, + taskId, + false, + 1, + Arrays.asList(categoryFields.get(0)), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + null, + endTime + ); + expectThrows( + IllegalArgumentException.class, + () -> client().execute(SearchTopAnomalyResultAction.INSTANCE, searchRequest).actionGet(10_000) + ); + } + + public void testMissingEndTime() { + SearchTopAnomalyResultRequest searchRequest = new SearchTopAnomalyResultRequest( + detectorId, + taskId, + false, + 1, + Arrays.asList(categoryFields.get(0)), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + startTime, + null + ); + expectThrows( + IllegalArgumentException.class, + () -> client().execute(SearchTopAnomalyResultAction.INSTANCE, searchRequest).actionGet(10_000) + ); + } + + public void testInvalidStartAndEndTimes() { + SearchTopAnomalyResultRequest searchRequest = new SearchTopAnomalyResultRequest( + detectorId, + taskId, + false, + 1, + Arrays.asList(categoryFields.get(0)), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + endTime, + startTime + ); + expectThrows( + IllegalArgumentException.class, + () -> client().execute(SearchTopAnomalyResultAction.INSTANCE, searchRequest).actionGet(10_000) + ); + + Instant curTimeInMillis = Instant.now(); + SearchTopAnomalyResultRequest searchRequest2 = new SearchTopAnomalyResultRequest( + detectorId, + taskId, + false, + 1, + Arrays.asList(categoryFields.get(0)), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + curTimeInMillis, + curTimeInMillis + ); + expectThrows( + IllegalArgumentException.class, + () -> client().execute(SearchTopAnomalyResultAction.INSTANCE, searchRequest2).actionGet(10_000) + ); + } + + public void testNoExistingHistoricalTask() throws IOException { + SearchTopAnomalyResultRequest searchRequest = new SearchTopAnomalyResultRequest( + detectorId, + taskId, + true, + 1, + Arrays.asList(categoryFields.get(0)), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + startTime, + endTime + ); + expectThrows(Exception.class, () -> client().execute(SearchTopAnomalyResultAction.INSTANCE, searchRequest).actionGet(10_000)); + } + + public void testSearchOnNonHCDetector() throws IOException { + AnomalyDetector nonHCDetector = TestHelpers + .randomAnomalyDetector( + ImmutableList.of(testIndex), + ImmutableList.of(TestHelpers.randomFeature(true)), + null, + Instant.now(), + 1, + false, + ImmutableList.of() + ); + String nonHCDetectorId = createDetector(nonHCDetector); + SearchTopAnomalyResultRequest searchRequest = new SearchTopAnomalyResultRequest( + nonHCDetectorId, + taskId, + false, + 1, + Arrays.asList(categoryFields.get(0)), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + startTime, + endTime + ); + expectThrows( + IllegalArgumentException.class, + () -> client().execute(SearchTopAnomalyResultAction.INSTANCE, searchRequest).actionGet(10_000) + ); + } +} diff --git a/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultRequestTests.java b/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultRequestTests.java new file mode 100644 index 000000000..c21f444e7 --- /dev/null +++ b/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultRequestTests.java @@ -0,0 +1,189 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.transport; + +import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.Assert; +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.ad.TestHelpers; +import org.opensearch.common.io.stream.BytesStreamOutput; +import org.opensearch.common.io.stream.StreamInput; +import org.opensearch.common.xcontent.XContentBuilder; +import org.opensearch.test.OpenSearchTestCase; + +public class SearchTopAnomalyResultRequestTests extends OpenSearchTestCase { + + public void testSerialization() throws IOException { + SearchTopAnomalyResultRequest originalRequest = new SearchTopAnomalyResultRequest( + "test-detector-id", + "test-task-id", + false, + 1, + Arrays.asList("test-field"), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + Instant.now().minus(10, ChronoUnit.DAYS), + Instant.now().minus(2, ChronoUnit.DAYS) + ); + + BytesStreamOutput output = new BytesStreamOutput(); + originalRequest.writeTo(output); + StreamInput input = output.bytes().streamInput(); + SearchTopAnomalyResultRequest parsedRequest = new SearchTopAnomalyResultRequest(input); + assertEquals(originalRequest.getDetectorId(), parsedRequest.getDetectorId()); + assertEquals(originalRequest.getTaskId(), parsedRequest.getTaskId()); + assertEquals(originalRequest.getHistorical(), parsedRequest.getHistorical()); + assertEquals(originalRequest.getSize(), parsedRequest.getSize()); + assertEquals(originalRequest.getCategoryFields(), parsedRequest.getCategoryFields()); + assertEquals(originalRequest.getOrder(), parsedRequest.getOrder()); + assertEquals(originalRequest.getStartTime(), parsedRequest.getStartTime()); + assertEquals(originalRequest.getEndTime(), parsedRequest.getEndTime()); + } + + public void testParse() throws IOException { + String detectorId = "test-detector-id"; + boolean historical = false; + String taskId = "test-task-id"; + int size = 5; + List categoryFields = Arrays.asList("field-1", "field-2"); + String order = "severity"; + Instant startTime = Instant.ofEpochMilli(1234); + Instant endTime = Instant.ofEpochMilli(5678); + + XContentBuilder xContentBuilder = TestHelpers + .builder() + .startObject() + .field("task_id", taskId) + .field("size", size) + .field("category_field", categoryFields) + .field("order", order) + .field("start_time_ms", startTime.toEpochMilli()) + .field("end_time_ms", endTime.toEpochMilli()) + .endObject(); + + String requestAsXContentString = TestHelpers.xContentBuilderToString(xContentBuilder); + SearchTopAnomalyResultRequest parsedRequest = SearchTopAnomalyResultRequest + .parse(TestHelpers.parser(requestAsXContentString), "test-detector-id", false); + assertEquals(taskId, parsedRequest.getTaskId()); + assertEquals((Integer) size, parsedRequest.getSize()); + assertEquals(categoryFields, parsedRequest.getCategoryFields()); + assertEquals(order, parsedRequest.getOrder()); + assertEquals(startTime.toEpochMilli(), parsedRequest.getStartTime().toEpochMilli()); + assertEquals(endTime.toEpochMilli(), parsedRequest.getEndTime().toEpochMilli()); + assertEquals(detectorId, parsedRequest.getDetectorId()); + assertEquals(historical, parsedRequest.getHistorical()); + } + + public void testNullTaskIdIsValid() { + SearchTopAnomalyResultRequest request = new SearchTopAnomalyResultRequest( + "test-detector-id", + null, + false, + 1, + Arrays.asList("test-field"), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + Instant.now().minus(10, ChronoUnit.DAYS), + Instant.now().minus(2, ChronoUnit.DAYS) + ); + ActionRequestValidationException exception = request.validate(); + Assert.assertNull(exception); + } + + public void testNullSizeIsValid() { + SearchTopAnomalyResultRequest request = new SearchTopAnomalyResultRequest( + "test-detector-id", + "", + false, + null, + Arrays.asList("test-field"), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + Instant.now().minus(10, ChronoUnit.DAYS), + Instant.now().minus(2, ChronoUnit.DAYS) + ); + ActionRequestValidationException exception = request.validate(); + Assert.assertNull(exception); + } + + public void testNullCategoryFieldIsValid() { + SearchTopAnomalyResultRequest request = new SearchTopAnomalyResultRequest( + "test-detector-id", + "", + false, + 1, + null, + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + Instant.now().minus(10, ChronoUnit.DAYS), + Instant.now().minus(2, ChronoUnit.DAYS) + ); + ActionRequestValidationException exception = request.validate(); + Assert.assertNull(exception); + } + + public void testEmptyCategoryFieldIsValid() { + SearchTopAnomalyResultRequest request = new SearchTopAnomalyResultRequest( + "test-detector-id", + "", + false, + 1, + new ArrayList<>(), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + Instant.now().minus(10, ChronoUnit.DAYS), + Instant.now().minus(2, ChronoUnit.DAYS) + ); + ActionRequestValidationException exception = request.validate(); + Assert.assertNull(exception); + } + + public void testEmptyStartTimeIsInvalid() { + SearchTopAnomalyResultRequest request = new SearchTopAnomalyResultRequest( + "test-detector-id", + "", + false, + 1, + new ArrayList<>(), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + null, + Instant.now().minus(2, ChronoUnit.DAYS) + ); + ActionRequestValidationException exception = request.validate(); + Assert.assertNotNull(exception); + } + + public void testEmptyEndTimeIsInvalid() { + SearchTopAnomalyResultRequest request = new SearchTopAnomalyResultRequest( + "test-detector-id", + "", + false, + 1, + new ArrayList<>(), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + Instant.now().minus(10, ChronoUnit.DAYS), + null + ); + ActionRequestValidationException exception = request.validate(); + Assert.assertNotNull(exception); + } + + public void testEndTimeBeforeStartTimeIsInvalid() { + SearchTopAnomalyResultRequest request = new SearchTopAnomalyResultRequest( + "test-detector-id", + "", + false, + 1, + new ArrayList<>(), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + Instant.now().minus(2, ChronoUnit.DAYS), + Instant.now().minus(10, ChronoUnit.DAYS) + ); + ActionRequestValidationException exception = request.validate(); + Assert.assertNotNull(exception); + } +} diff --git a/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultResponseTests.java b/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultResponseTests.java new file mode 100644 index 000000000..841368c36 --- /dev/null +++ b/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultResponseTests.java @@ -0,0 +1,40 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.transport; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; + +import org.opensearch.ad.TestHelpers; +import org.opensearch.common.io.stream.BytesStreamOutput; +import org.opensearch.common.io.stream.StreamInput; +import org.opensearch.test.OpenSearchTestCase; + +public class SearchTopAnomalyResultResponseTests extends OpenSearchTestCase { + + public void testSerialization() throws IOException { + SearchTopAnomalyResultResponse originalResponse = new SearchTopAnomalyResultResponse( + Arrays.asList(TestHelpers.randomAnomalyResultBucket()) + ); + + BytesStreamOutput output = new BytesStreamOutput(); + originalResponse.writeTo(output); + StreamInput input = output.bytes().streamInput(); + SearchTopAnomalyResultResponse parsedResponse = new SearchTopAnomalyResultResponse(input); + assertEquals(originalResponse.getAnomalyResultBuckets(), parsedResponse.getAnomalyResultBuckets()); + } + + public void testEmptyResults() { + SearchTopAnomalyResultResponse response = new SearchTopAnomalyResultResponse(new ArrayList<>()); + } + + public void testPopulatedResults() { + SearchTopAnomalyResultResponse response = new SearchTopAnomalyResultResponse( + Arrays.asList(TestHelpers.randomAnomalyResultBucket()) + ); + } +} diff --git a/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultTransportActionTests.java b/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultTransportActionTests.java new file mode 100644 index 000000000..7d86fd3d1 --- /dev/null +++ b/src/test/java/org/opensearch/ad/transport/SearchTopAnomalyResultTransportActionTests.java @@ -0,0 +1,360 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.transport; + +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.mock; + +import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.mockito.ArgumentCaptor; +import org.mockito.stubbing.Answer; +import org.opensearch.action.ActionListener; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.action.search.SearchResponseSections; +import org.opensearch.action.search.ShardSearchFailure; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.ad.ADIntegTestCase; +import org.opensearch.ad.TestHelpers; +import org.opensearch.ad.constant.CommonName; +import org.opensearch.ad.model.AnomalyResultBucket; +import org.opensearch.ad.transport.handler.ADSearchHandler; +import org.opensearch.client.Client; +import org.opensearch.search.SearchHits; +import org.opensearch.search.aggregations.Aggregation; +import org.opensearch.search.aggregations.Aggregations; +import org.opensearch.search.aggregations.bucket.composite.CompositeAggregation; +import org.opensearch.search.aggregations.metrics.InternalMax; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.transport.TransportService; + +import com.google.common.collect.ImmutableList; + +public class SearchTopAnomalyResultTransportActionTests extends ADIntegTestCase { + private SearchTopAnomalyResultTransportAction action; + + // Helper method to generate the Aggregations obj using the list of result buckets + private Aggregations generateAggregationsFromBuckets(List buckets, Map mockAfterKeyValue) { + List bucketList = new ArrayList<>(); + + for (AnomalyResultBucket bucket : buckets) { + InternalMax maxGradeAgg = mock(InternalMax.class); + when(maxGradeAgg.getName()).thenReturn(AnomalyResultBucket.MAX_ANOMALY_GRADE_FIELD); + when(maxGradeAgg.getValue()).thenReturn(bucket.getMaxAnomalyGrade()); + CompositeAggregation.Bucket aggBucket = mock(CompositeAggregation.Bucket.class); + when(aggBucket.getKey()).thenReturn(bucket.getKey()); + when(aggBucket.getDocCount()).thenReturn((long) bucket.getDocCount()); + when(aggBucket.getAggregations()).thenReturn(new Aggregations(new ArrayList() { + { + add(maxGradeAgg); + } + })); + bucketList.add(aggBucket); + } + + CompositeAggregation composite = mock(CompositeAggregation.class); + when(composite.getName()).thenReturn(SearchTopAnomalyResultTransportAction.MULTI_BUCKETS_FIELD); + when(composite.getBuckets()).thenAnswer((Answer>) invocation -> bucketList); + when(composite.afterKey()).thenReturn(mockAfterKeyValue); + + List aggList = Collections.singletonList(composite); + return new Aggregations(aggList); + } + + // Helper method to generate a SearchResponse obj using the given aggs + private SearchResponse generateMockSearchResponse(Aggregations aggs) { + SearchResponseSections sections = new SearchResponseSections(SearchHits.empty(), aggs, null, false, null, null, 1); + return new SearchResponse(sections, null, 1, 1, 0, 0, ShardSearchFailure.EMPTY_ARRAY, SearchResponse.Clusters.EMPTY); + } + + @Override + @Before + public void setUp() throws Exception { + super.setUp(); + action = new SearchTopAnomalyResultTransportAction( + mock(TransportService.class), + mock(ActionFilters.class), + mock(ADSearchHandler.class), + mock(Client.class) + ); + } + + public void testSearchOnNonExistingResultIndex() throws IOException { + deleteIndexIfExists(CommonName.ANOMALY_RESULT_INDEX_ALIAS); + String testIndexName = randomAlphaOfLength(10).toLowerCase(); + ImmutableList categoryFields = ImmutableList.of("test-field-1", "test-field-2"); + String detectorId = createDetector( + TestHelpers + .randomAnomalyDetector( + ImmutableList.of(testIndexName), + ImmutableList.of(TestHelpers.randomFeature(true)), + null, + Instant.now(), + 1, + false, + categoryFields + ) + ); + SearchTopAnomalyResultRequest searchRequest = new SearchTopAnomalyResultRequest( + detectorId, + null, + false, + 1, + Arrays.asList(categoryFields.get(0)), + SearchTopAnomalyResultTransportAction.OrderType.SEVERITY.getName(), + Instant.now().minus(10, ChronoUnit.DAYS), + Instant.now() + ); + SearchTopAnomalyResultResponse searchResponse = client() + .execute(SearchTopAnomalyResultAction.INSTANCE, searchRequest) + .actionGet(10_000); + assertEquals(searchResponse.getAnomalyResultBuckets().size(), 0); + } + + @SuppressWarnings("unchecked") + public void testListenerWithNullResult() { + ActionListener mockListener = mock(ActionListener.class); + SearchTopAnomalyResultTransportAction.TopAnomalyResultListener listener = action.new TopAnomalyResultListener( + mockListener, new SearchSourceBuilder(), 1000, 10, SearchTopAnomalyResultTransportAction.OrderType.SEVERITY, + "custom-result-index-name" + ); + ArgumentCaptor failureCaptor = ArgumentCaptor.forClass(Exception.class); + + listener.onResponse(null); + + verify(mockListener, times(1)).onFailure(failureCaptor.capture()); + assertTrue(failureCaptor.getValue() != null); + } + + @SuppressWarnings("unchecked") + public void testListenerWithNullAggregation() { + ActionListener mockListener = mock(ActionListener.class); + SearchTopAnomalyResultTransportAction.TopAnomalyResultListener listener = action.new TopAnomalyResultListener( + mockListener, new SearchSourceBuilder(), 1000, 10, SearchTopAnomalyResultTransportAction.OrderType.SEVERITY, + "custom-result-index-name" + ); + + SearchResponse response = generateMockSearchResponse(null); + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(SearchTopAnomalyResultResponse.class); + + listener.onResponse(response); + + verify(mockListener, times(1)).onResponse(responseCaptor.capture()); + SearchTopAnomalyResultResponse capturedResponse = responseCaptor.getValue(); + assertTrue(capturedResponse != null); + assertTrue(capturedResponse.getAnomalyResultBuckets() != null); + assertEquals(0, capturedResponse.getAnomalyResultBuckets().size()); + } + + @SuppressWarnings("unchecked") + public void testListenerWithInvalidAggregation() { + ActionListener mockListener = mock(ActionListener.class); + SearchTopAnomalyResultTransportAction.TopAnomalyResultListener listener = action.new TopAnomalyResultListener( + mockListener, new SearchSourceBuilder(), 1000, 10, SearchTopAnomalyResultTransportAction.OrderType.SEVERITY, + "custom-result-index-name" + ); + + // an empty list won't have an entry for 'MULTI_BUCKETS_FIELD' as needed to parse out + // the expected result buckets, and thus should fail + Aggregations aggs = new Aggregations(new ArrayList<>()); + SearchResponse response = generateMockSearchResponse(aggs); + ArgumentCaptor failureCaptor = ArgumentCaptor.forClass(Exception.class); + + listener.onResponse(response); + + verify(mockListener, times(1)).onFailure(failureCaptor.capture()); + assertTrue(failureCaptor.getValue() != null); + } + + @SuppressWarnings("unchecked") + public void testListenerWithValidEmptyAggregation() { + ActionListener mockListener = mock(ActionListener.class); + SearchTopAnomalyResultTransportAction.TopAnomalyResultListener listener = action.new TopAnomalyResultListener( + mockListener, new SearchSourceBuilder(), 1000, 10, SearchTopAnomalyResultTransportAction.OrderType.SEVERITY, + "custom-result-index-name" + ); + + CompositeAggregation composite = mock(CompositeAggregation.class); + when(composite.getName()).thenReturn(SearchTopAnomalyResultTransportAction.MULTI_BUCKETS_FIELD); + when(composite.getBuckets()).thenReturn(new ArrayList<>()); + when(composite.afterKey()).thenReturn(null); + List aggList = Collections.singletonList(composite); + Aggregations aggs = new Aggregations(aggList); + + SearchResponse response = generateMockSearchResponse(aggs); + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(SearchTopAnomalyResultResponse.class); + + listener.onResponse(response); + + verify(mockListener, times(1)).onResponse(responseCaptor.capture()); + SearchTopAnomalyResultResponse capturedResponse = responseCaptor.getValue(); + assertTrue(capturedResponse != null); + assertTrue(capturedResponse.getAnomalyResultBuckets() != null); + assertEquals(0, capturedResponse.getAnomalyResultBuckets().size()); + } + + @SuppressWarnings("unchecked") + public void testListenerTimesOutWithNoResults() { + ActionListener mockListener = mock(ActionListener.class); + SearchTopAnomalyResultTransportAction.TopAnomalyResultListener listener = action.new TopAnomalyResultListener( + mockListener, new SearchSourceBuilder(), 1000, // this is guaranteed to be an expired timestamp + 10, SearchTopAnomalyResultTransportAction.OrderType.OCCURRENCE, "custom-result-index-name" + ); + + Aggregations aggs = generateAggregationsFromBuckets(new ArrayList<>(), new HashMap() { + { + put("category-field-name-1", "value-2"); + } + }); + SearchResponse response = generateMockSearchResponse(aggs); + ArgumentCaptor failureCaptor = ArgumentCaptor.forClass(Exception.class); + + listener.onResponse(response); + + verify(mockListener, times(1)).onFailure(failureCaptor.capture()); + assertTrue(failureCaptor.getValue() != null); + } + + @SuppressWarnings("unchecked") + public void testListenerTimesOutWithPartialResults() { + ActionListener mockListener = mock(ActionListener.class); + SearchTopAnomalyResultTransportAction.TopAnomalyResultListener listener = action.new TopAnomalyResultListener( + mockListener, new SearchSourceBuilder(), 1000, // this is guaranteed to be an expired timestamp + 10, SearchTopAnomalyResultTransportAction.OrderType.OCCURRENCE, "custom-result-index-name" + ); + + AnomalyResultBucket expectedResponseBucket1 = new AnomalyResultBucket(new HashMap() { + { + put("category-field-name-1", "value-1"); + } + }, 5, 0.2); + + Aggregations aggs = generateAggregationsFromBuckets(new ArrayList() { + { + add(expectedResponseBucket1); + } + }, new HashMap() { + { + put("category-field-name-1", "value-2"); + } + }); + + SearchResponse response = generateMockSearchResponse(aggs); + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(SearchTopAnomalyResultResponse.class); + + listener.onResponse(response); + + verify(mockListener, times(1)).onResponse(responseCaptor.capture()); + SearchTopAnomalyResultResponse capturedResponse = responseCaptor.getValue(); + assertTrue(capturedResponse != null); + assertTrue(capturedResponse.getAnomalyResultBuckets() != null); + assertEquals(1, capturedResponse.getAnomalyResultBuckets().size()); + assertEquals(expectedResponseBucket1, capturedResponse.getAnomalyResultBuckets().get(0)); + } + + @SuppressWarnings("unchecked") + public void testListenerSortingBySeverity() { + ActionListener mockListener = mock(ActionListener.class); + SearchTopAnomalyResultTransportAction.TopAnomalyResultListener listener = action.new TopAnomalyResultListener( + mockListener, new SearchSourceBuilder(), 1000, 10, SearchTopAnomalyResultTransportAction.OrderType.SEVERITY, + "custom-result-index-name" + ); + + AnomalyResultBucket expectedResponseBucket1 = new AnomalyResultBucket(new HashMap() { + { + put("category-field-name-1", "value-1"); + } + }, 5, 0.2); + AnomalyResultBucket expectedResponseBucket2 = new AnomalyResultBucket(new HashMap() { + { + put("category-field-name-1", "value-2"); + } + }, 5, 0.3); + AnomalyResultBucket expectedResponseBucket3 = new AnomalyResultBucket(new HashMap() { + { + put("category-field-name-1", "value-3"); + } + }, 5, 0.1); + + Aggregations aggs = generateAggregationsFromBuckets(new ArrayList() { + { + add(expectedResponseBucket1); + add(expectedResponseBucket2); + add(expectedResponseBucket3); + } + }, null); + + SearchResponse response = generateMockSearchResponse(aggs); + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(SearchTopAnomalyResultResponse.class); + + listener.onResponse(response); + + verify(mockListener, times(1)).onResponse(responseCaptor.capture()); + SearchTopAnomalyResultResponse capturedResponse = responseCaptor.getValue(); + assertTrue(capturedResponse != null); + assertTrue(capturedResponse.getAnomalyResultBuckets() != null); + assertEquals(3, capturedResponse.getAnomalyResultBuckets().size()); + assertEquals(expectedResponseBucket2, capturedResponse.getAnomalyResultBuckets().get(0)); + assertEquals(expectedResponseBucket1, capturedResponse.getAnomalyResultBuckets().get(1)); + assertEquals(expectedResponseBucket3, capturedResponse.getAnomalyResultBuckets().get(2)); + } + + @SuppressWarnings("unchecked") + public void testListenerSortingByOccurrence() { + ActionListener mockListener = mock(ActionListener.class); + SearchTopAnomalyResultTransportAction.TopAnomalyResultListener listener = action.new TopAnomalyResultListener( + mockListener, new SearchSourceBuilder(), 1000, 10, SearchTopAnomalyResultTransportAction.OrderType.OCCURRENCE, + "custom-result-index-name" + ); + + AnomalyResultBucket expectedResponseBucket1 = new AnomalyResultBucket(new HashMap() { + { + put("category-field-name-1", "value-1"); + } + }, 2, 0.5); + AnomalyResultBucket expectedResponseBucket2 = new AnomalyResultBucket(new HashMap() { + { + put("category-field-name-1", "value-2"); + } + }, 3, 0.5); + AnomalyResultBucket expectedResponseBucket3 = new AnomalyResultBucket(new HashMap() { + { + put("category-field-name-1", "value-3"); + } + }, 1, 0.5); + + Aggregations aggs = generateAggregationsFromBuckets(new ArrayList() { + { + add(expectedResponseBucket1); + add(expectedResponseBucket2); + add(expectedResponseBucket3); + } + }, null); + + SearchResponse response = generateMockSearchResponse(aggs); + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(SearchTopAnomalyResultResponse.class); + + listener.onResponse(response); + + verify(mockListener, times(1)).onResponse(responseCaptor.capture()); + SearchTopAnomalyResultResponse capturedResponse = responseCaptor.getValue(); + assertTrue(capturedResponse != null); + assertTrue(capturedResponse.getAnomalyResultBuckets() != null); + assertEquals(3, capturedResponse.getAnomalyResultBuckets().size()); + assertEquals(expectedResponseBucket2, capturedResponse.getAnomalyResultBuckets().get(0)); + assertEquals(expectedResponseBucket1, capturedResponse.getAnomalyResultBuckets().get(1)); + assertEquals(expectedResponseBucket3, capturedResponse.getAnomalyResultBuckets().get(2)); + } +} diff --git a/src/test/java/org/opensearch/ad/transport/ValidateAnomalyDetectorResponseTests.java b/src/test/java/org/opensearch/ad/transport/ValidateAnomalyDetectorResponseTests.java index 91dd2eda3..5082a6e93 100644 --- a/src/test/java/org/opensearch/ad/transport/ValidateAnomalyDetectorResponseTests.java +++ b/src/test/java/org/opensearch/ad/transport/ValidateAnomalyDetectorResponseTests.java @@ -12,6 +12,8 @@ package org.opensearch.ad.transport; import java.io.IOException; +import java.util.HashMap; +import java.util.Map; import org.junit.Test; import org.opensearch.ad.AbstractADTest; @@ -19,20 +21,20 @@ import org.opensearch.ad.model.DetectorValidationIssue; import org.opensearch.common.io.stream.BytesStreamOutput; import org.opensearch.common.io.stream.StreamInput; -import org.opensearch.common.xcontent.ToXContent; public class ValidateAnomalyDetectorResponseTests extends AbstractADTest { @Test public void testResponseSerialization() throws IOException { - DetectorValidationIssue issue = TestHelpers.randomDetectorValidationIssue(); + Map subIssues = new HashMap<>(); + subIssues.put("a", "b"); + subIssues.put("c", "d"); + DetectorValidationIssue issue = TestHelpers.randomDetectorValidationIssueWithSubIssues(subIssues); ValidateAnomalyDetectorResponse response = new ValidateAnomalyDetectorResponse(issue); BytesStreamOutput output = new BytesStreamOutput(); response.writeTo(output); - StreamInput streamInput = output.bytes().streamInput(); ValidateAnomalyDetectorResponse readResponse = ValidateAnomalyDetectorAction.INSTANCE.getResponseReader().read(streamInput); - assertEquals("serialization has the wrong issue", issue, readResponse.getIssue()); } @@ -41,19 +43,36 @@ public void testResponseSerializationWithEmptyIssue() throws IOException { ValidateAnomalyDetectorResponse response = new ValidateAnomalyDetectorResponse((DetectorValidationIssue) null); BytesStreamOutput output = new BytesStreamOutput(); response.writeTo(output); - StreamInput streamInput = output.bytes().streamInput(); ValidateAnomalyDetectorResponse readResponse = ValidateAnomalyDetectorAction.INSTANCE.getResponseReader().read(streamInput); - assertNull("serialization should have empty issue", readResponse.getIssue()); } + public void testResponseToXContentWithSubIssues() throws IOException { + Map subIssues = new HashMap<>(); + subIssues.put("a", "b"); + subIssues.put("c", "d"); + DetectorValidationIssue issue = TestHelpers.randomDetectorValidationIssueWithSubIssues(subIssues); + ValidateAnomalyDetectorResponse response = new ValidateAnomalyDetectorResponse(issue); + String validationResponse = TestHelpers.xContentBuilderToString(response.toXContent(TestHelpers.builder())); + String message = issue.getMessage(); + assertEquals( + "{\"detector\":{\"name\":{\"message\":\"" + message + "\",\"sub_issues\":{\"a\":\"b\",\"c\":\"d\"}}}}", + validationResponse + ); + } + public void testResponseToXContent() throws IOException { DetectorValidationIssue issue = TestHelpers.randomDetectorValidationIssue(); ValidateAnomalyDetectorResponse response = new ValidateAnomalyDetectorResponse(issue); - String validationResponse = TestHelpers - .xContentBuilderToString(response.toXContent(TestHelpers.builder(), ToXContent.EMPTY_PARAMS)); + String validationResponse = TestHelpers.xContentBuilderToString(response.toXContent(TestHelpers.builder())); String message = issue.getMessage(); assertEquals("{\"detector\":{\"name\":{\"message\":\"" + message + "\"}}}", validationResponse); } + + public void testResponseToXContentNull() throws IOException { + ValidateAnomalyDetectorResponse response = new ValidateAnomalyDetectorResponse((DetectorValidationIssue) null); + String validationResponse = TestHelpers.xContentBuilderToString(response.toXContent(TestHelpers.builder())); + assertEquals("{}", validationResponse); + } } diff --git a/src/test/java/org/opensearch/ad/transport/ValidateAnomalyDetectorTransportActionTests.java b/src/test/java/org/opensearch/ad/transport/ValidateAnomalyDetectorTransportActionTests.java index a03b1015d..d694389a5 100644 --- a/src/test/java/org/opensearch/ad/transport/ValidateAnomalyDetectorTransportActionTests.java +++ b/src/test/java/org/opensearch/ad/transport/ValidateAnomalyDetectorTransportActionTests.java @@ -16,6 +16,7 @@ import static org.opensearch.ad.rest.handler.AbstractAnomalyDetectorActionHandler.UNKNOWN_SEARCH_QUERY_EXCEPTION_MSG; import java.io.IOException; +import java.net.URL; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -23,15 +24,20 @@ import org.opensearch.ad.ADIntegTestCase; import org.opensearch.ad.TestHelpers; import org.opensearch.ad.constant.CommonErrorMessages; +import org.opensearch.ad.constant.CommonName; +import org.opensearch.ad.indices.AnomalyDetectionIndices; import org.opensearch.ad.model.AnomalyDetector; import org.opensearch.ad.model.DetectorValidationIssueType; import org.opensearch.ad.model.Feature; import org.opensearch.ad.model.ValidationAspect; +import org.opensearch.ad.settings.AnomalyDetectorSettings; import org.opensearch.common.unit.TimeValue; import org.opensearch.search.aggregations.AggregationBuilder; +import com.google.common.base.Charsets; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.io.Resources; public class ValidateAnomalyDetectorTransportActionTests extends ADIntegTestCase { @@ -55,8 +61,6 @@ public void testValidateAnomalyDetectorWithNoIssue() throws IOException { @Test public void testValidateAnomalyDetectorWithNoIndexFound() throws IOException { AnomalyDetector anomalyDetector = TestHelpers.randomAnomalyDetector(ImmutableMap.of(), Instant.now()); - Instant startTime = Instant.now().minus(1, ChronoUnit.DAYS); - // ingestTestDataValidate(anomalyDetector.getIndices().get(0), startTime, 1, "error"); ValidateAnomalyDetectorRequest request = new ValidateAnomalyDetectorRequest( anomalyDetector, ValidationAspect.DETECTOR.getName(), @@ -261,4 +265,174 @@ public void testValidateAnomalyDetectorWithMultipleInvalidFeatureField() throws assertTrue(response.getIssue().getSubIssues().containsKey(maxFeature.getName())); assertTrue(FEATURE_WITH_INVALID_QUERY_MSG.contains(response.getIssue().getSubIssues().get(maxFeature.getName()))); } + + @Test + public void testValidateAnomalyDetectorWithCustomResultIndex() throws IOException { + String resultIndex = CommonName.CUSTOM_RESULT_INDEX_PREFIX + "test"; + createCustomADResultIndex(resultIndex); + AnomalyDetector anomalyDetector = TestHelpers + .randomDetector( + ImmutableList.of(TestHelpers.randomFeature()), + randomAlphaOfLength(5).toLowerCase(), + randomIntBetween(1, 5), + randomAlphaOfLength(5), + null, + resultIndex + ); + Instant startTime = Instant.now().minus(1, ChronoUnit.DAYS); + ingestTestDataValidate(anomalyDetector.getIndices().get(0), startTime, 1, "error"); + ValidateAnomalyDetectorRequest request = new ValidateAnomalyDetectorRequest( + anomalyDetector, + ValidationAspect.DETECTOR.getName(), + 5, + 5, + 5, + new TimeValue(5_000L) + ); + ValidateAnomalyDetectorResponse response = client().execute(ValidateAnomalyDetectorAction.INSTANCE, request).actionGet(5_000); + assertNull(response.getIssue()); + } + + @Test + public void testValidateAnomalyDetectorWithCustomResultIndexCreated() throws IOException { + testValidateAnomalyDetectorWithCustomResultIndex(true); + } + + @Test + public void testValidateAnomalyDetectorWithCustomResultIndexPresentButNotCreated() throws IOException { + testValidateAnomalyDetectorWithCustomResultIndex(false); + + } + + @Test + public void testValidateAnomalyDetectorWithCustomResultIndexWithInvalidMapping() throws IOException { + String resultIndex = CommonName.CUSTOM_RESULT_INDEX_PREFIX + "test"; + URL url = AnomalyDetectionIndices.class.getClassLoader().getResource("mappings/checkpoint.json"); + createIndex(resultIndex, Resources.toString(url, Charsets.UTF_8)); + AnomalyDetector anomalyDetector = TestHelpers + .randomDetector( + ImmutableList.of(TestHelpers.randomFeature()), + randomAlphaOfLength(5).toLowerCase(), + randomIntBetween(1, 5), + randomAlphaOfLength(5), + null, + resultIndex + ); + Instant startTime = Instant.now().minus(1, ChronoUnit.DAYS); + ingestTestDataValidate(anomalyDetector.getIndices().get(0), startTime, 1, "error"); + ValidateAnomalyDetectorRequest request = new ValidateAnomalyDetectorRequest( + anomalyDetector, + ValidationAspect.DETECTOR.getName(), + 5, + 5, + 5, + new TimeValue(5_000L) + ); + ValidateAnomalyDetectorResponse response = client().execute(ValidateAnomalyDetectorAction.INSTANCE, request).actionGet(5_000); + assertEquals(DetectorValidationIssueType.RESULT_INDEX, response.getIssue().getType()); + assertEquals(ValidationAspect.DETECTOR, response.getIssue().getAspect()); + assertTrue(response.getIssue().getMessage().contains(CommonErrorMessages.INVALID_RESULT_INDEX_MAPPING)); + } + + private void testValidateAnomalyDetectorWithCustomResultIndex(boolean resultIndexCreated) throws IOException { + String resultIndex = CommonName.CUSTOM_RESULT_INDEX_PREFIX + "test"; + if (resultIndexCreated) { + createCustomADResultIndex(resultIndex); + } + AnomalyDetector anomalyDetector = TestHelpers + .randomDetector( + ImmutableList.of(TestHelpers.randomFeature()), + randomAlphaOfLength(5).toLowerCase(), + randomIntBetween(1, 5), + randomAlphaOfLength(5), + null, + resultIndex + ); + Instant startTime = Instant.now().minus(1, ChronoUnit.DAYS); + ingestTestDataValidate(anomalyDetector.getIndices().get(0), startTime, 1, "error"); + ValidateAnomalyDetectorRequest request = new ValidateAnomalyDetectorRequest( + anomalyDetector, + ValidationAspect.DETECTOR.getName(), + 5, + 5, + 5, + new TimeValue(5_000L) + ); + ValidateAnomalyDetectorResponse response = client().execute(ValidateAnomalyDetectorAction.INSTANCE, request).actionGet(5_000); + assertNull(response.getIssue()); + } + + @Test + public void testValidateAnomalyDetectorWithInvalidDetectorName() throws IOException { + AnomalyDetector anomalyDetector = new AnomalyDetector( + randomAlphaOfLength(5), + randomLong(), + "#$32", + randomAlphaOfLength(5), + randomAlphaOfLength(5), + ImmutableList.of(randomAlphaOfLength(5).toLowerCase()), + ImmutableList.of(TestHelpers.randomFeature()), + TestHelpers.randomQuery(), + TestHelpers.randomIntervalTimeConfiguration(), + TestHelpers.randomIntervalTimeConfiguration(), + AnomalyDetectorSettings.DEFAULT_SHINGLE_SIZE, + null, + 1, + Instant.now(), + null, + TestHelpers.randomUser(), + null + ); + Instant startTime = Instant.now().minus(1, ChronoUnit.DAYS); + ingestTestDataValidate(anomalyDetector.getIndices().get(0), startTime, 1, "error"); + ValidateAnomalyDetectorRequest request = new ValidateAnomalyDetectorRequest( + anomalyDetector, + ValidationAspect.DETECTOR.getName(), + 5, + 5, + 5, + new TimeValue(5_000L) + ); + ValidateAnomalyDetectorResponse response = client().execute(ValidateAnomalyDetectorAction.INSTANCE, request).actionGet(5_000); + assertEquals(DetectorValidationIssueType.NAME, response.getIssue().getType()); + assertEquals(ValidationAspect.DETECTOR, response.getIssue().getAspect()); + assertEquals(CommonErrorMessages.INVALID_DETECTOR_NAME, response.getIssue().getMessage()); + } + + @Test + public void testValidateAnomalyDetectorWithDetectorNameTooLong() throws IOException { + AnomalyDetector anomalyDetector = new AnomalyDetector( + randomAlphaOfLength(5), + randomLong(), + "abababababababababababababababababababababababababababababababababababababababababababababababab", + randomAlphaOfLength(5), + randomAlphaOfLength(5), + ImmutableList.of(randomAlphaOfLength(5).toLowerCase()), + ImmutableList.of(TestHelpers.randomFeature()), + TestHelpers.randomQuery(), + TestHelpers.randomIntervalTimeConfiguration(), + TestHelpers.randomIntervalTimeConfiguration(), + AnomalyDetectorSettings.DEFAULT_SHINGLE_SIZE, + null, + 1, + Instant.now(), + null, + TestHelpers.randomUser(), + null + ); + Instant startTime = Instant.now().minus(1, ChronoUnit.DAYS); + ingestTestDataValidate(anomalyDetector.getIndices().get(0), startTime, 1, "error"); + ValidateAnomalyDetectorRequest request = new ValidateAnomalyDetectorRequest( + anomalyDetector, + ValidationAspect.DETECTOR.getName(), + 5, + 5, + 5, + new TimeValue(5_000L) + ); + ValidateAnomalyDetectorResponse response = client().execute(ValidateAnomalyDetectorAction.INSTANCE, request).actionGet(5_000); + assertEquals(DetectorValidationIssueType.NAME, response.getIssue().getType()); + assertEquals(ValidationAspect.DETECTOR, response.getIssue().getAspect()); + assertTrue(response.getIssue().getMessage().contains("Name should be shortened. The maximum limit is")); + } } diff --git a/src/test/java/org/opensearch/ad/util/ParseUtilsTests.java b/src/test/java/org/opensearch/ad/util/ParseUtilsTests.java index 54ad0a522..1e354f19e 100644 --- a/src/test/java/org/opensearch/ad/util/ParseUtilsTests.java +++ b/src/test/java/org/opensearch/ad/util/ParseUtilsTests.java @@ -21,6 +21,7 @@ import org.opensearch.ad.common.exception.AnomalyDetectionException; import org.opensearch.ad.model.AnomalyDetector; import org.opensearch.ad.model.Feature; +import org.opensearch.common.ParsingException; import org.opensearch.common.xcontent.XContentBuilder; import org.opensearch.common.xcontent.XContentFactory; import org.opensearch.common.xcontent.XContentParser; @@ -85,6 +86,26 @@ public void testParseAggregatorsWithAggregationQueryString() throws IOException assertEquals("test", agg.getAggregatorFactories().iterator().next().getName()); } + public void testParseAggregatorsWithInvalidAggregationName() throws IOException { + XContentParser parser = ParseUtils.parser("{\"aa\":{\"value_count\":{\"field\":\"ok\"}}}", TestHelpers.xContentRegistry()); + Exception ex = expectThrows(ParsingException.class, () -> ParseUtils.parseAggregators(parser, 0, "#@?><:{")); + assertTrue(ex.getMessage().contains("Aggregation names must be alpha-numeric and can only contain '_' and '-'")); + } + + public void testParseAggregatorsWithTwoAggregationTypes() throws IOException { + XContentParser parser = ParseUtils + .parser("{\"test\":{\"avg\":{\"field\":\"value\"},\"sum\":{\"field\":\"value\"}}}", TestHelpers.xContentRegistry()); + Exception ex = expectThrows(ParsingException.class, () -> ParseUtils.parseAggregators(parser, 0, "test")); + assertTrue(ex.getMessage().contains("Found two aggregation type definitions in")); + } + + public void testParseAggregatorsWithNullAggregationDefinition() throws IOException { + String aggName = "test"; + XContentParser parser = ParseUtils.parser("{\"test\":{}}", TestHelpers.xContentRegistry()); + Exception ex = expectThrows(ParsingException.class, () -> ParseUtils.parseAggregators(parser, 0, aggName)); + assertTrue(ex.getMessage().contains("Missing definition for aggregation [" + aggName + "]")); + } + public void testParseAggregatorsWithAggregationQueryStringAndNullAggName() throws IOException { AggregatorFactories.Builder agg = ParseUtils .parseAggregators("{\"aa\":{\"value_count\":{\"field\":\"ok\"}}}", TestHelpers.xContentRegistry(), null); diff --git a/src/test/resources/job-scheduler/opensearch-job-scheduler-1.2.0.0-SNAPSHOT.zip b/src/test/resources/job-scheduler/opensearch-job-scheduler-1.2.0.0-SNAPSHOT.zip deleted file mode 100644 index 53c0668a1..000000000 Binary files a/src/test/resources/job-scheduler/opensearch-job-scheduler-1.2.0.0-SNAPSHOT.zip and /dev/null differ diff --git a/src/test/resources/job-scheduler/opensearch-job-scheduler-1.3.0.0-SNAPSHOT.zip b/src/test/resources/job-scheduler/opensearch-job-scheduler-1.3.0.0-SNAPSHOT.zip new file mode 100644 index 000000000..5b1d925c0 Binary files /dev/null and b/src/test/resources/job-scheduler/opensearch-job-scheduler-1.3.0.0-SNAPSHOT.zip differ diff --git a/src/test/resources/org/opensearch/ad/bwc/job-scheduler/1.3.0.0-SNAPSHOT/opensearch-job-scheduler-1.3.0.0-SNAPSHOT.zip b/src/test/resources/org/opensearch/ad/bwc/job-scheduler/1.3.0.0-SNAPSHOT/opensearch-job-scheduler-1.3.0.0-SNAPSHOT.zip new file mode 100644 index 000000000..5b1d925c0 Binary files /dev/null and b/src/test/resources/org/opensearch/ad/bwc/job-scheduler/1.3.0.0-SNAPSHOT/opensearch-job-scheduler-1.3.0.0-SNAPSHOT.zip differ