diff --git a/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro b/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro index 5de2dac4bdb..174e6e38774 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro +++ b/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro @@ -24,6 +24,7 @@ -dontwarn com.google.errorprone.** -dontwarn com.sun.jna.** +-dontwarn com.sun.net.httpserver.** -dontwarn edu.umd.cs.findbugs.** -dontwarn java.lang.instrument.** -dontwarn java.lang.management.** diff --git a/sentry-test-support/build.gradle.kts b/sentry-test-support/build.gradle.kts index a0b508c9715..ca60a47c6bb 100644 --- a/sentry-test-support/build.gradle.kts +++ b/sentry-test-support/build.gradle.kts @@ -30,4 +30,6 @@ dependencies { implementation(kotlin(Config.kotlinStdLib)) implementation(libs.kotlin.test.junit) implementation(libs.mockito.kotlin) + + testImplementation(libs.google.truth) } diff --git a/sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt b/sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt new file mode 100644 index 00000000000..439b61f2c2a --- /dev/null +++ b/sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt @@ -0,0 +1,87 @@ +package io.sentry.test + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import io.sentry.JsonSerializer +import io.sentry.SentryEnvelope +import io.sentry.SentryOptions +import io.sentry.time.Deadline +import io.sentry.time.JavaMonotonicTicker +import java.io.Closeable +import java.net.InetAddress +import java.net.InetSocketAddress +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit +import java.util.zip.GZIPInputStream + +/** + * A stand-in for Sentry's ingestion endpoint that integration tests point the SDK at through [dsn]. + * + * Envelopes travel through the real HTTP transport, so serialization, compression and the DSN are + * covered too. It binds a free port so that test tasks running in parallel don't clash, and it + * works for an app in a child process as well as in the test's own JVM. + */ +class MockSentryServer : Closeable { + private val serializer = JsonSerializer(SentryOptions.empty()) + private val received = CopyOnWriteArrayList() + private val server = + HttpServer.create(InetSocketAddress(InetAddress.getByName(HOST), 0), 0).apply { + createContext("/api/") { exchange -> receive(exchange) } + start() + } + + val dsn: String = "http://key@$HOST:${server.address.port}/0" + + val envelopes: List + get() = received.toList() + + /** Returns the first received envelope matching [predicate], waiting up to [timeoutMillis]. */ + fun awaitEnvelope( + timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS, + predicate: (SentryEnvelope) -> Boolean, + ): SentryEnvelope { + val deadline = + Deadline.after(JavaMonotonicTicker.getInstance(), timeoutMillis, TimeUnit.MILLISECONDS) + while (true) { + received.firstOrNull(predicate)?.let { + return it + } + if (deadline.hasPassed()) { + throw AssertionError( + "No matching envelope within ${timeoutMillis}ms, received ${received.size} envelopes" + ) + } + Thread.sleep(POLL_INTERVAL_MILLIS) + } + } + + fun reset() { + received.clear() + } + + override fun close() { + server.stop(0) + } + + private fun receive(exchange: HttpExchange) { + try { + val body = + if (exchange.requestHeaders.getFirst("Content-Encoding") == "gzip") { + GZIPInputStream(exchange.requestBody) + } else { + exchange.requestBody + } + serializer.deserializeEnvelope(body)?.let { received.add(it) } + exchange.sendResponseHeaders(200, -1) + } finally { + exchange.close() + } + } + + private companion object { + // An IP rather than "localhost", which can resolve to IPv4 for the server and IPv6 for the SDK. + const val HOST = "127.0.0.1" + const val DEFAULT_TIMEOUT_MILLIS = 10_000L + const val POLL_INTERVAL_MILLIS = 50L + } +} diff --git a/sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt b/sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt new file mode 100644 index 00000000000..6a1e3f03225 --- /dev/null +++ b/sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt @@ -0,0 +1,34 @@ +package io.sentry.test + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.SentryItemType +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class MockSentryServerTest { + private val server = MockSentryServer() + + @AfterTest + fun teardown() { + Sentry.close() + server.close() + } + + @Test + fun `receives an event sent through the HTTP transport`() { + initForTest { it.dsn = server.dsn } + + val eventId = Sentry.captureMessage("hello") + Sentry.flush(5000) + + val envelope = server.awaitEnvelope { it.header.eventId == eventId } + assertThat(envelope.items.single().header.type).isEqualTo(SentryItemType.Event) + } + + @Test + fun `awaitEnvelope fails once the timeout passes without a match`() { + assertFailsWith { server.awaitEnvelope(timeoutMillis = 100) { true } } + } +}