From a584df1cec3139b46923f1bc1beadffe1ef9797e Mon Sep 17 00:00:00 2001 From: adamkobor Date: Tue, 18 Aug 2020 22:22:06 +0200 Subject: [PATCH] Implement Slack integration (#36) --- README.md | 3 +- docs/kuvasz_avatar.png | Bin 0 -> 20407 bytes examples/docker-compose/docker-compose.yml | 2 + examples/k8s/kuvasz.configmap.yml | 2 + examples/k8s/kuvasz.deployment.yaml | 10 + .../handlers/SlackEventHandlerConfig.kt | 15 ++ .../kuvasz/factories/EmailFactory.kt | 25 +- .../kuvasz/handlers/LogEventHandler.kt | 6 +- .../kuvasz/handlers/SlackEventHandler.kt | 80 ++++++ .../com/kuvaszuptime/kuvasz/models/Emoji.kt | 6 + .../com/kuvaszuptime/kuvasz/models/Event.kt | 60 +++-- .../kuvasz/models/SlackWebhookMessage.kt | 15 ++ .../kuvasz/models/dto/Validation.kt | 2 +- .../kuvasz/services/SlackWebhookService.kt | 38 +++ src/main/resources/application.yml | 3 + .../kuvasz/config/SMTPMailerConfigTest.kt | 2 +- .../config/SlackEventHandlerConfigTest.kt | 48 ++++ .../kuvasz/handlers/SMTPEventHandlerTest.kt | 6 +- .../kuvasz/handlers/SlackEventHandlerTest.kt | 255 ++++++++++++++++++ 19 files changed, 551 insertions(+), 27 deletions(-) create mode 100644 docs/kuvasz_avatar.png create mode 100644 src/main/kotlin/com/kuvaszuptime/kuvasz/config/handlers/SlackEventHandlerConfig.kt create mode 100644 src/main/kotlin/com/kuvaszuptime/kuvasz/handlers/SlackEventHandler.kt create mode 100644 src/main/kotlin/com/kuvaszuptime/kuvasz/models/Emoji.kt create mode 100644 src/main/kotlin/com/kuvaszuptime/kuvasz/models/SlackWebhookMessage.kt create mode 100644 src/main/kotlin/com/kuvaszuptime/kuvasz/services/SlackWebhookService.kt create mode 100644 src/test/kotlin/com/kuvaszuptime/kuvasz/config/SlackEventHandlerConfigTest.kt create mode 100644 src/test/kotlin/com/kuvaszuptime/kuvasz/handlers/SlackEventHandlerTest.kt diff --git a/README.md b/README.md index 9839009..d84f14f 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,13 @@ Kuvasz (pronounce as [ˈkuvɒs]) is an ancient hungarian breed of livestock & gu - Uptime & latency monitoring with a configurable interval - Email notifications through SMTP +- Slack notifications through webhoooks ### Under development 🚧 - SSL certification monitoring - Regular Lighthouse audits for your websites -- Pagerduty, Opsgenie, Slack integration +- Pagerduty, Opsgenie integration - Kuvasz Dashboard, a standalone GUI ## ⚡️ Quick start guide diff --git a/docs/kuvasz_avatar.png b/docs/kuvasz_avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..b64b8db05680b6082cf557f5e9eb4522c0ea4b51 GIT binary patch literal 20407 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 Y0RKkn@S>tJ*Z=?k07*qoM6N<$g84Dw{{R30 literal 0 HcmV?d00001 diff --git a/examples/docker-compose/docker-compose.yml b/examples/docker-compose/docker-compose.yml index 7ecba41..bc33fde 100644 --- a/examples/docker-compose/docker-compose.yml +++ b/examples/docker-compose/docker-compose.yml @@ -26,3 +26,5 @@ services: SMTP_FROM_ADDRESS: 'noreply@kuvasz.uptime' SMTP_TO_ADDRESS: 'your.email@example.com' SMTP_TRANSPORT_STRATEGY: 'SMTP_TLS' + ENABLE_SLACK_EVENT_HANDLER: 'true' + SLACK_WEBHOOK_URL: 'https://your.slack-webhook.url' diff --git a/examples/k8s/kuvasz.configmap.yml b/examples/k8s/kuvasz.configmap.yml index 9ad4fbf..bae0c9a 100644 --- a/examples/k8s/kuvasz.configmap.yml +++ b/examples/k8s/kuvasz.configmap.yml @@ -16,3 +16,5 @@ data: smtp_from_address: "noreply@kuvasz.uptime" smtp_to_address: "your.address@example.com" smtp_transport_strategy: "SMTP_TLS" + slack_event_handler_enabled: "true" + slack_webhook_url: "https://your.slack-webhook.url" diff --git a/examples/k8s/kuvasz.deployment.yaml b/examples/k8s/kuvasz.deployment.yaml index f001fe3..afa16cb 100644 --- a/examples/k8s/kuvasz.deployment.yaml +++ b/examples/k8s/kuvasz.deployment.yaml @@ -116,3 +116,13 @@ spec: configMapKeyRef: name: kuvasz-config key: smtp_to_address + - name: ENABLE_SLACK_EVENT_HANDLER + valueFrom: + configMapKeyRef: + name: kuvasz-config + key: slack_event_handler_enabled + - name: SLACK_WEBHOOK_URL + valueFrom: + configMapKeyRef: + name: kuvasz-config + key: slack_webhook_url diff --git a/src/main/kotlin/com/kuvaszuptime/kuvasz/config/handlers/SlackEventHandlerConfig.kt b/src/main/kotlin/com/kuvaszuptime/kuvasz/config/handlers/SlackEventHandlerConfig.kt new file mode 100644 index 0000000..a57b395 --- /dev/null +++ b/src/main/kotlin/com/kuvaszuptime/kuvasz/config/handlers/SlackEventHandlerConfig.kt @@ -0,0 +1,15 @@ +package com.kuvaszuptime.kuvasz.config.handlers + +import com.kuvaszuptime.kuvasz.models.dto.Validation.URI_REGEX +import io.micronaut.context.annotation.ConfigurationProperties +import io.micronaut.core.annotation.Introspected +import javax.inject.Singleton +import javax.validation.constraints.Pattern + +@ConfigurationProperties("handler-config.slack-event-handler") +@Singleton +@Introspected +class SlackEventHandlerConfig { + @Pattern(regexp = URI_REGEX) + var webhookUrl: String? = null +} diff --git a/src/main/kotlin/com/kuvaszuptime/kuvasz/factories/EmailFactory.kt b/src/main/kotlin/com/kuvaszuptime/kuvasz/factories/EmailFactory.kt index 519b0d2..7416fc7 100644 --- a/src/main/kotlin/com/kuvaszuptime/kuvasz/factories/EmailFactory.kt +++ b/src/main/kotlin/com/kuvaszuptime/kuvasz/factories/EmailFactory.kt @@ -1,8 +1,11 @@ package com.kuvaszuptime.kuvasz.factories import com.kuvaszuptime.kuvasz.config.handlers.EmailEventHandlerConfig +import com.kuvaszuptime.kuvasz.models.MonitorDownEvent +import com.kuvaszuptime.kuvasz.models.MonitorUpEvent import com.kuvaszuptime.kuvasz.models.UptimeMonitorEvent -import com.kuvaszuptime.kuvasz.models.toMessage +import com.kuvaszuptime.kuvasz.models.toEmoji +import com.kuvaszuptime.kuvasz.models.toStructuredMessage import com.kuvaszuptime.kuvasz.models.toUptimeStatus import org.simplejavamail.api.email.Email import org.simplejavamail.email.EmailBuilder @@ -16,11 +19,29 @@ class EmailFactory(private val config: EmailEventHandlerConfig) { .buildEmail() private fun UptimeMonitorEvent.getSubject(): String = - "[kuvasz-uptime] - [${monitor.name}] ${monitor.url} is ${toUptimeStatus()}" + "[kuvasz-uptime] - ${toEmoji()} [${monitor.name}] ${monitor.url} is ${toUptimeStatus()}" private fun createEmailBase() = EmailBuilder .startingBlank() .to(config.to, config.to) .from(config.from, config.from) + + private fun UptimeMonitorEvent.toMessage() = + when (this) { + is MonitorUpEvent -> toStructuredMessage().let { details -> + listOfNotNull( + details.summary, + details.latency, + details.previousDownTime.orNull() + ) + } + is MonitorDownEvent -> toStructuredMessage().let { details -> + listOfNotNull( + details.summary, + details.error, + details.previousUpTime.orNull() + ) + } + }.joinToString("\n") } diff --git a/src/main/kotlin/com/kuvaszuptime/kuvasz/handlers/LogEventHandler.kt b/src/main/kotlin/com/kuvaszuptime/kuvasz/handlers/LogEventHandler.kt index 4f844a7..f9fda1b 100644 --- a/src/main/kotlin/com/kuvaszuptime/kuvasz/handlers/LogEventHandler.kt +++ b/src/main/kotlin/com/kuvaszuptime/kuvasz/handlers/LogEventHandler.kt @@ -1,7 +1,7 @@ package com.kuvaszuptime.kuvasz.handlers import com.kuvaszuptime.kuvasz.models.RedirectEvent -import com.kuvaszuptime.kuvasz.models.toMessage +import com.kuvaszuptime.kuvasz.models.toPlainMessage import com.kuvaszuptime.kuvasz.services.EventDispatcher import io.micronaut.context.annotation.Context import io.micronaut.context.annotation.Requires @@ -17,10 +17,10 @@ class LogEventHandler @Inject constructor(eventDispatcher: EventDispatcher) { init { eventDispatcher.subscribeToMonitorUpEvents { event -> - logger.info(event.toMessage()) + logger.info(event.toPlainMessage()) } eventDispatcher.subscribeToMonitorDownEvents { event -> - logger.error(event.toMessage()) + logger.error(event.toPlainMessage()) } eventDispatcher.subscribeToRedirectEvents { event -> logger.warn(event.toLogMessage()) diff --git a/src/main/kotlin/com/kuvaszuptime/kuvasz/handlers/SlackEventHandler.kt b/src/main/kotlin/com/kuvaszuptime/kuvasz/handlers/SlackEventHandler.kt new file mode 100644 index 0000000..a2ca10d --- /dev/null +++ b/src/main/kotlin/com/kuvaszuptime/kuvasz/handlers/SlackEventHandler.kt @@ -0,0 +1,80 @@ +package com.kuvaszuptime.kuvasz.handlers + +import com.kuvaszuptime.kuvasz.models.MonitorDownEvent +import com.kuvaszuptime.kuvasz.models.MonitorUpEvent +import com.kuvaszuptime.kuvasz.models.SlackWebhookMessage +import com.kuvaszuptime.kuvasz.models.UptimeMonitorEvent +import com.kuvaszuptime.kuvasz.models.runWhenStateChanges +import com.kuvaszuptime.kuvasz.models.toEmoji +import com.kuvaszuptime.kuvasz.models.toStructuredMessage +import com.kuvaszuptime.kuvasz.services.EventDispatcher +import com.kuvaszuptime.kuvasz.services.SlackWebhookService +import io.micronaut.context.annotation.Context +import io.micronaut.context.annotation.Requires +import io.micronaut.http.HttpResponse +import io.micronaut.http.client.exceptions.HttpClientResponseException +import io.micronaut.scheduling.TaskExecutors +import io.micronaut.scheduling.annotation.ExecuteOn +import io.reactivex.Flowable +import org.slf4j.LoggerFactory +import javax.inject.Inject + +@Context +@Requires(property = "handler-config.slack-event-handler.enabled", value = "true") +class SlackEventHandler @Inject constructor( + private val slackWebhookService: SlackWebhookService, + private val eventDispatcher: EventDispatcher +) { + companion object { + private val logger = LoggerFactory.getLogger(SlackEventHandler::class.java) + } + + init { + subscribeToEvents() + } + + @ExecuteOn(TaskExecutors.IO) + private fun subscribeToEvents() { + eventDispatcher.subscribeToMonitorUpEvents { event -> + logger.debug("A MonitorUpEvent has been received for monitor with ID: ${event.monitor.id}") + event.runWhenStateChanges { slackWebhookService.sendMessage(it.toSlackMessage()).handleResponse() } + } + eventDispatcher.subscribeToMonitorDownEvents { event -> + logger.debug("A MonitorDownEvent has been received for monitor with ID: ${event.monitor.id}") + event.runWhenStateChanges { slackWebhookService.sendMessage(it.toSlackMessage()).handleResponse() } + } + } + + private fun UptimeMonitorEvent.toSlackMessage() = SlackWebhookMessage(text = "${toEmoji()} ${toMessage()}") + + private fun Flowable>.handleResponse() = + subscribe( + { + logger.debug("A Slack message to your configured webhook has been successfully sent") + }, + { ex -> + if (ex is HttpClientResponseException) { + val responseBody = ex.response.getBody(String::class.java) + logger.error("Slack message cannot be sent to your configured webhook: $responseBody") + } + } + ) + + private fun UptimeMonitorEvent.toMessage() = + when (this) { + is MonitorUpEvent -> toStructuredMessage().let { details -> + listOfNotNull( + "*${details.summary}*", + "_${details.latency}_", + details.previousDownTime.orNull() + ) + } + is MonitorDownEvent -> toStructuredMessage().let { details -> + listOfNotNull( + "*${details.summary}*", + "_${details.error}_", + details.previousUpTime.orNull() + ) + } + }.joinToString("\n") +} diff --git a/src/main/kotlin/com/kuvaszuptime/kuvasz/models/Emoji.kt b/src/main/kotlin/com/kuvaszuptime/kuvasz/models/Emoji.kt new file mode 100644 index 0000000..9300f7d --- /dev/null +++ b/src/main/kotlin/com/kuvaszuptime/kuvasz/models/Emoji.kt @@ -0,0 +1,6 @@ +package com.kuvaszuptime.kuvasz.models + +object Emoji { + const val ALERT = "🚨" + const val CHECK_OK = "✅" +} diff --git a/src/main/kotlin/com/kuvaszuptime/kuvasz/models/Event.kt b/src/main/kotlin/com/kuvaszuptime/kuvasz/models/Event.kt index 3c0500e..b83e4bd 100644 --- a/src/main/kotlin/com/kuvaszuptime/kuvasz/models/Event.kt +++ b/src/main/kotlin/com/kuvaszuptime/kuvasz/models/Event.kt @@ -40,12 +40,30 @@ data class RedirectEvent( val redirectLocation: URI ) : Event() +data class StructuredUpMessage( + val summary: String, + val latency: String, + val previousDownTime: Option +) + +data class StructuredDownMessage( + val summary: String, + val error: String, + val previousUpTime: Option +) + fun UptimeMonitorEvent.toUptimeStatus(): UptimeStatus = when (this) { is MonitorUpEvent -> UptimeStatus.UP is MonitorDownEvent -> UptimeStatus.DOWN } +fun UptimeMonitorEvent.toEmoji(): String = + when (this) { + is MonitorUpEvent -> Emoji.CHECK_OK + is MonitorDownEvent -> Emoji.ALERT + } + fun UptimeMonitorEvent.uptimeStatusEquals(previousEvent: UptimeEventPojo) = toUptimeStatus() == previousEvent.status @@ -73,24 +91,34 @@ fun UptimeMonitorEvent.runWhenStateChanges(toRun: (UptimeMonitorEvent) -> Unit) ) } -fun UptimeMonitorEvent.toMessage(): String = - when (this) { - is MonitorUpEvent -> toMessage() - is MonitorDownEvent -> toMessage() +fun MonitorUpEvent.toPlainMessage(): String = + toStructuredMessage().let { details -> + listOfNotNull( + details.summary, + details.latency, + details.previousDownTime.orNull() + ).joinToString(". ") } -fun MonitorUpEvent.toMessage(): String { - val message = "Your monitor \"${monitor.name}\" (${monitor.url}) is UP (${status.code}). Latency was: ${latency}ms." - return getEndedEventDuration().toDurationString().fold( - { message }, - { "$message Was down for $it." } +fun MonitorUpEvent.toStructuredMessage() = + StructuredUpMessage( + summary = "Your monitor \"${monitor.name}\" (${monitor.url}) is UP (${status.code})", + latency = "Latency: ${latency}ms", + previousDownTime = getEndedEventDuration().toDurationString().map { "Was down for $it" } ) -} -fun MonitorDownEvent.toMessage(): String { - val message = "Your monitor \"${monitor.name}\" (${monitor.url}) is DOWN. Reason: ${error.message}." - return getEndedEventDuration().toDurationString().fold( - { message }, - { "$message Was up for $it." } +fun MonitorDownEvent.toPlainMessage(): String = + toStructuredMessage().let { details -> + listOfNotNull( + details.summary, + details.error, + details.previousUpTime.orNull() + ).joinToString(". ") + } + +fun MonitorDownEvent.toStructuredMessage() = + StructuredDownMessage( + summary = "Your monitor \"${monitor.name}\" (${monitor.url}) is DOWN", + error = "Reason: ${error.message}", + previousUpTime = getEndedEventDuration().toDurationString().map { "Was up for $it" } ) -} diff --git a/src/main/kotlin/com/kuvaszuptime/kuvasz/models/SlackWebhookMessage.kt b/src/main/kotlin/com/kuvaszuptime/kuvasz/models/SlackWebhookMessage.kt new file mode 100644 index 0000000..fde3bfe --- /dev/null +++ b/src/main/kotlin/com/kuvaszuptime/kuvasz/models/SlackWebhookMessage.kt @@ -0,0 +1,15 @@ +package com.kuvaszuptime.kuvasz.models + +import com.fasterxml.jackson.annotation.JsonProperty +import java.net.URI + +data class SlackWebhookMessage( + val username: String = "KuvaszBot", + @JsonProperty("icon_url") + val iconUrl: URI = URI(ICON_URL), + val text: String +) { + companion object { + const val ICON_URL = "https://raw.githubusercontent.com/kuvasz-uptime/kuvasz/main/docs/kuvasz_avatar.png" + } +} diff --git a/src/main/kotlin/com/kuvaszuptime/kuvasz/models/dto/Validation.kt b/src/main/kotlin/com/kuvaszuptime/kuvasz/models/dto/Validation.kt index 6c5c7c5..175dad3 100644 --- a/src/main/kotlin/com/kuvaszuptime/kuvasz/models/dto/Validation.kt +++ b/src/main/kotlin/com/kuvaszuptime/kuvasz/models/dto/Validation.kt @@ -1,6 +1,6 @@ package com.kuvaszuptime.kuvasz.models.dto -internal object Validation { +object Validation { const val MIN_UPTIME_CHECK_INTERVAL = 60L const val URI_REGEX = "^(https?)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]" } diff --git a/src/main/kotlin/com/kuvaszuptime/kuvasz/services/SlackWebhookService.kt b/src/main/kotlin/com/kuvaszuptime/kuvasz/services/SlackWebhookService.kt new file mode 100644 index 0000000..1aade0e --- /dev/null +++ b/src/main/kotlin/com/kuvaszuptime/kuvasz/services/SlackWebhookService.kt @@ -0,0 +1,38 @@ +package com.kuvaszuptime.kuvasz.services + +import com.kuvaszuptime.kuvasz.config.handlers.SlackEventHandlerConfig +import com.kuvaszuptime.kuvasz.models.SlackWebhookMessage +import io.micronaut.context.event.ShutdownEvent +import io.micronaut.core.type.Argument +import io.micronaut.http.HttpRequest +import io.micronaut.http.HttpResponse +import io.micronaut.http.client.RxHttpClient +import io.micronaut.runtime.event.annotation.EventListener +import io.reactivex.Flowable +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SlackWebhookService @Inject constructor( + private val slackEventHandlerConfig: SlackEventHandlerConfig, + private val httpClient: RxHttpClient +) { + + companion object { + private const val RETRY_COUNT = 3L + } + + fun sendMessage(message: SlackWebhookMessage): Flowable> { + val request: HttpRequest = HttpRequest.POST(slackEventHandlerConfig.webhookUrl, message) + + return httpClient + .exchange(request, Argument.STRING, Argument.STRING) + .retry(RETRY_COUNT) + } + + @EventListener + @Suppress("UNUSED_PARAMETER") + internal fun onShutdownEvent(event: ShutdownEvent) { + httpClient.close() + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 761b08f..39f109f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -75,6 +75,9 @@ handler-config: enabled: ${ENABLE_SMTP_EVENT_HANDLER:`false`} from: ${SMTP_FROM_ADDRESS} to: ${SMTP_TO_ADDRESS} + slack-event-handler: + enabled: ${ENABLE_SLACK_EVENT_HANDLER:`false`} + webhook-url: ${SLACK_WEBHOOK_URL} --- admin-auth: username: ${ADMIN_USER} diff --git a/src/test/kotlin/com/kuvaszuptime/kuvasz/config/SMTPMailerConfigTest.kt b/src/test/kotlin/com/kuvaszuptime/kuvasz/config/SMTPMailerConfigTest.kt index 28527a8..753f3c1 100644 --- a/src/test/kotlin/com/kuvaszuptime/kuvasz/config/SMTPMailerConfigTest.kt +++ b/src/test/kotlin/com/kuvaszuptime/kuvasz/config/SMTPMailerConfigTest.kt @@ -9,7 +9,7 @@ import io.micronaut.context.env.PropertySource import io.micronaut.context.exceptions.BeanInstantiationException class SMTPMailerConfigTest : BehaviorSpec({ - given("an SMTPMailerConfigTest bean") { + given("an SMTPMailerConfig bean") { `when`("the SMTP host does not exists") { val properties = PropertySource.of( "test", diff --git a/src/test/kotlin/com/kuvaszuptime/kuvasz/config/SlackEventHandlerConfigTest.kt b/src/test/kotlin/com/kuvaszuptime/kuvasz/config/SlackEventHandlerConfigTest.kt new file mode 100644 index 0000000..2ff90d1 --- /dev/null +++ b/src/test/kotlin/com/kuvaszuptime/kuvasz/config/SlackEventHandlerConfigTest.kt @@ -0,0 +1,48 @@ +package com.kuvaszuptime.kuvasz.config + +import io.kotest.assertions.exceptionToMessage +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.string.shouldContain +import io.micronaut.context.ApplicationContext +import io.micronaut.context.env.PropertySource +import io.micronaut.context.exceptions.BeanInstantiationException + +class SlackEventHandlerConfigTest : BehaviorSpec({ + given("an SlackEventHandlerConfig bean") { + `when`("there is no webhook URL in the configuration") { + val properties = PropertySource.of( + "test", + mapOf( + "handler-config.slack-event-handler.enabled" to "true" + ) + ) + then("ApplicationContext should throw a BeanInstantiationException") { + val exception = shouldThrow { + ApplicationContext.run(properties) + } + println(exceptionToMessage(exception)) + exceptionToMessage(exception) shouldContain + "Bean definition [com.kuvaszuptime.kuvasz.handlers.SlackEventHandler] could not be loaded" + } + } + + `when`("there the webhookUrl is not a valid URI") { + val properties = PropertySource.of( + "test", + mapOf( + "handler-config.slack-event-handler.enabled" to "true", + "handler-config.slack-event-handler.webhook-url" to "jklfdjaklfjdalfda" + ) + ) + then("ApplicationContext should throw a BeanInstantiationException") { + val exception = shouldThrow { + ApplicationContext.run(properties) + } + println(exceptionToMessage(exception)) + exceptionToMessage(exception) shouldContain + "Bean definition [com.kuvaszuptime.kuvasz.handlers.SlackEventHandler] could not be loaded" + } + } + } +}) diff --git a/src/test/kotlin/com/kuvaszuptime/kuvasz/handlers/SMTPEventHandlerTest.kt b/src/test/kotlin/com/kuvaszuptime/kuvasz/handlers/SMTPEventHandlerTest.kt index 6109950..72fccb7 100644 --- a/src/test/kotlin/com/kuvaszuptime/kuvasz/handlers/SMTPEventHandlerTest.kt +++ b/src/test/kotlin/com/kuvaszuptime/kuvasz/handlers/SMTPEventHandlerTest.kt @@ -109,7 +109,7 @@ class SMTPEventHandlerTest( val slot = slot() verify(exactly = 1) { mailerSpy.sendAsync(capture(slot)) } - slot.captured.plainText shouldContain "Latency was: 1000ms" + slot.captured.plainText shouldContain "Latency: 1000ms" slot.captured.plainText shouldBe expectedEmail.plainText slot.captured.subject shouldContain "is UP" slot.captured.subject shouldBe expectedEmail.subject @@ -176,7 +176,7 @@ class SMTPEventHandlerTest( emailsSent[0].plainText shouldBe firstExpectedEmail.plainText emailsSent[0].subject shouldContain "is DOWN" emailsSent[0].subject shouldBe firstExpectedEmail.subject - emailsSent[1].plainText shouldContain "Latency was: 1000ms" + emailsSent[1].plainText shouldContain "Latency: 1000ms" emailsSent[1].plainText shouldBe secondExpectedEmail.plainText emailsSent[1].subject shouldContain "is UP" emailsSent[1].subject shouldBe secondExpectedEmail.subject @@ -209,7 +209,7 @@ class SMTPEventHandlerTest( val emailsSent = mutableListOf() verify(exactly = 2) { mailerSpy.sendAsync(capture(emailsSent)) } - emailsSent[0].plainText shouldContain "Latency was: 1000ms" + emailsSent[0].plainText shouldContain "Latency: 1000ms" emailsSent[0].plainText shouldBe firstExpectedEmail.plainText emailsSent[0].subject shouldContain "is UP" emailsSent[0].subject shouldBe firstExpectedEmail.subject diff --git a/src/test/kotlin/com/kuvaszuptime/kuvasz/handlers/SlackEventHandlerTest.kt b/src/test/kotlin/com/kuvaszuptime/kuvasz/handlers/SlackEventHandlerTest.kt new file mode 100644 index 0000000..7d1137d --- /dev/null +++ b/src/test/kotlin/com/kuvaszuptime/kuvasz/handlers/SlackEventHandlerTest.kt @@ -0,0 +1,255 @@ +package com.kuvaszuptime.kuvasz.handlers + +import arrow.core.Option +import com.kuvaszuptime.kuvasz.DatabaseBehaviorSpec +import com.kuvaszuptime.kuvasz.config.handlers.SlackEventHandlerConfig +import com.kuvaszuptime.kuvasz.mocks.createMonitor +import com.kuvaszuptime.kuvasz.models.MonitorDownEvent +import com.kuvaszuptime.kuvasz.models.MonitorUpEvent +import com.kuvaszuptime.kuvasz.models.SlackWebhookMessage +import com.kuvaszuptime.kuvasz.repositories.MonitorRepository +import com.kuvaszuptime.kuvasz.repositories.UptimeEventRepository +import com.kuvaszuptime.kuvasz.services.EventDispatcher +import com.kuvaszuptime.kuvasz.services.SlackWebhookService +import com.kuvaszuptime.kuvasz.tables.UptimeEvent.UPTIME_EVENT +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.core.test.TestCase +import io.kotest.core.test.TestResult +import io.kotest.matchers.string.shouldContain +import io.micronaut.core.type.Argument +import io.micronaut.http.HttpResponse +import io.micronaut.http.HttpStatus +import io.micronaut.http.client.RxHttpClient +import io.micronaut.http.client.exceptions.HttpClientResponseException +import io.micronaut.test.annotation.MicronautTest +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.spyk +import io.mockk.verify +import io.reactivex.Flowable + +@MicronautTest +class SlackEventHandlerTest( + private val eventDispatcher: EventDispatcher, + private val monitorRepository: MonitorRepository, + private val uptimeEventRepository: UptimeEventRepository + +) : DatabaseBehaviorSpec() { + private val mockHttpClient = mockk() + + init { + val eventHandlerConfig = SlackEventHandlerConfig().apply { webhookUrl = "https://jklfdalda.com/webhook" } + val slackWebhookService = SlackWebhookService(eventHandlerConfig, mockHttpClient) + val webhookServiceSpy = spyk(slackWebhookService, recordPrivateCalls = true) + SlackEventHandler(webhookServiceSpy, eventDispatcher) + + given("the SlackEventHandler") { + `when`("it receives a MonitorUpEvent and there is no previous event for the monitor") { + val monitor = createMonitor(monitorRepository) + val event = MonitorUpEvent( + monitor = monitor, + status = HttpStatus.OK, + latency = 1000, + previousEvent = Option.empty() + ) + mockHttpResponse(HttpStatus.OK) + + eventDispatcher.dispatch(event) + + then("it should send a webhook message about the event") { + val slot = slot() + + verify(exactly = 1) { webhookServiceSpy.sendMessage(capture(slot)) } + slot.captured.text shouldContain "Your monitor \"testMonitor\" (http://irrelevant.com) is UP (200)" + } + } + + `when`("it receives a MonitorDownEvent and there is no previous event for the monitor") { + val monitor = createMonitor(monitorRepository) + val event = MonitorDownEvent( + monitor = monitor, + status = HttpStatus.INTERNAL_SERVER_ERROR, + error = Throwable(), + previousEvent = Option.empty() + ) + mockHttpResponse(HttpStatus.OK) + + eventDispatcher.dispatch(event) + + then("it should send a webhook message about the event") { + val slot = slot() + + verify(exactly = 1) { webhookServiceSpy.sendMessage(capture(slot)) } + slot.captured.text shouldContain "Your monitor \"testMonitor\" (http://irrelevant.com) is DOWN" + } + } + + `when`("it receives a MonitorUpEvent and there is a previous event with the same status") { + val monitor = createMonitor(monitorRepository) + val firstEvent = MonitorUpEvent( + monitor = monitor, + status = HttpStatus.OK, + latency = 1000, + previousEvent = Option.empty() + ) + mockHttpResponse(HttpStatus.OK) + eventDispatcher.dispatch(firstEvent) + val firstUptimeRecord = uptimeEventRepository.fetchOne(UPTIME_EVENT.MONITOR_ID, monitor.id) + + val secondEvent = MonitorUpEvent( + monitor = monitor, + status = HttpStatus.OK, + latency = 1200, + previousEvent = Option.just(firstUptimeRecord) + ) + eventDispatcher.dispatch(secondEvent) + + then("it should send only one notification about them") { + val slot = slot() + + verify(exactly = 1) { webhookServiceSpy.sendMessage(capture(slot)) } + slot.captured.text shouldContain "Latency: 1000ms" + } + } + + `when`("it receives a MonitorDownEvent and there is a previous event with the same status") { + val monitor = createMonitor(monitorRepository) + val firstEvent = MonitorDownEvent( + monitor = monitor, + status = HttpStatus.INTERNAL_SERVER_ERROR, + error = Throwable("First error"), + previousEvent = Option.empty() + ) + mockHttpResponse(HttpStatus.OK) + eventDispatcher.dispatch(firstEvent) + val firstUptimeRecord = uptimeEventRepository.fetchOne(UPTIME_EVENT.MONITOR_ID, monitor.id) + + val secondEvent = MonitorDownEvent( + monitor = monitor, + status = HttpStatus.INTERNAL_SERVER_ERROR, + error = Throwable("Second error"), + previousEvent = Option.just(firstUptimeRecord) + ) + eventDispatcher.dispatch(secondEvent) + + then("it should send only one notification about them") { + val slot = slot() + + verify(exactly = 1) { webhookServiceSpy.sendMessage(capture(slot)) } + slot.captured.text shouldContain "First error" + } + } + + `when`("it receives a MonitorUpEvent and there is a previous event with different status") { + val monitor = createMonitor(monitorRepository) + val firstEvent = MonitorDownEvent( + monitor = monitor, + status = HttpStatus.INTERNAL_SERVER_ERROR, + previousEvent = Option.empty(), + error = Throwable() + ) + mockHttpResponse(HttpStatus.OK) + eventDispatcher.dispatch(firstEvent) + val firstUptimeRecord = uptimeEventRepository.fetchOne(UPTIME_EVENT.MONITOR_ID, monitor.id) + + val secondEvent = MonitorUpEvent( + monitor = monitor, + status = HttpStatus.OK, + latency = 1000, + previousEvent = Option.just(firstUptimeRecord) + ) + eventDispatcher.dispatch(secondEvent) + + then("it should send two different notifications about them") { + val notificationsSent = mutableListOf() + + verify(exactly = 2) { webhookServiceSpy.sendMessage(capture(notificationsSent)) } + notificationsSent[0].text shouldContain "is DOWN" + notificationsSent[1].text shouldContain "Latency: 1000ms" + notificationsSent[1].text shouldContain "is UP" + } + } + + `when`("it receives a MonitorDownEvent and there is a previous event with different status") { + val monitor = createMonitor(monitorRepository) + val firstEvent = MonitorUpEvent( + monitor = monitor, + status = HttpStatus.OK, + latency = 1000, + previousEvent = Option.empty() + ) + mockHttpResponse(HttpStatus.OK) + eventDispatcher.dispatch(firstEvent) + val firstUptimeRecord = uptimeEventRepository.fetchOne(UPTIME_EVENT.MONITOR_ID, monitor.id) + + val secondEvent = MonitorDownEvent( + monitor = monitor, + status = HttpStatus.INTERNAL_SERVER_ERROR, + previousEvent = Option.just(firstUptimeRecord), + error = Throwable() + ) + eventDispatcher.dispatch(secondEvent) + + then("it should send two different notifications about them") { + val notificationsSent = mutableListOf() + + verify(exactly = 2) { webhookServiceSpy.sendMessage(capture(notificationsSent)) } + notificationsSent[0].text shouldContain "Latency: 1000ms" + notificationsSent[0].text shouldContain "is UP" + notificationsSent[1].text shouldContain "is DOWN" + } + } + + `when`("it receives an event but an error happens when it calls the webhook") { + val monitor = createMonitor(monitorRepository) + val event = MonitorUpEvent( + monitor = monitor, + status = HttpStatus.OK, + latency = 1000, + previousEvent = Option.empty() + ) + mockHttpErrorResponse(HttpStatus.BAD_REQUEST, "bad_request") + + then("it should send a webhook message about the event") { + val slot = slot() + + shouldNotThrowAny { eventDispatcher.dispatch(event) } + verify(exactly = 1) { webhookServiceSpy.sendMessage(capture(slot)) } + slot.captured.text shouldContain "Your monitor \"testMonitor\" (http://irrelevant.com) is UP (200)" + + } + } + } + } + + override fun afterTest(testCase: TestCase, result: TestResult) { + clearAllMocks() + super.afterTest(testCase, result) + } + + private fun mockHttpResponse(status: HttpStatus, body: String = "") { + every { + mockHttpClient.exchange( + any(), + Argument.STRING, + Argument.STRING + ) + } returns Flowable.just( + HttpResponse.status(status).body(body) + ) + } + + private fun mockHttpErrorResponse(status: HttpStatus, body: String = "") { + every { + mockHttpClient.exchange( + any(), + Argument.STRING, + Argument.STRING + ) + } returns Flowable.error( + HttpClientResponseException("error", HttpResponse.status(status).body(body)) + ) + } +}