Skip to content

Commit

Permalink
Microsoft teams (opensearch-project#676)
Browse files Browse the repository at this point in the history
* Added feature support for microsoft teams webhoo

Signed-off-by: danielkyalo599 <johbiento@gmail.com>

* Added feature support for microsoft teams webhook ,removed valid webhooks

Signed-off-by: danielkyalo599 <johbiento@gmail.com>

* Added feature support for Microsoft teams webhook

Signed-off-by: danielkyalo599 <johbiento@gmail.com>

* Refactored feature support for  ms teams and added unit and integTest

Signed-off-by: danielkyalo599 <johbiento@gmail.com>

* fix build in core

Signed-off-by: zhichao-aws <zhichaog@amazon.com>

* fix core-spi build

Signed-off-by: zhichao-aws <zhichaog@amazon.com>

* fix notifications main code

Signed-off-by: zhichao-aws <zhichaog@amazon.com>

* fix mappings, add IT

Signed-off-by: zhichao-aws <zhichaog@amazon.com>

* add auto upgrade mapping logic

Signed-off-by: zhichao-aws <zhichaog@amazon.com>

* put load mapping to initialize step

Signed-off-by: zhichao-aws <zhichaog@amazon.com>

* add schema_version field

Signed-off-by: zhichao-aws <zhichaog@amazon.com>

* add integ test

Signed-off-by: zhichao-aws <zhichaog@amazon.com>

* adjust with auto upgrade mapping logic

Signed-off-by: zhichao-aws <zhichaog@amazon.com>

* add bwc

Signed-off-by: zhichao-aws <zhichaog@amazon.com>

* modify bwc

Signed-off-by: zhichao-aws <zhichaog@amazon.com>

* modify bwc

Signed-off-by: zhichao-aws <zhichaog@amazon.com>

* resolve  comments

Signed-off-by: zhichao-aws <zhichaog@amazon.com>

* add license header

Signed-off-by: zhichao-aws <zhichaog@amazon.com>

* fix microsoft teams sample url in IT to adapt url validation

Signed-off-by: zhichao-aws <zhichaog@amazon.com>

---------

Signed-off-by: danielkyalo599 <johbiento@gmail.com>
Signed-off-by: zhichao-aws <zhichaog@amazon.com>
Co-authored-by: danielkyalo599 <johbiento@gmail.com>
Signed-off-by: Aniruddh <ansriv2005@gmail.com>
  • Loading branch information
2 people authored and Noir01 committed Nov 16, 2023
1 parent d36b9c0 commit 0ff5ba0
Show file tree
Hide file tree
Showing 28 changed files with 575 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ package org.opensearch.notifications.spi.model.destination
* Supported notification destinations
*/
enum class DestinationType {
CHIME, SLACK, CUSTOM_WEBHOOK, SMTP, SES, SNS
CHIME, SLACK, MICROSOFT_TEAMS, CUSTOM_WEBHOOK, SMTP, SES, SNS
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

package org.opensearch.notifications.spi.model.destination

/**
* This class holds the contents of a Microsoft Teams destination
*/
class MicrosoftTeamsDestination(
url: String,
) : WebhookDestination(url, DestinationType.MICROSOFT_TEAMS)
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ internal class ValidationHelpersTests {
private val LOCAL_HOST_EXTENDED = "https://localhost:6060/service"
private val WEBHOOK_URL = "https://test-webhook.com:1234/subdirectory?param1=value1&param2=&param3=value3"
private val CHIME_URL = "https://domain.com/sample_chime_url#1234567890"
private val MICROSOFT_TEAMS_WEBHOOK_URL = "https://test.webhook.office.com/webhookb2/12345678/IncomingWebhook/87654321"

private val hostDenyList = listOf(
"127.0.0.0/8",
Expand Down Expand Up @@ -99,4 +100,8 @@ internal class ValidationHelpersTests {
fun `validator identifies chime url as valid`() {
assert(isValidUrl(CHIME_URL))
}
@Test
fun `validator identifies microsoft teams url as valid`() {
assert(isValidUrl(MICROSOFT_TEAMS_WEBHOOK_URL))
}
}
2 changes: 1 addition & 1 deletion notifications/core/src/main/config/notifications-core.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@ opensearch.notifications.core:
connection_timeout: 5000 # in milliseconds
socket_timeout: 50000
host_deny_list: []
allowed_config_types: ["slack","chime","webhook","email","sns","ses_account","smtp_account","email_group"]
allowed_config_types: ["slack","chime","microsoft_teams","webhook","email","sns","ses_account","smtp_account","email_group"]
tooltip_support: true
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import java.security.PrivilegedAction

/**
* This is a client facing NotificationCoreImpl class to send the messages
* to the NotificationCoreImpl channels like chime, slack, webhooks, email etc
* to the NotificationCoreImpl channels like chime, slack, Microsoft Teams, webhooks, email etc
*/
object NotificationCoreImpl : NotificationCore {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import org.opensearch.notifications.core.utils.validateUrlHost
import org.opensearch.notifications.spi.model.MessageContent
import org.opensearch.notifications.spi.model.destination.ChimeDestination
import org.opensearch.notifications.spi.model.destination.CustomWebhookDestination
import org.opensearch.notifications.spi.model.destination.MicrosoftTeamsDestination
import org.opensearch.notifications.spi.model.destination.SlackDestination
import org.opensearch.notifications.spi.model.destination.WebhookDestination
import java.io.IOException
Expand Down Expand Up @@ -159,12 +160,14 @@ class DestinationHttpClient {
val keyName = when (destination) {
// Slack webhook request body has required "text" as key name https://api.slack.com/messaging/webhooks
// Chime webhook request body has required "Content" as key name
// Microsoft Teams webhook request body has required "text" as key name https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/what-are-webhooks-and-connectors
// Customer webhook allows input as json or plain text, so we just return the message as it is
is SlackDestination -> "text"
is ChimeDestination -> "Content"
is MicrosoftTeamsDestination -> "text"
is CustomWebhookDestination -> return message.textDescription
else -> throw IllegalArgumentException(
"Invalid destination type is provided, Only Slack, Chime and CustomWebhook are allowed"
"Invalid destination type is provided, Only Slack, Chime, Microsoft Teams and CustomWebhook are allowed"
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ internal object PluginSettings {
private val DEFAULT_ALLOWED_CONFIG_TYPES = listOf(
"slack",
"chime",
"microsoft_teams",
"webhook",
"email",
"sns",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ internal object DestinationTransportProvider {
var destinationTransportMap = mapOf(
DestinationType.SLACK to webhookDestinationTransport,
DestinationType.CHIME to webhookDestinationTransport,
DestinationType.MICROSOFT_TEAMS to webhookDestinationTransport,
DestinationType.CUSTOM_WEBHOOK to webhookDestinationTransport,
DestinationType.SMTP to smtpDestinationTransport,
DestinationType.SNS to snsDestinationTransport,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class NotificationCoreImplTests {
private val defaultConfigTypes = listOf(
"slack",
"chime",
"microsoft_teams",
"webhook",
"email",
"sns",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

package org.opensearch.notifications.core.destinations

import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import org.apache.hc.client5.http.classic.methods.HttpPost
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse
import org.apache.hc.core5.http.io.entity.StringEntity
import org.easymock.EasyMock
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import org.opensearch.core.rest.RestStatus
import org.opensearch.notifications.core.NotificationCoreImpl
import org.opensearch.notifications.core.client.DestinationHttpClient
import org.opensearch.notifications.core.transport.DestinationTransportProvider
import org.opensearch.notifications.core.transport.WebhookDestinationTransport
import org.opensearch.notifications.spi.model.DestinationMessageResponse
import org.opensearch.notifications.spi.model.MessageContent
import org.opensearch.notifications.spi.model.destination.ChimeDestination
import org.opensearch.notifications.spi.model.destination.DestinationType
import org.opensearch.notifications.spi.model.destination.MicrosoftTeamsDestination
import java.net.MalformedURLException
import java.util.stream.Stream

internal class MicrosoftTeamsDestinationTests {
companion object {
@JvmStatic
fun escapeSequenceToRaw(): Stream<Arguments> =
Stream.of(
Arguments.of("\n", """\n"""),
Arguments.of("\t", """\t"""),
Arguments.of("\b", """\b"""),
Arguments.of("\r", """\r"""),
Arguments.of("\"", """\""""),
)
}

@BeforeEach
fun setup() {
// Stubbing isHostInDenylist() so it doesn't attempt to resolve hosts that don't exist in the unit tests
mockkStatic("org.opensearch.notifications.spi.utils.ValidationHelpersKt")
every { org.opensearch.notifications.spi.utils.isHostInDenylist(any(), any()) } returns false
}

@Test
fun `test MicrosoftTeams message null entity response`() {
val mockHttpClient: CloseableHttpClient = EasyMock.createMock(CloseableHttpClient::class.java)

// The DestinationHttpClient replaces a null entity with "{}".
val expectedWebhookResponse = DestinationMessageResponse(RestStatus.OK.status, "{}")

val httpResponse = mockk<CloseableHttpResponse>()
EasyMock.expect(mockHttpClient.execute(EasyMock.anyObject(HttpPost::class.java))).andReturn(httpResponse)

every { httpResponse.code } returns RestStatus.OK.status
every { httpResponse.entity } returns null
EasyMock.replay(mockHttpClient)

val httpClient = DestinationHttpClient(mockHttpClient)
val webhookDestinationTransport = WebhookDestinationTransport(httpClient)
DestinationTransportProvider.destinationTransportMap = mapOf(DestinationType.MICROSOFT_TEAMS to webhookDestinationTransport)

val title = "test MicrosoftTeams"
val messageText = "Message gughjhjlkh Body emoji test: :) :+1: " +
"link test: http://sample.com email test: marymajor@example.com All member callout: " +
"@All All Present member callout: @Present"
val url = "https://abc/com"

val destination = MicrosoftTeamsDestination(url)
val message = MessageContent(title, messageText)

val actualMicrosoftTeamsResponse: DestinationMessageResponse = NotificationCoreImpl.sendMessage(destination, message, "ref")

assertEquals(expectedWebhookResponse.statusText, actualMicrosoftTeamsResponse.statusText)
assertEquals(expectedWebhookResponse.statusCode, actualMicrosoftTeamsResponse.statusCode)
}

@Test
fun `test MicrosoftTeams message empty entity response`() {
val mockHttpClient: CloseableHttpClient = EasyMock.createMock(CloseableHttpClient::class.java)
val expectedWebhookResponse = DestinationMessageResponse(RestStatus.OK.status, "{}")

val httpResponse = mockk<CloseableHttpResponse>()
EasyMock.expect(mockHttpClient.execute(EasyMock.anyObject(HttpPost::class.java))).andReturn(httpResponse)
every { httpResponse.code } returns RestStatus.OK.status
every { httpResponse.entity } returns StringEntity("")
EasyMock.replay(mockHttpClient)

val httpClient = DestinationHttpClient(mockHttpClient)
val webhookDestinationTransport = WebhookDestinationTransport(httpClient)
DestinationTransportProvider.destinationTransportMap = mapOf(DestinationType.MICROSOFT_TEAMS to webhookDestinationTransport)

val title = "test MicrosoftTeams"
val messageText = "{\"Content\":\"Message gughjhjlkh Body emoji test: :) :+1: " +
"link test: http://sample.com email test: marymajor@example.com All member callout: " +
"@All All Present member callout: @Present\"}"
val url = "https://abc/com"

val destination = MicrosoftTeamsDestination(url)
val message = MessageContent(title, messageText)

val actualMicrosoftTeamsResponse: DestinationMessageResponse = NotificationCoreImpl.sendMessage(destination, message, "ref")

assertEquals(expectedWebhookResponse.statusText, actualMicrosoftTeamsResponse.statusText)
assertEquals(expectedWebhookResponse.statusCode, actualMicrosoftTeamsResponse.statusCode)
}

@Test
fun `test MicrosoftTeams message non-empty entity response`() {
val responseContent = "It worked!"
val mockHttpClient: CloseableHttpClient = EasyMock.createMock(CloseableHttpClient::class.java)
val expectedWebhookResponse = DestinationMessageResponse(RestStatus.OK.status, responseContent)

val httpResponse = mockk<CloseableHttpResponse>()
EasyMock.expect(mockHttpClient.execute(EasyMock.anyObject(HttpPost::class.java))).andReturn(httpResponse)
every { httpResponse.code } returns RestStatus.OK.status
every { httpResponse.entity } returns StringEntity(responseContent)
EasyMock.replay(mockHttpClient)

val httpClient = DestinationHttpClient(mockHttpClient)
val webhookDestinationTransport = WebhookDestinationTransport(httpClient)
DestinationTransportProvider.destinationTransportMap = mapOf(DestinationType.MICROSOFT_TEAMS to webhookDestinationTransport)

val title = "test MicrosoftTeams"
val messageText = "{\"Content\":\"Message gughjhjlkh Body emoji test: :) :+1: " +
"link test: http://sample.com email test: marymajor@example.com All member callout: " +
"@All All Present member callout: @Present\"}"
val url = "https://abc/com"

val destination = MicrosoftTeamsDestination(url)
val message = MessageContent(title, messageText)

val actualMicrosoftTeamsResponse: DestinationMessageResponse = NotificationCoreImpl.sendMessage(destination, message, "ref")

assertEquals(expectedWebhookResponse.statusText, actualMicrosoftTeamsResponse.statusText)
assertEquals(expectedWebhookResponse.statusCode, actualMicrosoftTeamsResponse.statusCode)
}

@Test
fun `test url missing should throw IllegalArgumentException with message`() {
val exception = Assertions.assertThrows(IllegalArgumentException::class.java) {
MicrosoftTeamsDestination("")
}
assertEquals("url is null or empty", exception.message)
}

@Test
fun testUrlInvalidMessage() {
assertThrows<MalformedURLException> {
ChimeDestination("invalidUrl")
}
}

@ParameterizedTest
@MethodSource("escapeSequenceToRaw")
fun `test build webhook request body for microsoft teams should have title included and prevent escape`(
escapeSequence: String,
rawString: String
) {
val httpClient = DestinationHttpClient()
val title = "test MicrosoftTeams"
val messageText = "line1${escapeSequence}line2"
val url = "https://abc/com"
val expectedRequestBody = """{"text":"$title\n\nline1${rawString}line2"}"""
val destination = MicrosoftTeamsDestination(url)
val message = MessageContent(title, messageText)
val actualRequestBody = httpClient.buildRequestBody(destination, message)
assertEquals(expectedRequestBody, actualRequestBody)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ internal class PluginSettingsTests {
listOf(
"slack",
"chime",
"microsoft_teams",
"webhook",
"email",
"sns",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
*/

package org.opensearch.notifications.index

import org.opensearch.OpenSearchStatusException
import org.opensearch.commons.authuser.User
import org.opensearch.commons.notifications.action.CreateNotificationConfigRequest
Expand All @@ -23,6 +22,7 @@ import org.opensearch.commons.notifications.model.Chime
import org.opensearch.commons.notifications.model.ConfigType
import org.opensearch.commons.notifications.model.Email
import org.opensearch.commons.notifications.model.EmailGroup
import org.opensearch.commons.notifications.model.MicrosoftTeams
import org.opensearch.commons.notifications.model.NotificationConfig
import org.opensearch.commons.notifications.model.NotificationConfigInfo
import org.opensearch.commons.notifications.model.NotificationConfigSearchResult
Expand All @@ -39,7 +39,6 @@ import org.opensearch.notifications.model.DocMetadata
import org.opensearch.notifications.model.NotificationConfigDoc
import org.opensearch.notifications.security.UserAccess
import java.time.Instant

/**
* NotificationConfig indexing operation actions.
*/
Expand All @@ -65,6 +64,10 @@ object ConfigIndexingActions {
// TODO: URL validation with rules
}

private fun validateMicrosoftTeamsConfig(microsoftTeams: MicrosoftTeams, user: User?) {
require(microsoftTeams.url.contains(Regex("https://.*\\.webhook\\.office\\.com")))
}

@Suppress("UnusedPrivateMember")
private fun validateWebhookConfig(webhook: Webhook, user: User?) {
// TODO: URL validation with rules
Expand Down Expand Up @@ -162,6 +165,7 @@ object ConfigIndexingActions {
)
ConfigType.SLACK -> validateSlackConfig(config.configData as Slack, user)
ConfigType.CHIME -> validateChimeConfig(config.configData as Chime, user)
ConfigType.MICROSOFT_TEAMS -> validateMicrosoftTeamsConfig(config.configData as MicrosoftTeams, user)
ConfigType.WEBHOOK -> validateWebhookConfig(config.configData as Webhook, user)
ConfigType.EMAIL -> validateEmailConfig(config.configData as Email, user)
ConfigType.SMTP_ACCOUNT -> validateSmtpAccountConfig(config.configData as SmtpAccount, user)
Expand Down Expand Up @@ -369,6 +373,7 @@ object ConfigIndexingActions {
return listOf(
ConfigType.SLACK.tag,
ConfigType.CHIME.tag,
ConfigType.MICROSOFT_TEAMS.tag,
ConfigType.WEBHOOK.tag,
ConfigType.EMAIL.tag,
ConfigType.SNS.tag
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import org.opensearch.commons.notifications.NotificationConstants.URL_TAG
import org.opensearch.commons.notifications.model.ConfigType.CHIME
import org.opensearch.commons.notifications.model.ConfigType.EMAIL
import org.opensearch.commons.notifications.model.ConfigType.EMAIL_GROUP
import org.opensearch.commons.notifications.model.ConfigType.MICROSOFT_TEAMS
import org.opensearch.commons.notifications.model.ConfigType.SES_ACCOUNT
import org.opensearch.commons.notifications.model.ConfigType.SLACK
import org.opensearch.commons.notifications.model.ConfigType.SMTP_ACCOUNT
Expand Down Expand Up @@ -69,6 +70,7 @@ object ConfigQueryHelper {
"$DESCRIPTION_TAG.$KEYWORD_SUFFIX",
"${SLACK.tag}.$URL_TAG.$KEYWORD_SUFFIX",
"${CHIME.tag}.$URL_TAG.$KEYWORD_SUFFIX",
"${MICROSOFT_TEAMS.tag}.$URL_TAG.$KEYWORD_SUFFIX",
"${WEBHOOK.tag}.$URL_TAG.$KEYWORD_SUFFIX",
"${SMTP_ACCOUNT.tag}.$HOST_TAG.$KEYWORD_SUFFIX",
"${SMTP_ACCOUNT.tag}.$FROM_ADDRESS_TAG.$KEYWORD_SUFFIX",
Expand All @@ -82,6 +84,7 @@ object ConfigQueryHelper {
DESCRIPTION_TAG,
"${SLACK.tag}.$URL_TAG",
"${CHIME.tag}.$URL_TAG",
"${MICROSOFT_TEAMS.tag}.$URL_TAG",
"${WEBHOOK.tag}.$URL_TAG",
"${SMTP_ACCOUNT.tag}.$HOST_TAG",
"${SMTP_ACCOUNT.tag}.$FROM_ADDRESS_TAG",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ enum class Metrics(val metricName: String, val counter: Counter<*>) {
"notifications.message_destination.chime",
BasicCounter()
),
NOTIFICATIONS_MESSAGE_DESTINATION_MICROSOFT_TEAMS(
"notifications.message_destination.microsoft_teams",
BasicCounter()
),
NOTIFICATIONS_MESSAGE_DESTINATION_WEBHOOK(
"notifications.message_destination.webhook",
BasicCounter()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ internal class NotificationConfigRestHandler : PluginBaseHandler() {
* email_group.recipient_list.recipient=abc,xyz (Text filter field)
* slack.url=domain (Text filter field)
* chime.url=domain (Text filter field)
* microsoft_teams.url=domain (Text filter field)
* webhook.url=domain (Text filter field)
* smtp_account.host=domain (Text filter field)
* smtp_account.from_address=abc,xyz (Text filter field)
Expand Down
Loading

0 comments on commit 0ff5ba0

Please sign in to comment.