Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,11 @@ android {
dimension = "buildType"
applicationIdSuffix = ".instrumentation"
resValue("string", "app_name", "Puber(Instrumentation)")
buildConfigField(
"int",
"BASELINE_MOCK_PORT",
providers.gradleProperty("puber.baselineMockPort").get(),
)
}
}

Expand Down Expand Up @@ -297,11 +302,13 @@ dependencies {
testImplementation(libs.coroutines.test)
testImplementation(libs.mockk)
testImplementation(libs.ktor.client.mock)
testImplementation(libs.mockwebserver3)

detektPlugins(libs.detekt.compose.rules)

androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
androidTestImplementation(libs.mockwebserver3)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package com.kino.puber.profile

import android.app.Activity
import android.content.Context
import android.os.ParcelFileDescriptor
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.kino.puber.data.api.config.ApiEndpointMode
import com.kino.puber.data.api.config.KinoPubConfig
import com.kino.puber.data.repository.ICryptoPreferenceRepository
import com.kino.puber.domain.interactor.update.IAppUpdateInteractor
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.request.get
import java.net.HttpURLConnection
import java.net.ProxySelector
import java.net.URI
import java.net.URL
import kotlinx.coroutines.runBlocking
import mockwebserver3.MockResponse
import mockwebserver3.MockWebServer
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koin.core.context.GlobalContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class BaselineNetworkIsolationTest {

private val context: Context
get() = InstrumentationRegistry.getInstrumentation().targetContext

@Test
fun loopbackIsAllowedAndExternalOriginsAreRejectedAndJournaled() {
BaselineInstrumentationEnvironment.configure(context)
assertEquals(
Activity.RESULT_OK,
callControlReceiver(BaselineProfileControlReceiver.ACTION_CLEAR).code,
)

MockWebServer().use { server ->
server.start(com.kino.puber.BuildConfig.BASELINE_MOCK_PORT)
server.enqueue(
MockResponse.Builder()
.code(200)
.body("ready")
.build(),
)

val request = Request.Builder()
.url("${BaselineInstrumentationEnvironment.mockOrigin()}/ready")
.build()
OkHttpClient().newCall(request).execute().use { response ->
assertEquals(200, response.code)
assertEquals("ready", response.body.string())
}
}

assertFailsWithoutEgress {
OkHttpClient().newCall(
Request.Builder()
.url("https://example.com/private?token=must-not-be-recorded")
.build(),
).execute().use { }
}
// OkHttp supplies an origin-only route URI to ProxySelector. Exercise
// the selector's richer URI contract directly, then prove the client
// request is still rejected by that same selector.
observeProxyUri(
"https://journal-user:journal-password@example.com/private" +
"?token=must-not-be-recorded",
)

val verification = callControlReceiver(BaselineProfileControlReceiver.ACTION_VERIFY)
assertEquals(BaselineProfileControlReceiver.RESULT_VIOLATIONS, verification.code)
assertTrue(verification.data.contains("https://example.com:443/private"))
assertFalse(
verification.data.contains("journal-user") ||
verification.data.contains("journal-password") ||
verification.data.contains("token") ||
verification.data.contains("?"),
)

assertEquals(
Activity.RESULT_OK,
callControlReceiver(BaselineProfileControlReceiver.ACTION_CLEAR).code,
)
val clearedVerification = callControlReceiver(BaselineProfileControlReceiver.ACTION_VERIFY)
assertEquals(Activity.RESULT_OK, clearedVerification.code)
assertTrue(clearedVerification.data.isEmpty())
}

@Test
fun ktorAndHttpUrlConnectionAreRejectedByTheSameProcessBlocker() {
BaselineInstrumentationEnvironment.configure(context)
assertEquals(
Activity.RESULT_OK,
callControlReceiver(BaselineProfileControlReceiver.ACTION_CLEAR).code,
)

assertFailsWithoutEgress {
runBlocking {
HttpClient(OkHttp).use { client ->
client.get("https://example.org/ktor?secret=hidden")
}
}
}

assertFailsWithoutEgress {
val connection = URL("http://example.net/legacy?secret=hidden")
.openConnection() as HttpURLConnection
connection.connectTimeout = CONNECT_TIMEOUT_MS
connection.readTimeout = CONNECT_TIMEOUT_MS
connection.connect()
}
observeProxyUri("https://example.org/ktor?secret=hidden")
observeProxyUri("http://example.net/legacy?secret=hidden")

val verification = callControlReceiver(BaselineProfileControlReceiver.ACTION_VERIFY)
assertEquals(BaselineProfileControlReceiver.RESULT_VIOLATIONS, verification.code)
assertTrue(verification.data.contains("https://example.org:443/ktor"))
assertTrue(verification.data.contains("http://example.net:80/legacy"))
assertFalse(verification.data.contains("secret") || verification.data.contains("?"))
}

@Test
fun instrumentationCompositionUsesSyntheticAuthAndDisablesAutomaticUpdates() = runBlocking {
BaselineInstrumentationEnvironment.configure(context)
assertEquals(
Activity.RESULT_OK,
callControlReceiver(BaselineProfileControlReceiver.ACTION_CLEAR).code,
)

val koin = GlobalContext.get()
val auth = koin.get<ICryptoPreferenceRepository>()
val updates = koin.get<IAppUpdateInteractor>()

assertEquals(ApiEndpointMode.PINNED, KinoPubConfig.CURRENT_ENDPOINT_MODE)
assertEquals(
BaselineInstrumentationEnvironment.mockOrigin(),
KinoPubConfig.MAIN_API_BASE_URL.removeSuffix("/v1/"),
)
assertEquals("baseline-access-token", auth.getAccessToken())
assertEquals("baseline-refresh-token", auth.getRefreshToken())
assertFalse(updates.isAutoCheckEnabled())
assertEquals(null, updates.checkForUpdate("1.0.0").getOrThrow())
assertEquals(
Activity.RESULT_OK,
callControlReceiver(BaselineProfileControlReceiver.ACTION_VERIFY).code,
)
}

private fun callControlReceiver(action: String): ReceiverResult {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val component = "${context.packageName}/${BaselineProfileControlReceiver::class.java.name}"
val output = ParcelFileDescriptor.AutoCloseInputStream(
instrumentation.uiAutomation.executeShellCommand(
"am broadcast --receiver-foreground -n $component -a $action",
),
).bufferedReader().use { it.readText() }
val result = BROADCAST_RESULT.find(output)
?: throw AssertionError("Missing broadcast result: $output")
return ReceiverResult(
code = result.groupValues[1].toInt(),
data = result.groupValues[2],
)
}

private fun assertFailsWithoutEgress(block: () -> Unit) {
try {
block()
throw AssertionError("External request unexpectedly succeeded")
} catch (error: java.io.IOException) {
// The deny proxy is a local sink; no external socket is allowed.
} catch (error: AssertionError) {
throw error
} catch (error: Exception) {
assertTrue(error.message.orEmpty().isNotBlank())
}
}

private fun observeProxyUri(rawUri: String) {
val proxies = ProxySelector.getDefault().select(URI(rawUri))
assertTrue(proxies.none { it == java.net.Proxy.NO_PROXY })
}

private companion object {
private const val CONNECT_TIMEOUT_MS = 1_000
private val BROADCAST_RESULT = Regex(
"""Broadcast completed: result=(-?\d+)(?:, data="([^"]*)")?""",
)
}

private data class ReceiverResult(
val code: Int,
val data: String,
)
}
16 changes: 16 additions & 0 deletions app/src/instrumentation/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<application
android:name=".profile.BaselineProfileApp"
tools:replace="android:name">
<receiver
android:name=".profile.BaselineProfileControlReceiver"
android:exported="true"
android:permission="android.permission.DUMP"
android:process=":baseline_profile_control" />
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.kino.puber.profile

import android.content.Context
import com.kino.puber.BuildConfig
import com.kino.puber.data.api.config.ApiEndpointPreset
import com.kino.puber.data.api.config.KinoPubConfig

internal object BaselineInstrumentationEnvironment {

const val MOCK_HOST = "127.0.0.1"
const val MOCK_SCHEME = "http"

fun configure(context: Context) {
KinoPubConfig.setPinnedEndpoint(
ApiEndpointPreset(
domain = "$MOCK_HOST:${BuildConfig.BASELINE_MOCK_PORT}",
apiHost = MOCK_HOST,
mainBaseUrl = "$MOCK_SCHEME://$MOCK_HOST:${BuildConfig.BASELINE_MOCK_PORT}/v1/",
oauthBaseUrl = "$MOCK_SCHEME://$MOCK_HOST:${BuildConfig.BASELINE_MOCK_PORT}/oauth2/",
extraBaseUrl = "$MOCK_SCHEME://$MOCK_HOST:${BuildConfig.BASELINE_MOCK_PORT}/",
)
)
BaselineNetworkBlocker.install(context, mockOrigin())
}

fun mockOrigin(): String =
"$MOCK_SCHEME://$MOCK_HOST:${BuildConfig.BASELINE_MOCK_PORT}"

fun clearNetworkCaches(context: Context) {
listOfNotNull(
context.cacheDir.resolve("okhttpcache"),
context.cacheDir.resolve("image_cache"),
context.externalCacheDir?.resolve("media_cache"),
).forEach { cacheDirectory ->
check(!cacheDirectory.exists() || cacheDirectory.deleteRecursively()) {
"Failed to clear instrumentation cache: $cacheDirectory"
}
}
}
}
Loading
Loading