Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Microsoft teams #676

Merged
merged 25 commits into from
Aug 24, 2023
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
add210b
Added feature support for microsoft teams webhoo
dankyalo599 Feb 10, 2023
6845949
Added feature support for microsoft teams webhook ,removed valid webh…
dankyalo599 Feb 10, 2023
077fd85
Added feature support for Microsoft teams webhook
dankyalo599 Feb 10, 2023
5a0196f
Refactored feature support for ms teams and added unit and integTest
dankyalo599 Feb 24, 2023
edbc3ce
Merge remote-tracking branch 'dankyalo599/Add_unit_integTest' into mi…
zhichao-aws May 12, 2023
93dc458
fix build in core
zhichao-aws May 15, 2023
b5cc50d
fix core-spi build
zhichao-aws May 15, 2023
3e717e1
fix notifications main code
zhichao-aws May 15, 2023
4c07241
fix mappings, add IT
zhichao-aws May 16, 2023
28c403f
add auto upgrade mapping logic
zhichao-aws Jul 3, 2023
02e1e76
put load mapping to initialize step
zhichao-aws Jul 3, 2023
5ff36e3
add schema_version field
zhichao-aws Jul 3, 2023
fe478b2
add integ test
zhichao-aws Jul 3, 2023
9ab78f6
Merge branch 'main' into microsoft_teams
zhichao-aws Jul 3, 2023
89fc8b6
Merge branch 'auto_upgrade_mappings' into microsoft_teams
zhichao-aws Jul 3, 2023
99893a2
adjust with auto upgrade mapping logic
zhichao-aws Jul 3, 2023
8d874c5
add bwc
zhichao-aws Jul 3, 2023
308589a
Merge branch 'main' into microsoft_teams
zhichao-aws Aug 7, 2023
391b0fd
fix merge
zhichao-aws Aug 8, 2023
08aa837
modify bwc
zhichao-aws Aug 8, 2023
35ca106
Merge branch 'main' into microsoft_teams
zhichao-aws Aug 16, 2023
0c2883c
modify bwc
zhichao-aws Aug 21, 2023
f13e1f8
resolve comments
zhichao-aws Aug 24, 2023
c330a1c
add license header
zhichao-aws Aug 24, 2023
18c8af1
fix microsoft teams sample url in IT to adapt url validation
zhichao-aws Aug 24, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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))
}
}
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
zhichao-aws marked this conversation as resolved.
Show resolved Hide resolved
// 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,11 @@ object ConfigIndexingActions {
// TODO: URL validation with rules
}

@Suppress("UnusedPrivateMember")
private fun validateMicrosoftTeamsConfig(microsoftTeams: MicrosoftTeams, user: User?) {
// TODO: host validation with rules
zhichao-aws marked this conversation as resolved.
Show resolved Hide resolved
}

@Suppress("UnusedPrivateMember")
private fun validateWebhookConfig(webhook: Webhook, user: User?) {
// TODO: URL validation with rules
Expand Down Expand Up @@ -162,6 +166,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 +374,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
Loading