diff --git a/README.md b/README.md index f5ff06e..56aab04 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,12 @@ Here's a list of features included in this project: | Name | Description | |------------------------------------------|--------------------------------------------| | /health | Healthcheck endpoint returning HTTP OK 200. | -| /{conversation_id}/{conversation_domain} | Webhook endpoint. | +| /github/webhook | GitHub App repository webhook endpoint. | + +When someone posts a GitHub pull request link in a Wire conversation, the app +creates a repository webhook for pull request events and routes future GitHub +events for that repository back to the conversation. Repository webhooks are +removed after the configured inactivity period. ## Building & Running @@ -43,12 +48,22 @@ GHAPP_API_HOST=https://127.0.0.1/github GHAPP_SERVER_PORT=8083 GHAPP_REDIS_HOST=redis://redis GHAPP_REDIS_PORT=6380 +GHAPP_GITHUB_CLIENT_ID=Iv1.example +GHAPP_GITHUB_PRIVATE_KEY_FILE=/run/secrets/github-app-private-key.pem +GHAPP_GITHUB_WEBHOOK_SECRET=exampleWebhookSecret +GHAPP_GITHUB_REPO_INACTIVITY_SECONDS=604800 WIRE_SDK_API_HOST=https://nginz-https.chala.wire.link WIRE_SDK_API_TOKEN=myApiToken WIRE_SDK_APP_ID=f562e146-dec2-4d85-93c7-7132746b5cca WIRE_SDK_CRYPTOGRAPHY_STORAGE_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= ``` +The GitHub App must be installed for the organization/repositories it should +manage and needs repository webhook write permissions. `GHAPP_GITHUB_CLIENT_ID` +and `GHAPP_GITHUB_PRIVATE_KEY_FILE` are used for GitHub App server-to-server +authentication; `GHAPP_GITHUB_WEBHOOK_SECRET` is configured on created +repository webhooks and used to validate incoming GitHub deliveries. + ## Deployment Currently, we are only deploying in our Integrations VM. diff --git a/build.gradle.kts b/build.gradle.kts index 4336494..3dd5686 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -113,9 +113,16 @@ tasks { environment("GHAPP_SERVER_PORT", "8083") environment("GHAPP_REDIS_HOST", "redis://localhost") environment("GHAPP_REDIS_PORT", "6379") + environment("GHAPP_GITHUB_CLIENT_ID", "dummyClientId") + environment("GHAPP_GITHUB_PRIVATE_KEY_FILE", "/dev/null") + environment("GHAPP_GITHUB_WEBHOOK_SECRET", "dummyWebhookSecret") + environment("GHAPP_GITHUB_REPO_INACTIVITY_SECONDS", "604800") environment("WIRE_SDK_API_HOST", "https://nginz-https.chala.wire.link") environment("WIRE_SDK_API_TOKEN", "myApiToken") environment("WIRE_SDK_APP_ID", "f562e146-dec2-4d85-93c7-7132746b5cca") - environment("WIRE_SDK_CRYPTOGRAPHY_STORAGE_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + environment( + "WIRE_SDK_CRYPTOGRAPHY_STORAGE_KEY", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + ) } } diff --git a/docker-compose.yml b/docker-compose.yml index b33ff44..59c887b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,10 @@ services: - GHAPP_SERVER_PORT=${GHAPP_SERVER_PORT} - GHAPP_REDIS_HOST=${GHAPP_REDIS_HOST} - GHAPP_REDIS_PORT=${GHAPP_REDIS_PORT} + - GHAPP_GITHUB_CLIENT_ID=${GHAPP_GITHUB_CLIENT_ID} + - GHAPP_GITHUB_PRIVATE_KEY_FILE=${GHAPP_GITHUB_PRIVATE_KEY_FILE} + - GHAPP_GITHUB_WEBHOOK_SECRET=${GHAPP_GITHUB_WEBHOOK_SECRET} + - GHAPP_GITHUB_REPO_INACTIVITY_SECONDS=${GHAPP_GITHUB_REPO_INACTIVITY_SECONDS} - WIRE_SDK_API_HOST=${WIRE_SDK_API_HOST} - WIRE_SDK_API_TOKEN=${WIRE_SDK_API_TOKEN} - WIRE_SDK_APP_ID=${WIRE_SDK_APP_ID} diff --git a/src/main/kotlin/com/wire/github/EventsHandler.kt b/src/main/kotlin/com/wire/github/EventsHandler.kt index 60b6703..a2d94fd 100644 --- a/src/main/kotlin/com/wire/github/EventsHandler.kt +++ b/src/main/kotlin/com/wire/github/EventsHandler.kt @@ -1,21 +1,19 @@ package com.wire.github -import com.wire.github.util.ENV_VAR_HOST -import com.wire.github.util.SessionIdentifierGenerator -import com.wire.github.util.toStorageKey +import com.wire.github.github.GitHubPullRequestLinkParser +import com.wire.github.github.GitHubRepository +import com.wire.github.github.GitHubWebhookManager import com.wire.sdk.WireEventsHandlerSuspending import com.wire.sdk.model.Conversation import com.wire.sdk.model.ConversationMember import com.wire.sdk.model.QualifiedId import com.wire.sdk.model.WireMessage -import io.lettuce.core.api.StatefulRedisConnection import org.koin.core.context.GlobalContext import org.slf4j.LoggerFactory class EventsHandler : WireEventsHandlerSuspending() { private val logger = LoggerFactory.getLogger(this::class.java) - private val redisConnection = GlobalContext.get().get>() - private val storage = redisConnection.sync() + private val gitHubWebhookManager by lazy { GlobalContext.get().get() } override suspend fun onTextMessageReceived(wireMessage: WireMessage.Text) { if (wireMessage.text.equals(HELP_COMMAND, ignoreCase = true)) { @@ -24,22 +22,30 @@ class EventsHandler : WireEventsHandlerSuspending() { "conversationId: ${wireMessage.conversationId}, " + "senderId: ${wireMessage.sender}" ) - val message = formatSetupInstructions( + sendMessage( conversationId = wireMessage.conversationId, - secret = storage.get(wireMessage.conversationId.toStorageKey()) - ) - - manager.sendMessage( - message = WireMessage.Text.create( - conversationId = wireMessage.conversationId, - text = message - ) + text = USAGE_TEXT ) logger.info( "Event is processed successfully. Event: TextMessageReceived (HELP command), " + "conversationId: ${wireMessage.conversationId}" ) + return } + + val repositories = GitHubPullRequestLinkParser.parse(wireMessage.text) + if (repositories.isEmpty()) { + return + } + + val message = registerRepositories( + repositories = repositories, + conversationId = wireMessage.conversationId + ) + sendMessage( + conversationId = wireMessage.conversationId, + text = message + ) } override suspend fun onAppAddedToConversation( @@ -52,16 +58,14 @@ class EventsHandler : WireEventsHandlerSuspending() { ) val message = buildString { appendLine(WELCOME_TEXT) - appendLine(formatSetupInstructions(conversationId = conversation.id)) + appendLine(USAGE_TEXT) appendLine() append("Use the `$HELP_COMMAND` command to see the usage again.") } - manager.sendMessage( - message = WireMessage.Text.create( - conversationId = conversation.id, - text = message - ) + sendMessage( + conversationId = conversation.id, + text = message ) logger.info( "Event is processed successfully. Event: AppAddedToConversation, " + @@ -69,45 +73,55 @@ class EventsHandler : WireEventsHandlerSuspending() { ) } - private fun formatSetupInstructions( - conversationId: QualifiedId, - secret: String? = null + private fun registerRepositories( + repositories: Set, + conversationId: QualifiedId ): String { - val generatedSecret = secret ?: run { - val generated = SessionIdentifierGenerator.generate() - storage.set(conversationId.toStorageKey(), generated) - generated + val results = repositories.map { repository -> + runCatching { + gitHubWebhookManager.ensureWebhookForConversation( + repository = repository, + conversationId = conversationId + ) + }.fold( + onSuccess = { + "Receiving pull request notifications for `${repository.fullName}`." + }, + onFailure = { exception -> + logger.warn( + "Failed to provision GitHub webhook for ${repository.fullName}", + exception + ) + "Could not set up pull request notifications for `${repository.fullName}`." + } + ) } - val url = String.format( - HOST_URL_PATTERN, - ENV_VAR_HOST, - conversationId.id, - conversationId.domain - ) + return results.joinToString(separator = "\n") + } - val setupInstructions = String.format( - "Here is how to set me up:\n\n" + - "1. Go to the repository that you would like to connect to\n" + - "2. Go to **Settings / Webhooks / Add webhook**\n" + - "3. Add **Payload URL**: %s\n" + - "4. Set **Content-Type**: application/json\n" + - "5. Set **Secret**: %s", - url, - generatedSecret + private fun sendMessage( + conversationId: QualifiedId, + text: String + ) { + manager.sendMessage( + message = WireMessage.Text.create( + conversationId = conversationId, + text = text + ) ) - - return setupInstructions } private companion object { - const val HOST_URL_PATTERN = "%s/%s/%s" - const val WELCOME_TEXT = "👋 Hi, I'm GitHub App. Thanks for adding me to the conversation.\n" + "You can use me to receive GitHub notifications in Wire.\n" + "I'm here to help make everyday work a little easier." + const val USAGE_TEXT = + "Post a GitHub pull request link in this conversation and I will set up " + + "pull request notifications for that repository." + const val HELP_COMMAND = "/github help" } } diff --git a/src/main/kotlin/com/wire/github/Routing.kt b/src/main/kotlin/com/wire/github/Routing.kt index 55fa189..cec529b 100644 --- a/src/main/kotlin/com/wire/github/Routing.kt +++ b/src/main/kotlin/com/wire/github/Routing.kt @@ -1,11 +1,12 @@ package com.wire.github +import com.wire.github.github.GitHubWebhookManager import com.wire.github.response.model.GitHubResponse +import com.wire.github.util.ENV_VAR_GITHUB_WEBHOOK_SECRET import com.wire.github.util.KtxSerializer import com.wire.github.util.SignatureValidator import com.wire.github.util.TemplateHandler import com.wire.sdk.WireAppSdk -import com.wire.sdk.model.QualifiedId import com.wire.sdk.model.WireMessage import io.ktor.http.HttpStatusCode import io.ktor.serialization.kotlinx.json.json @@ -15,16 +16,13 @@ import io.ktor.server.application.log import io.ktor.server.plugins.contentnegotiation.ContentNegotiation import io.ktor.server.request.receiveText import io.ktor.server.response.respond -import io.ktor.server.response.respondText import io.ktor.server.routing.application import io.ktor.server.routing.get import io.ktor.server.routing.post import io.ktor.server.routing.routing -import java.util.UUID import kotlinx.serialization.ExperimentalSerializationApi import org.koin.core.context.GlobalContext -@Suppress("LongMethod") @OptIn(ExperimentalSerializationApi::class) fun Application.configureRouting() { install(plugin = ContentNegotiation) { @@ -34,6 +32,7 @@ fun Application.configureRouting() { val wireAppSdk = GlobalContext.get().get() val signatureValidator = GlobalContext.get().get() val templateHandler = GlobalContext.get().get() + val gitHubWebhookManager = GlobalContext.get().get() routing { trace { @@ -44,47 +43,36 @@ fun Application.configureRouting() { call.response.status(value = HttpStatusCode.OK) } - post("/{$PARAM_CONVERSATION_ID}/{$PARAM_CONVERSATION_DOMAIN}") { + post("/${GitHubWebhookManager.GITHUB_WEBHOOK_PATH}") { // Headers val event = call.request.headers["X-GitHub-Event"] - val signature = call.request.headers["X-Hub-Signature"] + val signature = call.request.headers["X-Hub-Signature-256"] val delivery = call.request.headers["X-GitHub-Delivery"] requireNotNull(event) requireNotNull(signature) requireNotNull(delivery) - // Path Parameter - val conversationId = call.parameters[PARAM_CONVERSATION_ID] - ?: return@post call.respondText( - status = HttpStatusCode.BadRequest, - text = "Missing $PARAM_CONVERSATION_ID" - ) - - val conversationDomain = call.parameters[PARAM_CONVERSATION_DOMAIN] - ?: return@post call.respondText( - status = HttpStatusCode.BadRequest, - text = "Missing $PARAM_CONVERSATION_DOMAIN" - ) - // Payload val payload = call.receiveText() // Validation of received signature val isSignatureValid = signatureValidator.isValid( - conversationId = conversationId, - conversationDomain = conversationDomain, signature = signature, - payload = payload + payload = payload, + secret = requireNotNull(ENV_VAR_GITHUB_WEBHOOK_SECRET) { + "GHAPP_GITHUB_WEBHOOK_SECRET must be set to validate GitHub webhooks" + } ) if (!isSignatureValid) { return@post call.respond( status = HttpStatusCode.Forbidden, - message = "Invalid Signature for Conversation" + message = "Invalid GitHub webhook signature" ) } val response = KtxSerializer.json.decodeFromString(payload) + gitHubWebhookManager.markRepositoryActive(response.repository.fullName) // Handle event response and send message val messageTemplate = templateHandler.handleEvent( @@ -93,21 +81,19 @@ fun Application.configureRouting() { ) messageTemplate?.let { message -> - wireAppSdk.getApplicationManager().sendMessage( - message = WireMessage.Text.create( - conversationId = QualifiedId( - id = UUID.fromString(conversationId), - domain = conversationDomain - ), - text = message - ) - ) + gitHubWebhookManager + .conversationsForRepository(response.repository.fullName) + .forEach { conversationId -> + wireAppSdk.getApplicationManager().sendMessage( + message = WireMessage.Text.create( + conversationId = conversationId, + text = message + ) + ) + } } return@post call.response.status(HttpStatusCode.OK) } } } - -private const val PARAM_CONVERSATION_ID = "conversationId" -private const val PARAM_CONVERSATION_DOMAIN = "conversationDomain" diff --git a/src/main/kotlin/com/wire/github/config/Modules.kt b/src/main/kotlin/com/wire/github/config/Modules.kt index f41d6bc..ef5ed83 100644 --- a/src/main/kotlin/com/wire/github/config/Modules.kt +++ b/src/main/kotlin/com/wire/github/config/Modules.kt @@ -1,6 +1,8 @@ package com.wire.github.config import com.wire.github.EventsHandler +import com.wire.github.github.GitHubAppClient +import com.wire.github.github.GitHubWebhookManager import com.wire.github.util.ENV_VAR_API_HOST import com.wire.github.util.ENV_VAR_API_TOKEN import com.wire.github.util.ENV_VAR_APPLICATION_ID @@ -24,6 +26,8 @@ val projectModules = module { single(createdAtStart = true) { initWireAppSdk() } single { SignatureValidator() } single { TemplateHandler() } + single { GitHubAppClient() } + single { GitHubWebhookManager(gitHubAppClient = get()) } single { RedisClient.create("$ENV_VAR_REDIS_HOST:$ENV_VAR_REDIS_PORT") } single> { get().connect() } } diff --git a/src/main/kotlin/com/wire/github/github/GitHubAppClient.kt b/src/main/kotlin/com/wire/github/github/GitHubAppClient.kt new file mode 100644 index 0000000..fa0ef7c --- /dev/null +++ b/src/main/kotlin/com/wire/github/github/GitHubAppClient.kt @@ -0,0 +1,379 @@ +package com.wire.github.github + +import com.wire.github.util.ENV_VAR_GITHUB_CLIENT_ID +import com.wire.github.util.ENV_VAR_GITHUB_PRIVATE_KEY +import com.wire.github.util.KtxSerializer +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.security.KeyFactory +import java.security.Signature +import java.security.interfaces.RSAPrivateKey +import java.security.spec.PKCS8EncodedKeySpec +import java.time.Instant +import java.util.Base64 +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +@Suppress("TooManyFunctions") +class GitHubAppClient( + private val httpClient: HttpClient = HttpClient.newHttpClient() +) { + fun ensureRepositoryWebhook( + repository: GitHubRepository, + webhookUrl: String, + webhookSecret: String + ): Long { + val token = createInstallationAccessToken(repository) + val existingHook = findRepositoryWebhook( + repository = repository, + webhookUrl = webhookUrl, + token = token + ) + + if (existingHook != null) { + updateRepositoryWebhook( + repository = repository, + webhookId = existingHook, + token = token + ) + return existingHook + } + + return createRepositoryWebhook( + repository = repository, + webhookUrl = webhookUrl, + webhookSecret = webhookSecret, + token = token + ) + } + + fun deleteRepositoryWebhook( + repository: GitHubRepository, + webhookId: Long + ) { + val token = createInstallationAccessToken(repository) + val response = send( + request = requestBuilder( + "/repos/${repository.owner}/${repository.name}/hooks/$webhookId" + ).DELETE() + .withBearer(token) + .build() + ) + + require(response.statusCode() in SUCCESS_STATUS_CODES) { + "GitHub failed to delete webhook for ${repository.fullName}: " + + "${response.statusCode()} ${response.body()}" + } + } + + private fun findRepositoryWebhook( + repository: GitHubRepository, + webhookUrl: String, + token: String + ): Long? { + val response = send( + request = requestBuilder( + "/repos/${repository.owner}/${repository.name}/hooks?per_page=$MAX_HOOKS_PAGE_SIZE" + ).GET() + .withBearer(token) + .build() + ) + + require(response.statusCode() in SUCCESS_STATUS_CODES) { + "GitHub failed to list webhooks for ${repository.fullName}: " + + "${response.statusCode()} ${response.body()}" + } + + return KtxSerializer.json + .parseToJsonElement(response.body()) + .jsonArray + .firstNotNullOfOrNull { hook -> + hook.jsonObject + .takeIf { it.webhookUrl() == webhookUrl } + ?.get("id") + ?.jsonPrimitive + ?.longOrNull + } + } + + private fun createRepositoryWebhook( + repository: GitHubRepository, + webhookUrl: String, + webhookSecret: String, + token: String + ): Long { + val body = buildJsonObject { + put("name", WEBHOOK_NAME) + put("active", true) + put("events", repositoryEvents()) + putJsonObject("config") { + put("url", webhookUrl) + put("content_type", WEBHOOK_CONTENT_TYPE) + put("secret", webhookSecret) + put("insecure_ssl", WEBHOOK_VERIFY_SSL) + } + }.toString() + + val response = send( + request = requestBuilder("/repos/${repository.owner}/${repository.name}/hooks") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .withBearer(token) + .header("Content-Type", "application/json") + .build() + ) + + require(response.statusCode() in SUCCESS_STATUS_CODES) { + "GitHub failed to create webhook for ${repository.fullName}: " + + "${response.statusCode()} ${response.body()}" + } + + return KtxSerializer.json + .parseToJsonElement(response.body()) + .jsonObject["id"] + ?.jsonPrimitive + ?.long + ?: error("GitHub webhook creation response did not include an id") + } + + private fun updateRepositoryWebhook( + repository: GitHubRepository, + webhookId: Long, + token: String + ) { + val body = buildJsonObject { + put("active", true) + put("events", repositoryEvents()) + }.toString() + + val response = send( + request = requestBuilder( + "/repos/${repository.owner}/${repository.name}/hooks/$webhookId" + ).method("PATCH", HttpRequest.BodyPublishers.ofString(body)) + .withBearer(token) + .header("Content-Type", "application/json") + .build() + ) + + require(response.statusCode() in SUCCESS_STATUS_CODES) { + "GitHub failed to update webhook for ${repository.fullName}: " + + "${response.statusCode()} ${response.body()}" + } + } + + private fun createInstallationAccessToken(repository: GitHubRepository): String { + val installationId = getRepositoryInstallationId(repository) + val response = send( + request = requestBuilder("/app/installations/$installationId/access_tokens") + .POST(HttpRequest.BodyPublishers.noBody()) + .withBearer(generateJwt()) + .build() + ) + + require(response.statusCode() in SUCCESS_STATUS_CODES) { + "GitHub failed to create installation token for ${repository.fullName}: " + + "${response.statusCode()} ${response.body()}" + } + + return KtxSerializer.json + .parseToJsonElement(response.body()) + .jsonObject["token"] + ?.jsonPrimitive + ?.contentOrNull + ?: error("GitHub installation token response did not include a token") + } + + private fun getRepositoryInstallationId(repository: GitHubRepository): Long { + val response = send( + request = requestBuilder("/repos/${repository.owner}/${repository.name}/installation") + .GET() + .withBearer(generateJwt()) + .build() + ) + + require(response.statusCode() in SUCCESS_STATUS_CODES) { + "GitHub failed to resolve app installation for ${repository.fullName}: " + + "${response.statusCode()} ${response.body()}" + } + + return KtxSerializer.json + .parseToJsonElement(response.body()) + .jsonObject["id"] + ?.jsonPrimitive + ?.long + ?: error("GitHub repository installation response did not include an id") + } + + private fun generateJwt(): String { + val now = Instant.now().epochSecond + val header = base64Url("""{"alg":"RS256","typ":"JWT"}""".toByteArray()) + val expiresAt = now + JWT_EXPIRATION_SECONDS + val issuedAt = now - JWT_CLOCK_DRIFT_SECONDS + val payload = base64Url( + """{"iat":$issuedAt,"exp":$expiresAt,"iss":"${githubClientId()}"}""" + .toByteArray() + ) + val headerAndPayload = "$header.$payload" + val signature = Signature + .getInstance("SHA256withRSA") + .apply { initSign(githubPrivateKey()) } + .run { + update(headerAndPayload.toByteArray()) + base64Url(sign()) + } + + return "$headerAndPayload.$signature" + } + + private fun requestBuilder(path: String): HttpRequest.Builder = + HttpRequest + .newBuilder(URI.create("$GITHUB_API_URL$path")) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", GITHUB_API_VERSION) + .header("User-Agent", USER_AGENT) + + private fun HttpRequest.Builder.withBearer(token: String): HttpRequest.Builder = + header("Authorization", "Bearer $token") + + private fun send(request: HttpRequest): HttpResponse = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + + private fun githubClientId(): String = + requireNotNull(ENV_VAR_GITHUB_CLIENT_ID) { + "GHAPP_GITHUB_CLIENT_ID must be set to provision GitHub repository webhooks" + } + + private fun githubPrivateKey(): RSAPrivateKey = + requireNotNull(ENV_VAR_GITHUB_PRIVATE_KEY) { + "GHAPP_GITHUB_PRIVATE_KEY_FILE must be set to provision GitHub repository webhooks" + }.toRsaPrivateKey() + + private fun String.toRsaPrivateKey(): RSAPrivateKey { + val normalized = replace("\\n", "\n") + val keyBytes = normalized + .lineSequence() + .filterNot { it.startsWith("-----") } + .joinToString(separator = "") + .let { Base64.getDecoder().decode(it) } + .let { decodedKey -> + if (normalized.contains("BEGIN RSA PRIVATE KEY")) { + wrapPkcs1PrivateKey(decodedKey) + } else { + decodedKey + } + } + + return KeyFactory + .getInstance("RSA") + .generatePrivate(PKCS8EncodedKeySpec(keyBytes)) as RSAPrivateKey + } + + private fun wrapPkcs1PrivateKey(pkcs1: ByteArray): ByteArray = + derSequence( + derIntegerZero(), + derSequence( + derObjectIdentifier(RSA_ENCRYPTION_OBJECT_IDENTIFIER), + derNull() + ), + derOctetString(pkcs1) + ) + + private fun derSequence(vararg values: ByteArray): ByteArray = + derValue(tag = DER_SEQUENCE_TAG, value = values.flatMap { it.asIterable() }.toByteArray()) + + private fun derIntegerZero(): ByteArray = + derValue(tag = DER_INTEGER_TAG, value = byteArrayOf(0)) + + private fun derObjectIdentifier(value: ByteArray): ByteArray = + derValue(tag = DER_OBJECT_IDENTIFIER_TAG, value = value) + + private fun derNull(): ByteArray = derValue(tag = DER_NULL_TAG, value = byteArrayOf()) + + private fun derOctetString(value: ByteArray): ByteArray = + derValue(tag = DER_OCTET_STRING_TAG, value = value) + + private fun derValue( + tag: Byte, + value: ByteArray + ): ByteArray = byteArrayOf(tag) + derLength(value.size) + value + + private fun derLength(length: Int): ByteArray = + when { + length < DER_SHORT_FORM_LENGTH_LIMIT -> byteArrayOf(length.toByte()) + else -> { + val lengthBytes = generateSequence(length) { it shr BITS_PER_BYTE } + .takeWhile { it > 0 } + .map { it.toByte() } + .toList() + .asReversed() + .toByteArray() + byteArrayOf((DER_LONG_FORM_LENGTH_MASK or lengthBytes.size).toByte()) + lengthBytes + } + } + + private fun base64Url(bytes: ByteArray): String = + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + + private fun JsonObject.webhookUrl(): String? = + this["config"] + ?.jsonObject + ?.get("url") + ?.jsonPrimitive + ?.contentOrNull + + private fun repositoryEvents(): JsonArray = + buildJsonArray { + add(JsonPrimitive("check_suite")) + add(JsonPrimitive("pull_request")) + add(JsonPrimitive("pull_request_review")) + add(JsonPrimitive("pull_request_review_comment")) + add(JsonPrimitive("status")) + add(JsonPrimitive("workflow_run")) + } + + private companion object { + const val GITHUB_API_URL = "https://api.github.com" + const val GITHUB_API_VERSION = "2022-11-28" + const val USER_AGENT = "wire-github-app" + const val WEBHOOK_NAME = "web" + const val WEBHOOK_CONTENT_TYPE = "json" + const val WEBHOOK_VERIFY_SSL = "0" + const val MAX_HOOKS_PAGE_SIZE = 100 + const val JWT_CLOCK_DRIFT_SECONDS = 60L + const val JWT_EXPIRATION_SECONDS = 540L + const val DER_SHORT_FORM_LENGTH_LIMIT = 128 + const val BITS_PER_BYTE = 8 + const val DER_LONG_FORM_LENGTH_MASK = 0x80 + const val DER_SEQUENCE_TAG = 0x30.toByte() + const val DER_INTEGER_TAG = 0x02.toByte() + const val DER_OBJECT_IDENTIFIER_TAG = 0x06.toByte() + const val DER_NULL_TAG = 0x05.toByte() + const val DER_OCTET_STRING_TAG = 0x04.toByte() + + val SUCCESS_STATUS_CODES = 200..299 + + val RSA_ENCRYPTION_OBJECT_IDENTIFIER = byteArrayOf( + 0x2a, + 0x86.toByte(), + 0x48, + 0x86.toByte(), + 0xf7.toByte(), + 0x0d, + 0x01, + 0x01, + 0x01 + ) + } +} diff --git a/src/main/kotlin/com/wire/github/github/GitHubPullRequestLinkParser.kt b/src/main/kotlin/com/wire/github/github/GitHubPullRequestLinkParser.kt new file mode 100644 index 0000000..67a49a3 --- /dev/null +++ b/src/main/kotlin/com/wire/github/github/GitHubPullRequestLinkParser.kt @@ -0,0 +1,23 @@ +package com.wire.github.github + +object GitHubPullRequestLinkParser { + fun parse(text: String): Set = + pullRequestUrlRegex + .findAll(text) + .map { match -> + GitHubRepository( + owner = match.groups[OWNER_GROUP]?.value.orEmpty(), + name = match.groups[REPOSITORY_GROUP]?.value.orEmpty() + ) + }.toSet() + + private val pullRequestUrlRegex = + Regex( + """https://github\.com/""" + + """(?[A-Za-z0-9_.-]+)/""" + + """(?[A-Za-z0-9_.-]+)/pull/\d+\b""" + ) + + private const val OWNER_GROUP = "owner" + private const val REPOSITORY_GROUP = "repository" +} diff --git a/src/main/kotlin/com/wire/github/github/GitHubRepository.kt b/src/main/kotlin/com/wire/github/github/GitHubRepository.kt new file mode 100644 index 0000000..699cecc --- /dev/null +++ b/src/main/kotlin/com/wire/github/github/GitHubRepository.kt @@ -0,0 +1,19 @@ +package com.wire.github.github + +data class GitHubRepository( + val owner: String, + val name: String +) { + val fullName: String = "$owner/$name" + + companion object { + fun fromFullName(fullName: String): GitHubRepository? { + val parts = fullName.split("/") + return parts + .takeIf { it.size == REPOSITORY_FULL_NAME_PARTS } + ?.let { GitHubRepository(owner = it[0], name = it[1]) } + } + + private const val REPOSITORY_FULL_NAME_PARTS = 2 + } +} diff --git a/src/main/kotlin/com/wire/github/github/GitHubWebhookManager.kt b/src/main/kotlin/com/wire/github/github/GitHubWebhookManager.kt new file mode 100644 index 0000000..2679c37 --- /dev/null +++ b/src/main/kotlin/com/wire/github/github/GitHubWebhookManager.kt @@ -0,0 +1,140 @@ +package com.wire.github.github + +import com.wire.github.util.ENV_VAR_GITHUB_REPO_INACTIVITY_SECONDS +import com.wire.github.util.ENV_VAR_GITHUB_WEBHOOK_SECRET +import com.wire.github.util.ENV_VAR_HOST +import com.wire.github.util.toStorageKey +import com.wire.sdk.model.QualifiedId +import io.lettuce.core.api.StatefulRedisConnection +import java.time.Clock +import java.util.UUID +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import org.koin.core.context.GlobalContext +import org.slf4j.LoggerFactory + +@Suppress("TooManyFunctions") +class GitHubWebhookManager( + private val gitHubAppClient: GitHubAppClient = GitHubAppClient(), + private val clock: Clock = Clock.systemUTC() +) { + private val logger = LoggerFactory.getLogger(this::class.java) + private val redisConnection = GlobalContext.get().get>() + private val storage = redisConnection.sync() + private val cleanupExecutor = Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "github-webhook-cleanup").apply { isDaemon = true } + } + + init { + cleanupExecutor.scheduleWithFixedDelay( + ::cleanupInactiveRepositoriesSafely, + cleanupIntervalSeconds(), + cleanupIntervalSeconds(), + TimeUnit.SECONDS + ) + } + + fun ensureWebhookForConversation( + repository: GitHubRepository, + conversationId: QualifiedId + ): Long { + storage.sadd(KNOWN_REPOSITORIES_KEY, repository.fullName) + storage.sadd(conversationsKey(repository.fullName), conversationId.toStorageKey()) + markRepositoryActive(repository.fullName) + + return gitHubAppClient + .ensureRepositoryWebhook( + repository = repository, + webhookUrl = webhookUrl(), + webhookSecret = githubWebhookSecret() + ).also { webhookId -> + storage.set(webhookIdKey(repository.fullName), webhookId.toString()) + } + } + + fun conversationsForRepository(fullName: String): List = + storage + .smembers(conversationsKey(fullName)) + .mapNotNull { storageKey -> + val (id, domain) = storageKey.split("@").takeIf { it.size == STORAGE_KEY_PARTS } + ?: return@mapNotNull null + + runCatching { + QualifiedId( + id = UUID.fromString(id), + domain = domain + ) + }.getOrNull() + } + + fun markRepositoryActive(fullName: String) { + storage.set(lastActivityKey(fullName), clock.millis().toString()) + } + + fun cleanupInactiveRepositories() { + val cutoff = clock.millis() - ENV_VAR_GITHUB_REPO_INACTIVITY_SECONDS * MILLIS_PER_SECOND + storage.smembers(KNOWN_REPOSITORIES_KEY).forEach { fullName -> + val lastActivity = storage.get(lastActivityKey(fullName))?.toLongOrNull() ?: 0L + if (lastActivity <= cutoff) { + removeRepositoryWebhook(fullName) + } + } + } + + private fun removeRepositoryWebhook(fullName: String) { + val repository = GitHubRepository.fromFullName(fullName) + val webhookId = storage.get(webhookIdKey(fullName))?.toLongOrNull() + + if (repository != null && webhookId != null) { + gitHubAppClient.deleteRepositoryWebhook( + repository = repository, + webhookId = webhookId + ) + } + + storage.srem(KNOWN_REPOSITORIES_KEY, fullName) + storage.del(webhookIdKey(fullName)) + storage.del(conversationsKey(fullName)) + storage.del(lastActivityKey(fullName)) + logger.info("Removed inactive GitHub webhook subscription for repository: $fullName") + } + + private fun cleanupInactiveRepositoriesSafely() { + runCatching { cleanupInactiveRepositories() } + .onFailure { exception -> + logger.warn("GitHub webhook cleanup failed", exception) + } + } + + private fun webhookUrl(): String = "${ENV_VAR_HOST.trimEnd('/')}/$GITHUB_WEBHOOK_PATH" + + private fun githubWebhookSecret(): String = + requireNotNull(ENV_VAR_GITHUB_WEBHOOK_SECRET) { + "GHAPP_GITHUB_WEBHOOK_SECRET must be set to provision GitHub repository webhooks" + } + + private fun cleanupIntervalSeconds(): Long = + (ENV_VAR_GITHUB_REPO_INACTIVITY_SECONDS / CLEANUP_INTERVAL_DIVISOR) + .coerceIn(MIN_CLEANUP_INTERVAL_SECONDS, MAX_CLEANUP_INTERVAL_SECONDS) + + private fun webhookIdKey(fullName: String): String = + "$REPOSITORY_KEY_PREFIX:$fullName:webhook_id" + + private fun conversationsKey(fullName: String): String = + "$REPOSITORY_KEY_PREFIX:$fullName:conversations" + + private fun lastActivityKey(fullName: String): String = + "$REPOSITORY_KEY_PREFIX:$fullName:last_activity" + + companion object { + const val GITHUB_WEBHOOK_PATH = "github/webhook" + + private const val KNOWN_REPOSITORIES_KEY = "github:repositories" + private const val REPOSITORY_KEY_PREFIX = "github:repository" + private const val STORAGE_KEY_PARTS = 2 + private const val MILLIS_PER_SECOND = 1000L + private const val CLEANUP_INTERVAL_DIVISOR = 2 + private const val MIN_CLEANUP_INTERVAL_SECONDS = 60L + private const val MAX_CLEANUP_INTERVAL_SECONDS = 3600L + } +} diff --git a/src/main/kotlin/com/wire/github/response/model/CheckSuite.kt b/src/main/kotlin/com/wire/github/response/model/CheckSuite.kt new file mode 100644 index 0000000..7a4eea1 --- /dev/null +++ b/src/main/kotlin/com/wire/github/response/model/CheckSuite.kt @@ -0,0 +1,32 @@ +package com.wire.github.response.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CheckSuite( + @SerialName("html_url") + val htmlUrl: String? = null, + @SerialName("head_branch") + val headBranch: String? = null, + @SerialName("head_sha") + val headSha: String? = null, + val status: String? = null, + val conclusion: String? = null +) { + val successful: Boolean + get() = conclusion == CHECK_SUITE_CONCLUSION_SUCCESS + + val failed: Boolean + get() = conclusion in CHECK_SUITE_FAILURE_CONCLUSIONS +} + +private const val CHECK_SUITE_CONCLUSION_SUCCESS = "success" + +private val CHECK_SUITE_FAILURE_CONCLUSIONS = setOf( + "failure", + "error", + "timed_out", + "action_required", + "startup_failure" +) diff --git a/src/main/kotlin/com/wire/github/response/model/GitHubResponse.kt b/src/main/kotlin/com/wire/github/response/model/GitHubResponse.kt index 7be3b07..0c5140d 100644 --- a/src/main/kotlin/com/wire/github/response/model/GitHubResponse.kt +++ b/src/main/kotlin/com/wire/github/response/model/GitHubResponse.kt @@ -14,7 +14,30 @@ data class GitHubResponse( val sender: User, val compare: String? = null, val review: Review? = null, + @SerialName("check_suite") + val checkSuite: CheckSuite? = null, + @SerialName("workflow_run") + val workflowRun: WorkflowRun? = null, + val state: String? = null, + val context: String? = null, + val description: String? = null, + @SerialName("target_url") + val targetUrl: String? = null, + val sha: String? = null, val repository: Repository, val created: Boolean? = null, val deleted: Boolean? = null +) { + val statusSuccessful: Boolean + get() = state == GITHUB_STATUS_SUCCESS + + val statusFailed: Boolean + get() = state in GITHUB_STATUS_FAILURE_STATES +} + +private const val GITHUB_STATUS_SUCCESS = "success" + +private val GITHUB_STATUS_FAILURE_STATES = setOf( + "failure", + "error" ) diff --git a/src/main/kotlin/com/wire/github/response/model/PullRequest.kt b/src/main/kotlin/com/wire/github/response/model/PullRequest.kt index acb5278..036a4cf 100644 --- a/src/main/kotlin/com/wire/github/response/model/PullRequest.kt +++ b/src/main/kotlin/com/wire/github/response/model/PullRequest.kt @@ -8,8 +8,8 @@ data class PullRequest( @SerialName("html_url") val htmlUrl: String, val title: String, - val body: String, + val body: String? = null, val user: User, - val merged: Boolean, + val merged: Boolean? = false, val number: Int ) diff --git a/src/main/kotlin/com/wire/github/response/model/Review.kt b/src/main/kotlin/com/wire/github/response/model/Review.kt index b46bb5a..255434a 100644 --- a/src/main/kotlin/com/wire/github/response/model/Review.kt +++ b/src/main/kotlin/com/wire/github/response/model/Review.kt @@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable @Serializable data class Review( - val body: String, + val body: String? = null, val user: User, val state: String ) diff --git a/src/main/kotlin/com/wire/github/response/model/WorkflowRun.kt b/src/main/kotlin/com/wire/github/response/model/WorkflowRun.kt new file mode 100644 index 0000000..7e97796 --- /dev/null +++ b/src/main/kotlin/com/wire/github/response/model/WorkflowRun.kt @@ -0,0 +1,33 @@ +package com.wire.github.response.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class WorkflowRun( + val name: String? = null, + @SerialName("html_url") + val htmlUrl: String? = null, + @SerialName("head_branch") + val headBranch: String? = null, + @SerialName("head_sha") + val headSha: String? = null, + val status: String? = null, + val conclusion: String? = null +) { + val successful: Boolean + get() = conclusion == WORKFLOW_RUN_CONCLUSION_SUCCESS + + val failed: Boolean + get() = conclusion in WORKFLOW_RUN_FAILURE_CONCLUSIONS +} + +private const val WORKFLOW_RUN_CONCLUSION_SUCCESS = "success" + +private val WORKFLOW_RUN_FAILURE_CONCLUSIONS = setOf( + "failure", + "error", + "timed_out", + "action_required", + "startup_failure" +) diff --git a/src/main/kotlin/com/wire/github/util/EnvironmentVariables.kt b/src/main/kotlin/com/wire/github/util/EnvironmentVariables.kt index 3c527c6..fdef071 100644 --- a/src/main/kotlin/com/wire/github/util/EnvironmentVariables.kt +++ b/src/main/kotlin/com/wire/github/util/EnvironmentVariables.kt @@ -9,6 +9,8 @@ package com.wire.github.util import java.util.Base64 +import java.nio.file.Files +import java.nio.file.Path import java.util.UUID /** @@ -33,6 +35,45 @@ val ENV_VAR_HOST: String = System "http://0.0.0.0" ) +/** + * GitHub App client ID. Used as the JWT issuer when authenticating as the app. + */ +val ENV_VAR_GITHUB_CLIENT_ID: String? = System.getenv("GHAPP_GITHUB_CLIENT_ID") + +/** + * Path to the GitHub App private key in PEM format. + */ +val ENV_VAR_GITHUB_PRIVATE_KEY: String? + get() = System + .getenv("GHAPP_GITHUB_PRIVATE_KEY_FILE") + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { privateKeyFile -> + runCatching { Files.readString(Path.of(privateKeyFile)) } + .getOrElse { exception -> + throw IllegalStateException( + "GHAPP_GITHUB_PRIVATE_KEY_FILE points to an unreadable file: " + + privateKeyFile, + exception + ) + } + } + +/** + * Secret configured on repository webhooks created by this app. + */ +val ENV_VAR_GITHUB_WEBHOOK_SECRET: String? = System.getenv("GHAPP_GITHUB_WEBHOOK_SECRET") + +/** + * Number of seconds a repository may stay inactive before its webhook is removed. + */ +val ENV_VAR_GITHUB_REPO_INACTIVITY_SECONDS: Long = System + .getenv() + .getOrDefault( + "GHAPP_GITHUB_REPO_INACTIVITY_SECONDS", + "604800" + ).toLong() + /** * Redis Host URL * In case it needs a password, must be included in this same environment variable. diff --git a/src/main/kotlin/com/wire/github/util/SignatureValidator.kt b/src/main/kotlin/com/wire/github/util/SignatureValidator.kt index 792b73f..4c80e06 100644 --- a/src/main/kotlin/com/wire/github/util/SignatureValidator.kt +++ b/src/main/kotlin/com/wire/github/util/SignatureValidator.kt @@ -1,37 +1,22 @@ package com.wire.github.util -import io.lettuce.core.api.StatefulRedisConnection -import java.io.IOException import java.util.Locale import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec -import org.koin.core.context.GlobalContext class SignatureValidator { - private val redisConnection = GlobalContext.get().get>() - private val storage = redisConnection.sync() - - @Throws(Exception::class) fun isValid( - conversationId: String, - conversationDomain: String, signature: String, - payload: String + payload: String, + secret: String ): Boolean { - val storageKey = conversationId.toStorageKey(domain = conversationDomain) - val secret = storage.get(storageKey) - - if (secret == null) { - throw IOException("Missing secret for Conversation: $conversationId") - } - - val generatedHmacSha1: String = generateHmacSha1(payload, secret) - val challenge = String.format(Locale.getDefault(), "sha1=%s", generatedHmacSha1) + val generatedHmacSha256 = generateHmacSha256(payload, secret) + val challenge = String.format(Locale.getDefault(), "sha256=%s", generatedHmacSha256) return challenge == signature } - internal fun generateHmacSha1( + internal fun generateHmacSha256( data: String, secret: String ): String { @@ -45,6 +30,6 @@ class SignatureValidator { } private companion object { - const val ALGORITHM = "HmacSHA1" + const val ALGORITHM = "HmacSHA256" } } diff --git a/src/main/kotlin/com/wire/github/util/TemplateHandler.kt b/src/main/kotlin/com/wire/github/util/TemplateHandler.kt index 2b63bcd..dee78f9 100644 --- a/src/main/kotlin/com/wire/github/util/TemplateHandler.kt +++ b/src/main/kotlin/com/wire/github/util/TemplateHandler.kt @@ -16,11 +16,15 @@ class TemplateHandler { fun handleEvent( event: String, response: GitHubResponse - ): String? = - try { + ): String? { + val path = templatePath( + event = event, + response = response + ) ?: return null + + return try { val template = compileTemplate( - event = event, - action = response.action + path = path ) populateTemplate( @@ -31,28 +35,54 @@ class TemplateHandler { logger.error("MustacheNotFoundException: $exception") null } + } - private fun compileTemplate( + private fun templatePath( event: String, - action: String? - ): Mustache { - val path = action?.let { - String.format( - Locale.getDefault(), - "$TEMPLATE_DIRECTORY/%s/%s.%s.template", - LANGUAGE_ENGLISH, - event, - it - ) - } ?: String.format( + response: GitHubResponse + ): String? = + when (event) { + EVENT_CHECK_SUITE -> + response.checkSuite + ?.takeIf { it.successful || it.failed } + ?.let { eventTemplatePath(event) } + EVENT_STATUS -> + response + .takeIf { it.statusSuccessful || it.statusFailed } + ?.let { eventTemplatePath(event) } + EVENT_WORKFLOW_RUN -> + response.workflowRun + ?.takeIf { it.successful || it.failed } + ?.let { eventTemplatePath(event) } + else -> response.action?.let { + actionTemplatePath( + event = event, + action = it + ) + } ?: eventTemplatePath(event) + } + + private fun actionTemplatePath( + event: String, + action: String + ): String = + String.format( + Locale.getDefault(), + "$TEMPLATE_DIRECTORY/%s/%s.%s.template", + LANGUAGE_ENGLISH, + event, + action + ) + + private fun eventTemplatePath(event: String): String = + String.format( Locale.getDefault(), "$TEMPLATE_DIRECTORY/%s/%s.template", LANGUAGE_ENGLISH, event ) - return mustacheFactory.compile(path) - } + private fun compileTemplate(path: String): Mustache = mustacheFactory.compile(path) private fun populateTemplate( mustache: Mustache, @@ -66,5 +96,8 @@ class TemplateHandler { private companion object { const val LANGUAGE_ENGLISH = "en" const val TEMPLATE_DIRECTORY = "templates" + const val EVENT_CHECK_SUITE = "check_suite" + const val EVENT_STATUS = "status" + const val EVENT_WORKFLOW_RUN = "workflow_run" } } diff --git a/src/main/resources/templates/en/check_suite.template b/src/main/resources/templates/en/check_suite.template new file mode 100644 index 0000000..1884105 --- /dev/null +++ b/src/main/resources/templates/en/check_suite.template @@ -0,0 +1,12 @@ +{{#checkSuite.successful}} +✅ **CI Passed!** +{{/checkSuite.successful}} +{{#checkSuite.failed}} +❌ **CI Failed!** +{{/checkSuite.failed}} + +📦 **Repository:** {{repository.fullName}} +{{#checkSuite.headBranch}}🌿 **Branch:** {{checkSuite.headBranch}} +{{/checkSuite.headBranch}}{{#checkSuite.headSha}}🔖 **Commit:** `{{checkSuite.headSha}}` +{{/checkSuite.headSha}}{{#checkSuite.htmlUrl}}🔗 **Checks:** {{checkSuite.htmlUrl}} +{{/checkSuite.htmlUrl}} diff --git a/src/main/resources/templates/en/status.template b/src/main/resources/templates/en/status.template new file mode 100644 index 0000000..9835c7f --- /dev/null +++ b/src/main/resources/templates/en/status.template @@ -0,0 +1,12 @@ +{{#statusSuccessful}} +✅ **CI Passed!**{{#context}} {{context}}{{/context}} +{{/statusSuccessful}} +{{#statusFailed}} +❌ **CI Failed!**{{#context}} {{context}}{{/context}} +{{/statusFailed}} + +📦 **Repository:** {{repository.fullName}} +{{#description}}📝 **Status:** {{description}} +{{/description}}{{#sha}}🔖 **Commit:** `{{sha}}` +{{/sha}}{{#targetUrl}}🔗 **Details:** {{targetUrl}} +{{/targetUrl}} diff --git a/src/main/resources/templates/en/workflow_run.template b/src/main/resources/templates/en/workflow_run.template new file mode 100644 index 0000000..dd1d523 --- /dev/null +++ b/src/main/resources/templates/en/workflow_run.template @@ -0,0 +1,12 @@ +{{#workflowRun.successful}} +✅ **CI Passed!**{{#workflowRun.name}} {{workflowRun.name}}{{/workflowRun.name}} +{{/workflowRun.successful}} +{{#workflowRun.failed}} +❌ **CI Failed!**{{#workflowRun.name}} {{workflowRun.name}}{{/workflowRun.name}} +{{/workflowRun.failed}} + +📦 **Repository:** {{repository.fullName}} +{{#workflowRun.headBranch}}🌿 **Branch:** {{workflowRun.headBranch}} +{{/workflowRun.headBranch}}{{#workflowRun.headSha}}🔖 **Commit:** `{{workflowRun.headSha}}` +{{/workflowRun.headSha}}{{#workflowRun.htmlUrl}}🔗 **Run:** {{workflowRun.htmlUrl}} +{{/workflowRun.htmlUrl}} diff --git a/src/test/kotlin/com/wire/github/ApplicationTest.kt b/src/test/kotlin/com/wire/github/ApplicationTest.kt index bb06502..5a0720c 100644 --- a/src/test/kotlin/com/wire/github/ApplicationTest.kt +++ b/src/test/kotlin/com/wire/github/ApplicationTest.kt @@ -1,5 +1,6 @@ package com.wire.github +import com.wire.github.github.GitHubWebhookManager import com.wire.github.util.SignatureValidator import com.wire.github.util.TemplateHandler import com.wire.sdk.WireAppSdk @@ -7,22 +8,24 @@ import com.wire.sdk.model.QualifiedId import io.ktor.client.request.get import io.ktor.client.request.post import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.contentType import io.ktor.server.testing.testApplication -import kotlin.test.Test -import org.junit.jupiter.api.Assertions.assertEquals import io.ktor.client.request.header import io.lettuce.core.RedisClient import io.lettuce.core.api.StatefulRedisConnection import io.lettuce.core.api.sync.RedisCommands -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify import java.util.UUID import kotlin.test.AfterTest import kotlin.test.BeforeTest +import kotlin.test.Test +import io.mockk.every +import io.mockk.mockk +import io.mockk.justRun +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals import org.koin.core.context.loadKoinModules import org.koin.core.context.startKoin import org.koin.core.context.stopKoin @@ -35,6 +38,7 @@ class ApplicationTest { val mockRedisCommands = mockk>() val mockRedisConnection = mockk>() val mockWireAppSdk = mockk(relaxed = true) + val mockGitHubWebhookManager = mockk(relaxed = true) // Configure the Redis connection mock to return the sync commands every { mockRedisConnection.sync() } returns mockRedisCommands @@ -47,6 +51,7 @@ class ApplicationTest { single { mockRedisClient } single> { mockRedisConnection } single { mockWireAppSdk } + single { mockGitHubWebhookManager } } ) } @@ -65,24 +70,27 @@ class ApplicationTest { } val response = client.get("/health") - assertEquals(HttpStatusCode.OK, response.status) + assertEquals( + HttpStatusCode.OK, + response.status, + response.bodyAsText() + ) } @Test fun `given received event, when pull_request is created, then validations are passing`() { val signatureValidator = mockk() every { - signatureValidator.generateHmacSha1( + signatureValidator.generateHmacSha256( data = DUMMY_PAYLOAD, - secret = CONVERSATION_ID.id.toString() + secret = DUMMY_WEBHOOK_SECRET ) } returns DUMMY_SIGNATURE every { signatureValidator.isValid( - conversationId = CONVERSATION_ID.id.toString(), - conversationDomain = CONVERSATION_ID.domain, - signature = "sha1=$DUMMY_SIGNATURE", - payload = DUMMY_PAYLOAD + signature = "sha256=$DUMMY_SIGNATURE", + payload = DUMMY_PAYLOAD, + secret = DUMMY_WEBHOOK_SECRET ) } returns true @@ -101,11 +109,18 @@ class ApplicationTest { ) } returns DUMMY_TEMPLATE + val gitHubWebhookManager = mockk() + every { + gitHubWebhookManager.conversationsForRepository(REPOSITORY_FULL_NAME) + } returns listOf(CONVERSATION_ID) + justRun { gitHubWebhookManager.markRepositoryActive(REPOSITORY_FULL_NAME) } + loadKoinModules( module { single { signatureValidator } single { wireAppSdk } single { templateHandler } + single { gitHubWebhookManager } } ) @@ -115,19 +130,19 @@ class ApplicationTest { } val signature = String.format( - "sha1=%s", - signatureValidator.generateHmacSha1( + "sha256=%s", + signatureValidator.generateHmacSha256( data = DUMMY_PAYLOAD, - secret = CONVERSATION_ID.id.toString() + secret = DUMMY_WEBHOOK_SECRET ) ) // Test the real route with mocked service - val response = client.post("/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}") { + val response = client.post("/github/webhook") { contentType(ContentType.Application.Json) header("X-GitHub-Event", DUMMY_EVENT) - header("X-Hub-Signature", signature) + header("X-Hub-Signature-256", signature) header("X-GitHub-Delivery", "delivery") setBody(DUMMY_PAYLOAD) @@ -150,6 +165,8 @@ class ApplicationTest { const val DUMMY_EVENT = "pull_request" const val DUMMY_SIGNATURE = "dummySignature" const val DUMMY_TEMPLATE = "dummyTemplate" + const val DUMMY_WEBHOOK_SECRET = "dummyWebhookSecret" + const val REPOSITORY_FULL_NAME = "dummy_repository_full_name" val DUMMY_PAYLOAD = """ { "action": "created", @@ -158,7 +175,7 @@ class ApplicationTest { "login": "dummy_login" }, "repository": { - "full_name": "dummy_repository_full_name", + "full_name": "$REPOSITORY_FULL_NAME", "name": "repository_name" }, "deleted": false