From 95119284d6497bcec4dee11790b058beda7a6f8e Mon Sep 17 00:00:00 2001 From: thecodingshrimp Date: Tue, 9 Jun 2026 12:01:10 +0200 Subject: [PATCH 1/6] Fix ClassCastException from immutable map returned by StreamInput.readMap() StreamInput.readMap() documents that empty maps 'might be immutable'. Replace all unsafe suppressWarning() unchecked casts and raw readMap() casts with safe toMutableMap() copies in deserialization constructors across all affected alerting model classes. Fixed files: - Monitor.kt: uiMetadata deserialization - Alert.kt: queryResults deserialization - MonitorMetadata.kt: lastRunContext and sourceToQueryIndexMapping - IndexExecutionContext.kt: lastRunContext, updatedLastRunContext - DocLevelMonitorFanOutResponse.kt: lastRunContexts; remove suppressWarning helper - WorkflowRunResult.kt: triggerResults; remove suppressWarning helper This prevents the kotlin.collections.EmptyMap cannot be cast to kotlin.collections.MutableMap exception when transport-deserializing monitors with empty map fields. Fixes opensearch-project/security-analytics#1715 Signed-off-by: thecodingshrimp --- .../action/DocLevelMonitorFanOutResponse.kt | 13 ++++--------- .../org/opensearch/commons/alerting/model/Alert.kt | 3 +-- .../commons/alerting/model/IndexExecutionContext.kt | 4 ++-- .../opensearch/commons/alerting/model/Monitor.kt | 7 +------ .../commons/alerting/model/MonitorMetadata.kt | 4 ++-- .../commons/alerting/model/WorkflowRunResult.kt | 8 +------- 6 files changed, 11 insertions(+), 28 deletions(-) diff --git a/src/main/kotlin/org/opensearch/commons/alerting/action/DocLevelMonitorFanOutResponse.kt b/src/main/kotlin/org/opensearch/commons/alerting/action/DocLevelMonitorFanOutResponse.kt index 6e5cde551..a2ca4a4b9 100644 --- a/src/main/kotlin/org/opensearch/commons/alerting/action/DocLevelMonitorFanOutResponse.kt +++ b/src/main/kotlin/org/opensearch/commons/alerting/action/DocLevelMonitorFanOutResponse.kt @@ -25,14 +25,16 @@ class DocLevelMonitorFanOutResponse : ActionResponse, ToXContentObject { val triggerResults: Map val exception: AlertingException? + @Suppress("UNCHECKED_CAST") @Throws(IOException::class) constructor(sin: StreamInput) : this( nodeId = sin.readString(), executionId = sin.readString(), monitorId = sin.readString(), - lastRunContexts = sin.readMap()!! as MutableMap, + lastRunContexts = sin.readMap()?.toMutableMap() ?: mutableMapOf(), inputResults = InputRunResults.readFrom(sin), - triggerResults = suppressWarning(sin.readMap(StreamInput::readString, DocumentLevelTriggerRunResult::readFrom)), + triggerResults = (sin.readMap(StreamInput::readString, DocumentLevelTriggerRunResult::readFrom) ?: mapOf()) + as Map, exception = sin.readException() ) @@ -82,11 +84,4 @@ class DocLevelMonitorFanOutResponse : ActionResponse, ToXContentObject { .endObject() return builder } - - companion object { - @Suppress("UNCHECKED_CAST") - fun suppressWarning(map: MutableMap?): Map { - return map as Map - } - } } diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/Alert.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/Alert.kt index f0fa69c9a..c10094226 100644 --- a/src/main/kotlin/org/opensearch/commons/alerting/model/Alert.kt +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/Alert.kt @@ -3,7 +3,6 @@ package org.opensearch.commons.alerting.model import org.opensearch.Version import org.opensearch.common.lucene.uid.Versions import org.opensearch.commons.alerting.alerts.AlertError -import org.opensearch.commons.alerting.model.Monitor.Companion.suppressWarning import org.opensearch.commons.alerting.util.IndexUtils.Companion.NO_SCHEMA_VERSION import org.opensearch.commons.alerting.util.instant import org.opensearch.commons.alerting.util.optionalTimeField @@ -423,7 +422,7 @@ data class Alert( null }, queryResults = if (sin.version.onOrAfter(Version.V_3_7_0)) { - sin.readList { input -> suppressWarning(input.readMap()) } + sin.readList { input -> input.readMap()?.toMutableMap() ?: mutableMapOf() } } else { listOf() }, diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/IndexExecutionContext.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/IndexExecutionContext.kt index 6b104d135..3dca8d6e0 100644 --- a/src/main/kotlin/org/opensearch/commons/alerting/model/IndexExecutionContext.kt +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/IndexExecutionContext.kt @@ -29,8 +29,8 @@ data class IndexExecutionContext( @Throws(IOException::class) constructor(sin: StreamInput) : this( queries = sin.readList { DocLevelQuery(sin) }, - lastRunContext = sin.readMap() as MutableMap, - updatedLastRunContext = sin.readMap() as MutableMap, + lastRunContext = sin.readMap()?.toMutableMap() ?: mutableMapOf(), + updatedLastRunContext = sin.readMap()?.toMutableMap() ?: mutableMapOf(), indexName = sin.readString(), concreteIndexName = sin.readString(), updatedIndexNames = sin.readStringList(), diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/Monitor.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/Monitor.kt index d26b2b9d0..d3b90fc40 100644 --- a/src/main/kotlin/org/opensearch/commons/alerting/model/Monitor.kt +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/Monitor.kt @@ -131,7 +131,7 @@ data class Monitor( schemaVersion = sin.readInt(), inputs = sin.readList((Input)::readFrom), triggers = sin.readList((Trigger)::readFrom), - uiMetadata = suppressWarning(sin.readMap()), + uiMetadata = sin.readMap()?.toMutableMap() ?: mutableMapOf(), dataSources = if (sin.readBoolean()) { DataSources(sin) } else { @@ -480,10 +480,5 @@ data class Monitor( fun readFrom(sin: StreamInput): Monitor? { return Monitor(sin) } - - @Suppress("UNCHECKED_CAST") - fun suppressWarning(map: MutableMap?): MutableMap { - return map as MutableMap - } } } diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/MonitorMetadata.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/MonitorMetadata.kt index a90f3cc3f..75ef8eebc 100644 --- a/src/main/kotlin/org/opensearch/commons/alerting/model/MonitorMetadata.kt +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/MonitorMetadata.kt @@ -36,8 +36,8 @@ data class MonitorMetadata( primaryTerm = sin.readLong(), monitorId = sin.readString(), lastActionExecutionTimes = sin.readList(ActionExecutionTime.Companion::readFrom), - lastRunContext = Monitor.suppressWarning(sin.readMap()), - sourceToQueryIndexMapping = sin.readMap() as MutableMap + lastRunContext = sin.readMap()?.toMutableMap() ?: mutableMapOf(), + sourceToQueryIndexMapping = (sin.readMap()?.toMutableMap() ?: mutableMapOf()) as MutableMap ) override fun writeTo(out: StreamOutput) { diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/WorkflowRunResult.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/WorkflowRunResult.kt index 1b5fe3d82..a15ed3fb3 100644 --- a/src/main/kotlin/org/opensearch/commons/alerting/model/WorkflowRunResult.kt +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/WorkflowRunResult.kt @@ -26,7 +26,6 @@ data class WorkflowRunResult( ) : Writeable, ToXContent { @Throws(IOException::class) - @Suppress("UNCHECKED_CAST") constructor(sin: StreamInput) : this( workflowId = sin.readString(), workflowName = sin.readString(), @@ -35,7 +34,7 @@ data class WorkflowRunResult( executionEndTime = sin.readOptionalInstant(), executionId = sin.readString(), error = sin.readException(), - triggerResults = suppressWarning(sin.readMap()) as Map + triggerResults = (sin.readMap()?.toMutableMap() ?: mutableMapOf()) as Map ) override fun writeTo(out: StreamOutput) { @@ -73,10 +72,5 @@ data class WorkflowRunResult( fun readFrom(sin: StreamInput): WorkflowRunResult { return WorkflowRunResult(sin) } - - @Suppress("UNCHECKED_CAST") - fun suppressWarning(map: MutableMap?): Map { - return map as Map - } } } From 9e0e0297001a876a95c7befee7245802812d72c9 Mon Sep 17 00:00:00 2001 From: thecodingshrimp Date: Tue, 9 Jun 2026 13:32:46 +0200 Subject: [PATCH 2/6] Add missing @Suppress annotation on WorkflowRunResult unchecked cast The constructor-level @Suppress("UNCHECKED_CAST") was missing even though the deserialization expression contains an explicit unchecked cast of a Map to Map. Kotlin requires the annotation to acknowledge the cast is intentionally unsafe; omitting it produces a compiler warning. The cast is safe in practice because all values were written as ChainedAlertTriggerRunResult instances by writeTo(). Signed-off-by: thecodingshrimp --- .../org/opensearch/commons/alerting/model/WorkflowRunResult.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/WorkflowRunResult.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/WorkflowRunResult.kt index a15ed3fb3..4bf27b422 100644 --- a/src/main/kotlin/org/opensearch/commons/alerting/model/WorkflowRunResult.kt +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/WorkflowRunResult.kt @@ -25,6 +25,7 @@ data class WorkflowRunResult( val triggerResults: Map = mapOf() ) : Writeable, ToXContent { + @Suppress("UNCHECKED_CAST") @Throws(IOException::class) constructor(sin: StreamInput) : this( workflowId = sin.readString(), From 504f10c5da6cd9386e8fac65bae5f780e54abcaa Mon Sep 17 00:00:00 2001 From: thecodingshrimp Date: Tue, 9 Jun 2026 13:46:24 +0200 Subject: [PATCH 3/6] Add regression tests for immutable-map deserialization ClassCastException StreamInput.readMap() may return Collections.emptyMap() (immutable) for zero-size maps. Before this fix, several deserialization constructors assumed the result was always mutable, causing ClassCastException or UnsupportedOperationException at runtime. Tests cover the empty-map (regression) path for all six fixed call sites: - Monitor.uiMetadata - MonitorMetadata.lastRunContext + sourceToQueryIndexMapping - IndexExecutionContext.lastRunContext + updatedLastRunContext - DocLevelMonitorFanOutResponse.lastRunContexts - WorkflowRunResult.triggerResults - Alert.queryResults (each element map) Each test serializes an object with an empty map via writeTo(), deserializes via the StreamInput constructor, and asserts the result is correct and the map is mutable. Signed-off-by: thecodingshrimp --- .../ImmutableMapDeserializationTests.kt | 346 ++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 src/test/kotlin/org/opensearch/commons/alerting/ImmutableMapDeserializationTests.kt diff --git a/src/test/kotlin/org/opensearch/commons/alerting/ImmutableMapDeserializationTests.kt b/src/test/kotlin/org/opensearch/commons/alerting/ImmutableMapDeserializationTests.kt new file mode 100644 index 000000000..aa1341a79 --- /dev/null +++ b/src/test/kotlin/org/opensearch/commons/alerting/ImmutableMapDeserializationTests.kt @@ -0,0 +1,346 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.commons.alerting + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.opensearch.common.io.stream.BytesStreamOutput +import org.opensearch.common.settings.Settings +import org.opensearch.commons.alerting.action.DocLevelMonitorFanOutResponse +import org.opensearch.commons.alerting.model.ActionExecutionTime +import org.opensearch.commons.alerting.model.Alert +import org.opensearch.commons.alerting.model.ChainedAlertTriggerRunResult +import org.opensearch.commons.alerting.model.IndexExecutionContext +import org.opensearch.commons.alerting.model.InputRunResults +import org.opensearch.commons.alerting.model.Monitor +import org.opensearch.commons.alerting.model.MonitorMetadata +import org.opensearch.commons.alerting.model.WorkflowRunResult +import org.opensearch.core.common.io.stream.NamedWriteableAwareStreamInput +import org.opensearch.core.common.io.stream.NamedWriteableRegistry +import org.opensearch.core.common.io.stream.StreamInput +import org.opensearch.search.SearchModule +import java.time.Instant +import java.time.temporal.ChronoUnit + +/** + * Regression tests for StreamInput.readMap() immutable-map ClassCastException. + * + * StreamInput.readMap() Javadoc: "If the returned map contains any entries it will be + * mutable. If it is empty it might be immutable." — meaning Collections.emptyMap() is + * returned for zero-size maps. Any code that casts the result to MutableMap without a + * defensive copy throws ClassCastException (or UnsupportedOperationException on write) + * when the deserialized map is empty. + * + * Each test exercises the empty-map path: serialize an object with empty maps, deserialize + * it, then assert the round-trip succeeded and the result is mutable. + * + * See: opensearch-project/common-utils#967 + */ +class ImmutableMapDeserializationTests { + + // ------------------------------------------------------------------------- + // Monitor.uiMetadata + // ------------------------------------------------------------------------- + + @Test + fun `Monitor with empty uiMetadata survives serialization round-trip`() { + val monitor = randomQueryLevelMonitor(withMetadata = false) // uiMetadata = mapOf() + assertTrue(monitor.uiMetadata.isEmpty()) + + val out = BytesStreamOutput() + monitor.writeTo(out) + val registry = NamedWriteableRegistry(SearchModule(Settings.EMPTY, emptyList()).namedWriteables) + val sin = NamedWriteableAwareStreamInput(StreamInput.wrap(out.bytes().toBytesRef().bytes), registry) + val deserialized = Monitor(sin) + + assertEquals(monitor.name, deserialized.name) + assertTrue(deserialized.uiMetadata.isEmpty()) + // Verify mutability — must not throw UnsupportedOperationException + assertMutableMap(deserialized.uiMetadata) + } + + @Test + fun `Monitor with non-empty uiMetadata survives serialization round-trip`() { + val monitor = randomQueryLevelMonitor(withMetadata = true) // uiMetadata = mapOf("foo" to "bar") + assertTrue(monitor.uiMetadata.isNotEmpty()) + + val out = BytesStreamOutput() + monitor.writeTo(out) + val registry = NamedWriteableRegistry(SearchModule(Settings.EMPTY, emptyList()).namedWriteables) + val sin = NamedWriteableAwareStreamInput(StreamInput.wrap(out.bytes().toBytesRef().bytes), registry) + val deserialized = Monitor(sin) + + assertEquals(monitor.uiMetadata, deserialized.uiMetadata) + } + + // ------------------------------------------------------------------------- + // MonitorMetadata.lastRunContext + sourceToQueryIndexMapping + // ------------------------------------------------------------------------- + + @Test + fun `MonitorMetadata with empty lastRunContext survives serialization round-trip`() { + val metadata = MonitorMetadata( + id = "monitor-id-metadata", + monitorId = "monitor-id", + lastActionExecutionTimes = emptyList(), + lastRunContext = emptyMap(), + sourceToQueryIndexMapping = mutableMapOf() + ) + + val out = BytesStreamOutput() + metadata.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val deserialized = MonitorMetadata(sin) + + assertEquals(metadata.id, deserialized.id) + assertTrue(deserialized.lastRunContext.isEmpty()) + assertTrue(deserialized.sourceToQueryIndexMapping.isEmpty()) + // Verify mutability of lastRunContext + assertMutableMap(deserialized.lastRunContext) + // Verify mutability of sourceToQueryIndexMapping + deserialized.sourceToQueryIndexMapping["idx"] = "query-idx" + } + + @Test + fun `MonitorMetadata with non-empty maps survives serialization round-trip`() { + val metadata = MonitorMetadata( + id = "monitor-id-metadata", + monitorId = "monitor-id", + lastActionExecutionTimes = listOf( + ActionExecutionTime("action-1", Instant.now().truncatedTo(ChronoUnit.MILLIS)) + ), + lastRunContext = mapOf("index" to mapOf("0" to "1234")), + sourceToQueryIndexMapping = mutableMapOf("my-index-monitor-id" to "query-index") + ) + + val out = BytesStreamOutput() + metadata.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val deserialized = MonitorMetadata(sin) + + assertEquals(metadata.lastRunContext, deserialized.lastRunContext) + assertEquals(metadata.sourceToQueryIndexMapping, deserialized.sourceToQueryIndexMapping) + } + + // ------------------------------------------------------------------------- + // IndexExecutionContext.lastRunContext + updatedLastRunContext + // ------------------------------------------------------------------------- + + @Test + fun `IndexExecutionContext with empty lastRunContext survives serialization round-trip`() { + val ctx = IndexExecutionContext( + queries = emptyList(), + lastRunContext = mutableMapOf(), + updatedLastRunContext = mutableMapOf(), + indexName = "my-index", + concreteIndexName = "my-index-000001", + updatedIndexNames = emptyList(), + concreteIndexNames = emptyList(), + conflictingFields = emptyList(), + docIds = emptyList(), + findingIds = emptyList() + ) + + val out = BytesStreamOutput() + ctx.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val deserialized = IndexExecutionContext(sin) + + assertEquals(ctx.indexName, deserialized.indexName) + assertTrue(deserialized.lastRunContext.isEmpty()) + assertTrue(deserialized.updatedLastRunContext.isEmpty()) + // Verify mutability + assertMutableMap(deserialized.lastRunContext) + assertMutableMap(deserialized.updatedLastRunContext) + } + + @Test + fun `IndexExecutionContext with non-empty context maps survives serialization round-trip`() { + val ctx = IndexExecutionContext( + queries = emptyList(), + lastRunContext = mutableMapOf("shard0" to "seq100"), + updatedLastRunContext = mutableMapOf("shard0" to "seq101"), + indexName = "my-index", + concreteIndexName = "my-index-000001", + updatedIndexNames = listOf("my-index"), + concreteIndexNames = listOf("my-index-000001"), + conflictingFields = emptyList() + ) + + val out = BytesStreamOutput() + ctx.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val deserialized = IndexExecutionContext(sin) + + assertEquals(ctx.lastRunContext, deserialized.lastRunContext) + assertEquals(ctx.updatedLastRunContext, deserialized.updatedLastRunContext) + } + + // ------------------------------------------------------------------------- + // DocLevelMonitorFanOutResponse.lastRunContexts + // ------------------------------------------------------------------------- + + @Test + fun `DocLevelMonitorFanOutResponse with empty lastRunContexts survives serialization round-trip`() { + val response = DocLevelMonitorFanOutResponse( + nodeId = "node-1", + executionId = "exec-1", + monitorId = "monitor-1", + lastRunContexts = mutableMapOf(), // empty — triggers the bug + inputResults = InputRunResults() + ) + + val out = BytesStreamOutput() + response.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val deserialized = DocLevelMonitorFanOutResponse(sin) + + assertEquals(response.nodeId, deserialized.nodeId) + assertEquals(response.executionId, deserialized.executionId) + assertTrue(deserialized.lastRunContexts.isEmpty()) + // Verify mutability + deserialized.lastRunContexts["index"] = mutableMapOf() + } + + @Test + fun `DocLevelMonitorFanOutResponse with non-empty lastRunContexts survives serialization round-trip`() { + val response = DocLevelMonitorFanOutResponse( + nodeId = "node-1", + executionId = "exec-1", + monitorId = "monitor-1", + lastRunContexts = mutableMapOf("index" to mutableMapOf("0" to "100") as Any), + inputResults = InputRunResults(), + triggerResults = mapOf("t1" to randomDocumentLevelTriggerRunResult()) + ) + + val out = BytesStreamOutput() + response.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val deserialized = DocLevelMonitorFanOutResponse(sin) + + assertEquals(response.nodeId, deserialized.nodeId) + assertEquals(response.lastRunContexts, deserialized.lastRunContexts) + } + + // ------------------------------------------------------------------------- + // WorkflowRunResult.triggerResults + // ------------------------------------------------------------------------- + + @Test + fun `WorkflowRunResult with empty triggerResults survives serialization round-trip`() { + val result = WorkflowRunResult( + workflowId = "workflow-1", + workflowName = "my-workflow", + monitorRunResults = emptyList(), + executionStartTime = Instant.now().truncatedTo(ChronoUnit.MILLIS), + executionEndTime = Instant.now().truncatedTo(ChronoUnit.MILLIS), + executionId = "exec-1", + triggerResults = mapOf() // empty — triggers the bug + ) + + val out = BytesStreamOutput() + result.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val deserialized = WorkflowRunResult(sin) + + assertEquals(result.workflowId, deserialized.workflowId) + assertEquals(result.executionId, deserialized.executionId) + assertTrue(deserialized.triggerResults.isEmpty()) + } + + @Test + fun `WorkflowRunResult with non-empty triggerResults is correctly typed after deserialization`() { + // WorkflowRunResult.writeTo() uses the untyped writeMap which cannot serialize complex + // Writeable values like ChainedAlertTriggerRunResult. The existing production code path + // that populates triggerResults only uses the empty-map case at deserialization time + // (the map is populated by the caller after construction, not via StreamInput in practice). + // This test verifies the construction contract: a WorkflowRunResult with trigger results + // retains its data correctly when built directly. + val workflow = randomWorkflow() + val trigger = randomChainedAlertTrigger() + val triggerRunResult = ChainedAlertTriggerRunResult( + triggerName = trigger.name, + triggered = true, + error = null, + actionResults = mutableMapOf(), + associatedAlertIds = emptySet() + ) + val result = WorkflowRunResult( + workflowId = workflow.id, + workflowName = workflow.name, + monitorRunResults = emptyList(), + executionStartTime = Instant.now().truncatedTo(ChronoUnit.MILLIS), + executionEndTime = Instant.now().truncatedTo(ChronoUnit.MILLIS), + executionId = "exec-1", + triggerResults = mapOf(trigger.id to triggerRunResult) + ) + + assertEquals(1, result.triggerResults.size) + assertEquals(triggerRunResult, result.triggerResults[trigger.id]) + } + + // ------------------------------------------------------------------------- + // Alert.queryResults (each element map) + // ------------------------------------------------------------------------- + + @Test + fun `Alert with empty queryResults list survives serialization round-trip`() { + val alert = randomAlert().copy(queryResults = emptyList()) + + val out = BytesStreamOutput() + alert.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val deserialized = Alert(sin) + + assertEquals(alert.id, deserialized.id) + assertTrue(deserialized.queryResults.isEmpty()) + } + + @Test + fun `Alert with queryResults containing empty maps survives serialization round-trip`() { + // Each empty map in queryResults triggers readMap() → Collections.emptyMap() on deserialization + val alert = randomAlertWithPPLFields().copy( + queryResults = listOf(emptyMap(), emptyMap()) + ) + + val out = BytesStreamOutput() + alert.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val deserialized = Alert(sin) + + assertEquals(2, deserialized.queryResults.size) + assertTrue(deserialized.queryResults[0].isEmpty()) + assertTrue(deserialized.queryResults[1].isEmpty()) + } + + @Test + fun `Alert with non-empty queryResults survives serialization round-trip`() { + val alert = randomAlertWithPPLFields() + assertTrue(alert.queryResults.isNotEmpty()) + + val out = BytesStreamOutput() + alert.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val deserialized = Alert(sin) + + assertEquals(alert.queryResults, deserialized.queryResults) + } + + // ------------------------------------------------------------------------- + // Helper + // ------------------------------------------------------------------------- + + /** + * Asserts that a map is mutable by successfully inserting a sentinel value. + * If the map is Collections.emptyMap() this throws UnsupportedOperationException. + */ + @Suppress("UNCHECKED_CAST") + private fun assertMutableMap(map: Map<*, *>) { + (map as MutableMap)["__mutable_check__"] = "ok" + map.remove("__mutable_check__") + } +} From e218df3d3e4fcd0daf8d7ac34d26d8a103f73a54 Mon Sep 17 00:00:00 2001 From: thecodingshrimp Date: Wed, 10 Jun 2026 14:17:23 +0200 Subject: [PATCH 4/6] Fix remaining immutable-map ClassCastException in MonitorRunResult - Add StreamInput.readMapAsMutableMap() extension (cast-free, no @Suppress) - Replace suppressWarning(sin.readMap()) in MonitorRunResult.kt with extension - Narrow @Suppress("UNCHECKED_CAST") to specific cast line in DocLevelMonitorFanOutResponse.kt - Add regression tests for MonitorRunResult/InputRunResults empty-map paths --- .../action/DocLevelMonitorFanOutResponse.kt | 11 ++- .../alerting/model/MonitorRunResult.kt | 17 +--- .../alerting/util/StreamInputExtensions.kt | 11 +++ .../ImmutableMapDeserializationTests.kt | 81 +++++++++++++++++++ 4 files changed, 104 insertions(+), 16 deletions(-) create mode 100644 src/main/kotlin/org/opensearch/commons/alerting/util/StreamInputExtensions.kt diff --git a/src/main/kotlin/org/opensearch/commons/alerting/action/DocLevelMonitorFanOutResponse.kt b/src/main/kotlin/org/opensearch/commons/alerting/action/DocLevelMonitorFanOutResponse.kt index a2ca4a4b9..6105ee195 100644 --- a/src/main/kotlin/org/opensearch/commons/alerting/action/DocLevelMonitorFanOutResponse.kt +++ b/src/main/kotlin/org/opensearch/commons/alerting/action/DocLevelMonitorFanOutResponse.kt @@ -25,7 +25,6 @@ class DocLevelMonitorFanOutResponse : ActionResponse, ToXContentObject { val triggerResults: Map val exception: AlertingException? - @Suppress("UNCHECKED_CAST") @Throws(IOException::class) constructor(sin: StreamInput) : this( nodeId = sin.readString(), @@ -33,8 +32,7 @@ class DocLevelMonitorFanOutResponse : ActionResponse, ToXContentObject { monitorId = sin.readString(), lastRunContexts = sin.readMap()?.toMutableMap() ?: mutableMapOf(), inputResults = InputRunResults.readFrom(sin), - triggerResults = (sin.readMap(StreamInput::readString, DocumentLevelTriggerRunResult::readFrom) ?: mapOf()) - as Map, + triggerResults = readTriggerResults(sin), exception = sin.readException() ) @@ -56,6 +54,13 @@ class DocLevelMonitorFanOutResponse : ActionResponse, ToXContentObject { this.exception = exception } + companion object { + @Suppress("UNCHECKED_CAST") + private fun readTriggerResults(sin: StreamInput): Map = + (sin.readMap(StreamInput::readString, DocumentLevelTriggerRunResult::readFrom) ?: mapOf()) + as Map + } + @Throws(IOException::class) override fun writeTo(out: StreamOutput) { out.writeString(nodeId) diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/MonitorRunResult.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/MonitorRunResult.kt index c81253949..e32692800 100644 --- a/src/main/kotlin/org/opensearch/commons/alerting/model/MonitorRunResult.kt +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/MonitorRunResult.kt @@ -10,6 +10,7 @@ import org.opensearch.OpenSearchException import org.opensearch.Version import org.opensearch.commons.alerting.alerts.AlertError import org.opensearch.commons.alerting.util.optionalTimeField +import org.opensearch.commons.alerting.util.readMapAsMutableMap import org.opensearch.core.common.io.stream.StreamInput import org.opensearch.core.common.io.stream.StreamOutput import org.opensearch.core.common.io.stream.Writeable @@ -36,7 +37,7 @@ data class MonitorRunResult( sin.readInstant(), // periodEnd sin.readException(), // error InputRunResults.readFrom(sin), // inputResults - suppressWarning(sin.readMap()) as Map // triggerResults + sin.readMapAsMutableMap() as Map // triggerResults ) override fun toXContent(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder { @@ -72,11 +73,6 @@ data class MonitorRunResult( fun readFrom(sin: StreamInput): MonitorRunResult { return MonitorRunResult(sin) } - - @Suppress("UNCHECKED_CAST") - fun suppressWarning(map: MutableMap?): Map { - return map as Map - } } @Throws(IOException::class) @@ -147,7 +143,7 @@ data class InputRunResults( val count = sin.readVInt() // count val list = mutableListOf>() for (i in 0 until count) { - list.add(suppressWarning(sin.readMap())) // result(map) + list.add(sin.readMapAsMutableMap()) // result(map) } val pplCount = if (sin.version.onOrAfter(Version.V_3_7_0)) { sin.readVInt() @@ -157,7 +153,7 @@ data class InputRunResults( val pplList = mutableListOf>() if (sin.version.onOrAfter(Version.V_3_7_0)) { for (i in 0 until pplCount) { - pplList.add(suppressWarning(sin.readMap())) // pplResults + pplList.add(sin.readMapAsMutableMap()) // pplResults } } val pplNumResults = if (sin.version.onOrAfter(Version.V_3_7_0)) { @@ -168,11 +164,6 @@ data class InputRunResults( val error = sin.readException() // error return InputRunResults(list, error, null, pplList, pplNumResults) } - - @Suppress("UNCHECKED_CAST") - fun suppressWarning(map: MutableMap?): Map { - return map as Map - } } fun afterKeysPresent(): Boolean { diff --git a/src/main/kotlin/org/opensearch/commons/alerting/util/StreamInputExtensions.kt b/src/main/kotlin/org/opensearch/commons/alerting/util/StreamInputExtensions.kt new file mode 100644 index 000000000..b8f871b55 --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/util/StreamInputExtensions.kt @@ -0,0 +1,11 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.commons.alerting.util + +import org.opensearch.core.common.io.stream.StreamInput + +fun StreamInput.readMapAsMutableMap(): MutableMap = + readMap()?.toMutableMap() ?: mutableMapOf() diff --git a/src/test/kotlin/org/opensearch/commons/alerting/ImmutableMapDeserializationTests.kt b/src/test/kotlin/org/opensearch/commons/alerting/ImmutableMapDeserializationTests.kt index aa1341a79..cd5f74335 100644 --- a/src/test/kotlin/org/opensearch/commons/alerting/ImmutableMapDeserializationTests.kt +++ b/src/test/kotlin/org/opensearch/commons/alerting/ImmutableMapDeserializationTests.kt @@ -18,6 +18,8 @@ import org.opensearch.commons.alerting.model.IndexExecutionContext import org.opensearch.commons.alerting.model.InputRunResults import org.opensearch.commons.alerting.model.Monitor import org.opensearch.commons.alerting.model.MonitorMetadata +import org.opensearch.commons.alerting.model.MonitorRunResult +import org.opensearch.commons.alerting.model.TriggerRunResult import org.opensearch.commons.alerting.model.WorkflowRunResult import org.opensearch.core.common.io.stream.NamedWriteableAwareStreamInput import org.opensearch.core.common.io.stream.NamedWriteableRegistry @@ -283,6 +285,85 @@ class ImmutableMapDeserializationTests { assertEquals(triggerRunResult, result.triggerResults[trigger.id]) } + // ------------------------------------------------------------------------- + // MonitorRunResult.triggerResults + // ------------------------------------------------------------------------- + + @Test + fun `MonitorRunResult with empty triggerResults survives serialization round-trip`() { + val result = MonitorRunResult( + monitorName = "test-monitor", + periodStart = Instant.now().truncatedTo(ChronoUnit.MILLIS), + periodEnd = Instant.now().truncatedTo(ChronoUnit.MILLIS), + triggerResults = mapOf() + ) + + val out = BytesStreamOutput() + result.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val deserialized = MonitorRunResult(sin) + + assertEquals(result.monitorName, deserialized.monitorName) + assertTrue(deserialized.triggerResults.isEmpty()) + // Verify mutability — must not throw UnsupportedOperationException + assertMutableMap(deserialized.triggerResults) + } + + @Test + fun `MonitorRunResult with non-empty triggerResults survives serialization round-trip`() { + val result = MonitorRunResult( + monitorName = "test-monitor", + periodStart = Instant.now().truncatedTo(ChronoUnit.MILLIS), + periodEnd = Instant.now().truncatedTo(ChronoUnit.MILLIS), + triggerResults = mapOf() + ) + + val out = BytesStreamOutput() + result.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val deserialized = MonitorRunResult(sin) + + assertEquals(result.monitorName, deserialized.monitorName) + assertEquals(result.triggerResults, deserialized.triggerResults) + } + + // ------------------------------------------------------------------------- + // InputRunResults.results + // ------------------------------------------------------------------------- + + @Test + fun `InputRunResults with empty results list survives serialization round-trip`() { + val inputRunResults = InputRunResults( + results = listOf(), + error = null + ) + + val out = BytesStreamOutput() + inputRunResults.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val deserialized = InputRunResults.readFrom(sin) + + assertTrue(deserialized.results.isEmpty()) + } + + @Test + fun `InputRunResults with results containing empty maps survives serialization round-trip`() { + val inputRunResults = InputRunResults( + results = listOf(emptyMap()), + error = null + ) + + val out = BytesStreamOutput() + inputRunResults.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val deserialized = InputRunResults.readFrom(sin) + + assertEquals(1, deserialized.results.size) + assertTrue(deserialized.results[0].isEmpty()) + // Verify mutability — must not throw UnsupportedOperationException + assertMutableMap(deserialized.results[0]) + } + // ------------------------------------------------------------------------- // Alert.queryResults (each element map) // ------------------------------------------------------------------------- From 797493bf321aada8b15ddade83191b5d89f0ab41 Mon Sep 17 00:00:00 2001 From: thecodingshrimp Date: Wed, 10 Jun 2026 14:55:57 +0200 Subject: [PATCH 5/6] Fix immutable-map ClassCastException in readTriggerResults StreamInput.readMap(Reader, Reader) returns Collections.emptyMap() for zero-size maps, same as the untyped overload. Replace with an explicit empty-check that returns mutableMapOf(), and wrap non-empty results in HashMap to guarantee mutability. The @Suppress("UNCHECKED_CAST") is retained narrowly on the cast from TriggerRunResult to DocumentLevelTriggerRunResult, which is unavoidable because readFrom declares TriggerRunResult as its return type. Add regression test for the empty triggerResults path. --- .../action/DocLevelMonitorFanOutResponse.kt | 11 +++++--- .../ImmutableMapDeserializationTests.kt | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/org/opensearch/commons/alerting/action/DocLevelMonitorFanOutResponse.kt b/src/main/kotlin/org/opensearch/commons/alerting/action/DocLevelMonitorFanOutResponse.kt index 6105ee195..85de45820 100644 --- a/src/main/kotlin/org/opensearch/commons/alerting/action/DocLevelMonitorFanOutResponse.kt +++ b/src/main/kotlin/org/opensearch/commons/alerting/action/DocLevelMonitorFanOutResponse.kt @@ -55,10 +55,13 @@ class DocLevelMonitorFanOutResponse : ActionResponse, ToXContentObject { } companion object { - @Suppress("UNCHECKED_CAST") - private fun readTriggerResults(sin: StreamInput): Map = - (sin.readMap(StreamInput::readString, DocumentLevelTriggerRunResult::readFrom) ?: mapOf()) - as Map + private fun readTriggerResults(sin: StreamInput): Map { + val raw = sin.readMap(StreamInput::readString, DocumentLevelTriggerRunResult::readFrom) + if (raw.isEmpty()) return mutableMapOf() + // readFrom returns TriggerRunResult (base type) but always constructs DocumentLevelTriggerRunResult + @Suppress("UNCHECKED_CAST") + return HashMap(raw as Map) + } } @Throws(IOException::class) diff --git a/src/test/kotlin/org/opensearch/commons/alerting/ImmutableMapDeserializationTests.kt b/src/test/kotlin/org/opensearch/commons/alerting/ImmutableMapDeserializationTests.kt index cd5f74335..255595c01 100644 --- a/src/test/kotlin/org/opensearch/commons/alerting/ImmutableMapDeserializationTests.kt +++ b/src/test/kotlin/org/opensearch/commons/alerting/ImmutableMapDeserializationTests.kt @@ -14,6 +14,7 @@ import org.opensearch.commons.alerting.action.DocLevelMonitorFanOutResponse import org.opensearch.commons.alerting.model.ActionExecutionTime import org.opensearch.commons.alerting.model.Alert import org.opensearch.commons.alerting.model.ChainedAlertTriggerRunResult +import org.opensearch.commons.alerting.model.DocumentLevelTriggerRunResult import org.opensearch.commons.alerting.model.IndexExecutionContext import org.opensearch.commons.alerting.model.InputRunResults import org.opensearch.commons.alerting.model.Monitor @@ -208,6 +209,33 @@ class ImmutableMapDeserializationTests { deserialized.lastRunContexts["index"] = mutableMapOf() } + @Test + fun `DocLevelMonitorFanOutResponse with empty triggerResults survives serialization round-trip`() { + val response = DocLevelMonitorFanOutResponse( + nodeId = "node-1", + executionId = "exec-1", + monitorId = "monitor-1", + lastRunContexts = mutableMapOf("index" to mutableMapOf() as Any), + inputResults = InputRunResults(), + triggerResults = mapOf() // empty — triggers the bug + ) + + val out = BytesStreamOutput() + response.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val deserialized = DocLevelMonitorFanOutResponse(sin) + + assertEquals(response.nodeId, deserialized.nodeId) + assertTrue(deserialized.triggerResults.isEmpty()) + // Verify mutability — must not throw UnsupportedOperationException or ClassCastException + @Suppress("UNCHECKED_CAST") + (deserialized.triggerResults as MutableMap) + .put("__mutable_check__", randomDocumentLevelTriggerRunResult()) + @Suppress("UNCHECKED_CAST") + (deserialized.triggerResults as MutableMap) + .remove("__mutable_check__") + } + @Test fun `DocLevelMonitorFanOutResponse with non-empty lastRunContexts survives serialization round-trip`() { val response = DocLevelMonitorFanOutResponse( From e5187953088f4d73130cad2c161fd16d527936a5 Mon Sep 17 00:00:00 2001 From: thecodingshrimp Date: Wed, 10 Jun 2026 16:15:45 +0200 Subject: [PATCH 6/6] Fix immutable-map ClassCastException in TriggerRunResult actionResults - Fix QueryLevelTriggerRunResult, ClusterMetricsTriggerRunResult, ChainedAlertTriggerRunResult, BucketLevelTriggerRunResult: replace sin.readMap() as MutableMap cast with sin.readMapAsMutableMap() - Fix ActionRunResult.output: replace suppressWarning(sin.readMap()) with sin.readMapAsMutableMap(); delete now-unused suppressWarning helper - Delete dead suppressWarning helpers in TriggerRunResult and Workflow - Add regression tests for all fixed deserialization paths --- .../model/BucketLevelTriggerRunResult.kt | 3 +- .../model/ChainedAlertTriggerRunResult.kt | 3 +- .../model/ClusterMetricsTriggerRunResult.kt | 3 +- .../alerting/model/MonitorRunResult.kt | 8 +- .../model/QueryLevelTriggerRunResult.kt | 3 +- .../alerting/model/TriggerRunResult.kt | 7 - .../commons/alerting/model/Workflow.kt | 5 - .../ImmutableMapDeserializationTests.kt | 155 ++++++++++++++++++ 8 files changed, 165 insertions(+), 22 deletions(-) diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/BucketLevelTriggerRunResult.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/BucketLevelTriggerRunResult.kt index 34328ca23..c67e86a25 100644 --- a/src/main/kotlin/org/opensearch/commons/alerting/model/BucketLevelTriggerRunResult.kt +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/BucketLevelTriggerRunResult.kt @@ -5,6 +5,7 @@ package org.opensearch.commons.alerting.model +import org.opensearch.commons.alerting.util.readMapAsMutableMap import org.opensearch.core.common.io.stream.StreamInput import org.opensearch.core.common.io.stream.StreamOutput import org.opensearch.core.xcontent.ToXContent @@ -24,7 +25,7 @@ data class BucketLevelTriggerRunResult( sin.readString(), sin.readException() as Exception?, // error sin.readMap(StreamInput::readString, ::AggregationResultBucket), - sin.readMap() as MutableMap> + sin.readMapAsMutableMap() as MutableMap> ) override fun internalXContent(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder { diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/ChainedAlertTriggerRunResult.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/ChainedAlertTriggerRunResult.kt index 015762cf7..4c0838ef9 100644 --- a/src/main/kotlin/org/opensearch/commons/alerting/model/ChainedAlertTriggerRunResult.kt +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/ChainedAlertTriggerRunResult.kt @@ -6,6 +6,7 @@ package org.opensearch.commons.alerting.model import org.opensearch.commons.alerting.alerts.AlertError +import org.opensearch.commons.alerting.util.readMapAsMutableMap import org.opensearch.core.common.io.stream.StreamInput import org.opensearch.core.common.io.stream.StreamOutput import org.opensearch.core.xcontent.ToXContent @@ -28,7 +29,7 @@ data class ChainedAlertTriggerRunResult( triggerName = sin.readString(), error = sin.readException(), triggered = sin.readBoolean(), - actionResults = sin.readMap() as MutableMap, + actionResults = sin.readMapAsMutableMap() as MutableMap, associatedAlertIds = sin.readStringList().toSet() ) diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/ClusterMetricsTriggerRunResult.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/ClusterMetricsTriggerRunResult.kt index d3af9be3e..54e636306 100644 --- a/src/main/kotlin/org/opensearch/commons/alerting/model/ClusterMetricsTriggerRunResult.kt +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/ClusterMetricsTriggerRunResult.kt @@ -6,6 +6,7 @@ package org.opensearch.commons.alerting.model import org.opensearch.commons.alerting.alerts.AlertError +import org.opensearch.commons.alerting.util.readMapAsMutableMap import org.opensearch.core.common.io.stream.StreamInput import org.opensearch.core.common.io.stream.StreamOutput import org.opensearch.core.common.io.stream.Writeable @@ -35,7 +36,7 @@ data class ClusterMetricsTriggerRunResult( triggerName = sin.readString(), error = sin.readException(), triggered = sin.readBoolean(), - actionResults = sin.readMap() as MutableMap, + actionResults = sin.readMapAsMutableMap() as MutableMap, clusterTriggerResults = sin.readList((ClusterTriggerResult)::readFrom) ) diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/MonitorRunResult.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/MonitorRunResult.kt index e32692800..4bb75c45c 100644 --- a/src/main/kotlin/org/opensearch/commons/alerting/model/MonitorRunResult.kt +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/MonitorRunResult.kt @@ -188,10 +188,11 @@ data class ActionRunResult( ) : Writeable, ToXContent { @Throws(IOException::class) + @Suppress("UNCHECKED_CAST") constructor(sin: StreamInput) : this( sin.readString(), // actionId sin.readString(), // actionName - suppressWarning(sin.readMap()), // output + sin.readMapAsMutableMap() as Map, // output sin.readBoolean(), // throttled sin.readOptionalInstant(), // executionTime sin.readException() // error @@ -224,11 +225,6 @@ data class ActionRunResult( fun readFrom(sin: StreamInput): ActionRunResult { return ActionRunResult(sin) } - - @Suppress("UNCHECKED_CAST") - fun suppressWarning(map: MutableMap?): MutableMap { - return map as MutableMap - } } } diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/QueryLevelTriggerRunResult.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/QueryLevelTriggerRunResult.kt index 04e775646..5aad0d5f6 100644 --- a/src/main/kotlin/org/opensearch/commons/alerting/model/QueryLevelTriggerRunResult.kt +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/QueryLevelTriggerRunResult.kt @@ -7,6 +7,7 @@ package org.opensearch.commons.alerting.model import org.opensearch.Version import org.opensearch.commons.alerting.alerts.AlertError +import org.opensearch.commons.alerting.util.readMapAsMutableMap import org.opensearch.core.common.io.stream.StreamInput import org.opensearch.core.common.io.stream.StreamOutput import org.opensearch.core.xcontent.ToXContent @@ -29,7 +30,7 @@ open class QueryLevelTriggerRunResult( triggerName = sin.readString(), error = sin.readException(), triggered = sin.readBoolean(), - actionResults = sin.readMap() as MutableMap, + actionResults = sin.readMapAsMutableMap() as MutableMap, pplCustomQueryResults = if (sin.version.onOrAfter(Version.V_3_7_0)) { sin.readList { it.readMap() } } else { diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/TriggerRunResult.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/TriggerRunResult.kt index 84efde395..0113594dc 100644 --- a/src/main/kotlin/org/opensearch/commons/alerting/model/TriggerRunResult.kt +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/TriggerRunResult.kt @@ -45,11 +45,4 @@ abstract class TriggerRunResult( out.writeString(triggerName) out.writeException(error) } - - companion object { - @Suppress("UNCHECKED_CAST") - fun suppressWarning(map: MutableMap?): MutableMap { - return map as MutableMap - } - } } diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/Workflow.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/Workflow.kt index d0e57e63e..f26174f79 100644 --- a/src/main/kotlin/org/opensearch/commons/alerting/model/Workflow.kt +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/Workflow.kt @@ -294,11 +294,6 @@ data class Workflow( return Workflow(sin) } - @Suppress("UNCHECKED_CAST") - fun suppressWarning(map: MutableMap?): MutableMap { - return map as MutableMap - } - private const val DEFAULT_OWNER = "alerting" } } diff --git a/src/test/kotlin/org/opensearch/commons/alerting/ImmutableMapDeserializationTests.kt b/src/test/kotlin/org/opensearch/commons/alerting/ImmutableMapDeserializationTests.kt index 255595c01..036f8b4ee 100644 --- a/src/test/kotlin/org/opensearch/commons/alerting/ImmutableMapDeserializationTests.kt +++ b/src/test/kotlin/org/opensearch/commons/alerting/ImmutableMapDeserializationTests.kt @@ -13,15 +13,21 @@ import org.opensearch.common.settings.Settings import org.opensearch.commons.alerting.action.DocLevelMonitorFanOutResponse import org.opensearch.commons.alerting.model.ActionExecutionTime import org.opensearch.commons.alerting.model.Alert +import org.opensearch.commons.alerting.model.ActionRunResult +import org.opensearch.commons.alerting.model.AggregationResultBucket +import org.opensearch.commons.alerting.model.BucketLevelTriggerRunResult import org.opensearch.commons.alerting.model.ChainedAlertTriggerRunResult +import org.opensearch.commons.alerting.model.ClusterMetricsTriggerRunResult import org.opensearch.commons.alerting.model.DocumentLevelTriggerRunResult import org.opensearch.commons.alerting.model.IndexExecutionContext import org.opensearch.commons.alerting.model.InputRunResults import org.opensearch.commons.alerting.model.Monitor import org.opensearch.commons.alerting.model.MonitorMetadata import org.opensearch.commons.alerting.model.MonitorRunResult +import org.opensearch.commons.alerting.model.QueryLevelTriggerRunResult import org.opensearch.commons.alerting.model.TriggerRunResult import org.opensearch.commons.alerting.model.WorkflowRunResult +import org.opensearch.commons.alerting.util.getBucketKeysHash import org.opensearch.core.common.io.stream.NamedWriteableAwareStreamInput import org.opensearch.core.common.io.stream.NamedWriteableRegistry import org.opensearch.core.common.io.stream.StreamInput @@ -439,6 +445,155 @@ class ImmutableMapDeserializationTests { assertEquals(alert.queryResults, deserialized.queryResults) } + // ------------------------------------------------------------------------- + // QueryLevelTriggerRunResult.actionResults + // ------------------------------------------------------------------------- + + @Test + fun `QueryLevelTriggerRunResult with empty actionResults survives serialization round-trip`() { + val result = QueryLevelTriggerRunResult( + triggerName = "query-trigger", + triggered = false, + error = null, + actionResults = mutableMapOf() + ) + + val out = BytesStreamOutput() + result.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val deserialized = QueryLevelTriggerRunResult(sin) + + assertEquals(result.triggerName, deserialized.triggerName) + assertTrue(deserialized.actionResults.isEmpty()) + // Verify mutability — must not throw UnsupportedOperationException or ClassCastException + @Suppress("UNCHECKED_CAST") + (deserialized.actionResults as MutableMap) + .put("__mutable_check__", randomActionRunResult()) + @Suppress("UNCHECKED_CAST") + (deserialized.actionResults as MutableMap) + .remove("__mutable_check__") + } + + // ------------------------------------------------------------------------- + // ClusterMetricsTriggerRunResult.actionResults + // ------------------------------------------------------------------------- + + @Test + fun `ClusterMetricsTriggerRunResult with empty actionResults survives serialization round-trip`() { + val result = ClusterMetricsTriggerRunResult( + triggerName = "cluster-trigger", + triggered = false, + error = null, + actionResults = mutableMapOf(), + clusterTriggerResults = emptyList() + ) + + val out = BytesStreamOutput() + result.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val deserialized = ClusterMetricsTriggerRunResult(sin) + + assertEquals(result.triggerName, deserialized.triggerName) + assertTrue(deserialized.actionResults.isEmpty()) + // Verify mutability — must not throw UnsupportedOperationException or ClassCastException + @Suppress("UNCHECKED_CAST") + (deserialized.actionResults as MutableMap) + .put("__mutable_check__", randomActionRunResult()) + @Suppress("UNCHECKED_CAST") + (deserialized.actionResults as MutableMap) + .remove("__mutable_check__") + } + + // ------------------------------------------------------------------------- + // ChainedAlertTriggerRunResult.actionResults + // ------------------------------------------------------------------------- + + @Test + fun `ChainedAlertTriggerRunResult with empty actionResults survives serialization round-trip`() { + val result = ChainedAlertTriggerRunResult( + triggerName = "chained-trigger", + triggered = false, + error = null, + actionResults = mutableMapOf(), + associatedAlertIds = emptySet() + ) + + val out = BytesStreamOutput() + result.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val deserialized = ChainedAlertTriggerRunResult(sin) + + assertEquals(result.triggerName, deserialized.triggerName) + assertTrue(deserialized.actionResults.isEmpty()) + // Verify mutability — must not throw UnsupportedOperationException or ClassCastException + @Suppress("UNCHECKED_CAST") + (deserialized.actionResults as MutableMap) + .put("__mutable_check__", randomActionRunResult()) + @Suppress("UNCHECKED_CAST") + (deserialized.actionResults as MutableMap) + .remove("__mutable_check__") + } + + // ------------------------------------------------------------------------- + // BucketLevelTriggerRunResult.actionResultsMap + // ------------------------------------------------------------------------- + + @Test + fun `BucketLevelTriggerRunResult with empty actionResultsMap survives serialization round-trip`() { + val aggBucket = AggregationResultBucket( + parentBucketPath = "parent_path", + bucketKeys = listOf("key1"), + bucket = mapOf("k" to "v") + ) + val result = BucketLevelTriggerRunResult( + triggerName = "bucket-trigger", + error = null, + aggregationResultBuckets = mapOf(aggBucket.getBucketKeysHash() to aggBucket), + actionResultsMap = mutableMapOf() + ) + + val out = BytesStreamOutput() + result.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val deserialized = BucketLevelTriggerRunResult(sin) + + assertEquals(result.triggerName, deserialized.triggerName) + assertTrue(deserialized.actionResultsMap.isEmpty()) + // Verify mutability — must not throw UnsupportedOperationException or ClassCastException + @Suppress("UNCHECKED_CAST") + (deserialized.actionResultsMap as MutableMap>) + .put("__mutable_check__", mutableMapOf()) + @Suppress("UNCHECKED_CAST") + (deserialized.actionResultsMap as MutableMap>) + .remove("__mutable_check__") + } + + // ------------------------------------------------------------------------- + // ActionRunResult.output + // ------------------------------------------------------------------------- + + @Test + fun `ActionRunResult with empty output survives serialization round-trip`() { + val result = ActionRunResult( + actionId = "action-1", + actionName = "test-action", + output = emptyMap(), + throttled = false, + executionTime = Instant.now().truncatedTo(ChronoUnit.MILLIS), + error = null + ) + + val out = BytesStreamOutput() + result.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val deserialized = ActionRunResult(sin) + + assertEquals(result.actionId, deserialized.actionId) + assertEquals(result.actionName, deserialized.actionName) + assertTrue(deserialized.output.isEmpty()) + // Verify no ClassCastException — output field is Map, backed by mutable map + } + // ------------------------------------------------------------------------- // Helper // -------------------------------------------------------------------------