diff --git a/tests/cts/net/Android.bp b/tests/cts/net/Android.bp index fdc54bdded..4c00428f71 100644 --- a/tests/cts/net/Android.bp +++ b/tests/cts/net/Android.bp @@ -47,7 +47,6 @@ java_defaults { "ctstestserver", "junit", "junit-params", - "libnanohttpd", "mockwebserver", "net-utils-framework-common", "truth-prebuilt", diff --git a/tests/cts/net/src/android/net/cts/CaptivePortalTest.kt b/tests/cts/net/src/android/net/cts/CaptivePortalTest.kt index 4a7d38a172..12a966fd31 100644 --- a/tests/cts/net/src/android/net/cts/CaptivePortalTest.kt +++ b/tests/cts/net/src/android/net/cts/CaptivePortalTest.kt @@ -19,7 +19,6 @@ package android.net.cts import android.Manifest.permission.CONNECTIVITY_INTERNAL import android.Manifest.permission.NETWORK_SETTINGS import android.Manifest.permission.READ_DEVICE_CONFIG -import android.Manifest.permission.WRITE_DEVICE_CONFIG import android.content.pm.PackageManager.FEATURE_TELEPHONY import android.content.pm.PackageManager.FEATURE_WIFI import android.net.ConnectivityManager @@ -30,20 +29,25 @@ import android.net.NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL import android.net.NetworkCapabilities.TRANSPORT_WIFI import android.net.NetworkRequest import android.net.Uri +import android.net.cts.NetworkValidationTestUtil.clearValidationTestUrlsDeviceConfig +import android.net.cts.NetworkValidationTestUtil.runAsShell +import android.net.cts.NetworkValidationTestUtil.setHttpUrlDeviceConfig +import android.net.cts.NetworkValidationTestUtil.setHttpsUrlDeviceConfig +import android.net.cts.NetworkValidationTestUtil.setUrlExpirationDeviceConfig +import com.android.testutils.TestHttpServer.Request import android.net.cts.util.CtsNetUtils +import android.net.util.NetworkStackUtils.TEST_CAPTIVE_PORTAL_HTTPS_URL +import android.net.util.NetworkStackUtils.TEST_CAPTIVE_PORTAL_HTTP_URL import android.net.wifi.WifiManager import android.os.Build -import android.os.ConditionVariable import android.platform.test.annotations.AppModeFull import android.provider.DeviceConfig import android.provider.DeviceConfig.NAMESPACE_CONNECTIVITY import android.text.TextUtils import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import androidx.test.runner.AndroidJUnit4 -import com.android.compatibility.common.util.SystemUtil +import com.android.testutils.TestHttpServer import com.android.testutils.isDevSdkInRange -import fi.iki.elonen.NanoHTTPD -import fi.iki.elonen.NanoHTTPD.Response.IStatus import fi.iki.elonen.NanoHTTPD.Response.Status import junit.framework.AssertionFailedError import org.junit.After @@ -55,15 +59,12 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException import kotlin.test.Test import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull import kotlin.test.assertTrue -private const val TEST_CAPTIVE_PORTAL_HTTPS_URL_SETTING = "test_captive_portal_https_url" -private const val TEST_CAPTIVE_PORTAL_HTTP_URL_SETTING = "test_captive_portal_http_url" -private const val TEST_URL_EXPIRATION_TIME = "test_url_expiration_time" - -private const val TEST_HTTPS_URL_PATH = "https_path" -private const val TEST_HTTP_URL_PATH = "http_path" -private const val TEST_PORTAL_URL_PATH = "portal_path" +private const val TEST_HTTPS_URL_PATH = "/https_path" +private const val TEST_HTTP_URL_PATH = "/http_path" +private const val TEST_PORTAL_URL_PATH = "/portal_path" private const val LOCALHOST_HOSTNAME = "localhost" @@ -88,24 +89,24 @@ class CaptivePortalTest { private val pm by lazy { context.packageManager } private val utils by lazy { CtsNetUtils(context) } - private val server = HttpServer() + private val server = TestHttpServer("localhost") @Before fun setUp() { - doAsShell(READ_DEVICE_CONFIG) { + runAsShell(READ_DEVICE_CONFIG) { // Verify that the test URLs are not normally set on the device, but do not fail if the // test URLs are set to what this test uses (URLs on localhost), in case the test was // interrupted manually and rerun. - assertEmptyOrLocalhostUrl(TEST_CAPTIVE_PORTAL_HTTPS_URL_SETTING) - assertEmptyOrLocalhostUrl(TEST_CAPTIVE_PORTAL_HTTP_URL_SETTING) + assertEmptyOrLocalhostUrl(TEST_CAPTIVE_PORTAL_HTTPS_URL) + assertEmptyOrLocalhostUrl(TEST_CAPTIVE_PORTAL_HTTP_URL) } - clearTestUrls() + clearValidationTestUrlsDeviceConfig() server.start() } @After fun tearDown() { - clearTestUrls() + clearValidationTestUrlsDeviceConfig() if (pm.hasSystemFeature(FEATURE_WIFI)) { reconnectWifi() } @@ -118,12 +119,6 @@ class CaptivePortalTest { "$urlKey must not be set in production scenarios (current value: $url)") } - private fun clearTestUrls() { - setHttpsUrl(null) - setHttpUrl(null) - setUrlExpiration(null) - } - @Test fun testCaptivePortalIsNotDefaultNetwork() { assumeTrue(pm.hasSystemFeature(FEATURE_TELEPHONY)) @@ -132,19 +127,15 @@ class CaptivePortalTest { utils.connectToCell() // Have network validation use a local server that serves a HTTPS error / HTTP redirect - server.addResponse(TEST_PORTAL_URL_PATH, Status.OK, + server.addResponse(Request(TEST_PORTAL_URL_PATH), Status.OK, content = "Test captive portal content") - server.addResponse(TEST_HTTPS_URL_PATH, Status.INTERNAL_ERROR) - server.addResponse(TEST_HTTP_URL_PATH, Status.REDIRECT, - locationHeader = server.makeUrl(TEST_PORTAL_URL_PATH)) - setHttpsUrl(server.makeUrl(TEST_HTTPS_URL_PATH)) - setHttpUrl(server.makeUrl(TEST_HTTP_URL_PATH)) + server.addResponse(Request(TEST_HTTPS_URL_PATH), Status.INTERNAL_ERROR) + server.addResponse(Request(TEST_HTTP_URL_PATH), Status.REDIRECT, + locationHeader = makeUrl(TEST_PORTAL_URL_PATH)) + setHttpsUrlDeviceConfig(makeUrl(TEST_HTTPS_URL_PATH)) + setHttpUrlDeviceConfig(makeUrl(TEST_HTTP_URL_PATH)) // URL expiration needs to be in the next 10 minutes - setUrlExpiration(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(9)) - - // Expect the portal content to be fetched at some point after detecting the portal. - // Some implementations may fetch the URL before startCaptivePortalApp is called. - val portalContentRequestCv = server.addExpectRequestCv(TEST_PORTAL_URL_PATH) + setUrlExpirationDeviceConfig(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(9)) // Wait for a captive portal to be detected on the network val wifiNetworkFuture = CompletableFuture() @@ -173,9 +164,14 @@ class CaptivePortalTest { val startPortalAppPermission = if (isDevSdkInRange(0, Build.VERSION_CODES.Q)) CONNECTIVITY_INTERNAL else NETWORK_SETTINGS - doAsShell(startPortalAppPermission) { cm.startCaptivePortalApp(network) } - assertTrue(portalContentRequestCv.block(TEST_TIMEOUT_MS), "The captive portal login " + - "page was still not fetched ${TEST_TIMEOUT_MS}ms after startCaptivePortalApp.") + runAsShell(startPortalAppPermission) { cm.startCaptivePortalApp(network) } + + // Expect the portal content to be fetched at some point after detecting the portal. + // Some implementations may fetch the URL before startCaptivePortalApp is called. + assertNotNull(server.requestsRecord.poll(TEST_TIMEOUT_MS, pos = 0) { + it.path == TEST_PORTAL_URL_PATH + }, "The captive portal login page was still not fetched ${TEST_TIMEOUT_MS}ms " + + "after startCaptivePortalApp.") assertNotEquals(network, cm.activeNetwork, wifiDefaultMessage) } finally { @@ -186,73 +182,13 @@ class CaptivePortalTest { } } - private fun setHttpsUrl(url: String?) = setConfig(TEST_CAPTIVE_PORTAL_HTTPS_URL_SETTING, url) - private fun setHttpUrl(url: String?) = setConfig(TEST_CAPTIVE_PORTAL_HTTP_URL_SETTING, url) - private fun setUrlExpiration(timestamp: Long?) = setConfig(TEST_URL_EXPIRATION_TIME, - timestamp?.toString()) - - private fun setConfig(configKey: String, value: String?) { - doAsShell(WRITE_DEVICE_CONFIG) { - DeviceConfig.setProperty( - NAMESPACE_CONNECTIVITY, configKey, value, false /* makeDefault */) - } - } - - private fun doAsShell(vararg permissions: String, action: () -> Unit) { - // Wrap the below call to allow for more kotlin-like syntax - SystemUtil.runWithShellPermissionIdentity(action, permissions) - } + /** + * Create a URL string that, when fetched, will hit the test server with the given URL [path]. + */ + private fun makeUrl(path: String) = "http://localhost:${server.listeningPort}" + path private fun reconnectWifi() { utils.ensureWifiDisconnected(null /* wifiNetworkToCheck */) utils.ensureWifiConnected() } - - /** - * A minimal HTTP server running on localhost (loopback), on a random available port. - */ - private class HttpServer : NanoHTTPD("localhost", 0 /* auto-select the port */) { - // Map of URL path -> HTTP response code - private val responses = HashMap() - - // Map of path -> CV to open as soon as a request to the path is received - private val waitForRequestCv = HashMap() - - /** - * Create a URL string that, when fetched, will hit this server with the given URL [path]. - */ - fun makeUrl(path: String): String { - return Uri.Builder() - .scheme("http") - .encodedAuthority("localhost:$listeningPort") - .query(path) - .build() - .toString() - } - - fun addResponse( - path: String, - statusCode: IStatus, - locationHeader: String? = null, - content: String = "" - ) { - val response = newFixedLengthResponse(statusCode, "text/plain", content) - locationHeader?.let { response.addHeader("Location", it) } - responses[path] = response - } - - /** - * Create a [ConditionVariable] that will open when a request to [path] is received. - */ - fun addExpectRequestCv(path: String): ConditionVariable { - return ConditionVariable().apply { waitForRequestCv[path] = this } - } - - override fun serve(session: IHTTPSession): Response { - waitForRequestCv[session.queryParameterString]?.open() - return responses[session.queryParameterString] - // Default response is a 404 - ?: super.serve(session) - } - } } \ No newline at end of file diff --git a/tests/cts/net/src/android/net/cts/CaptivePortalApiTest.kt b/tests/cts/net/src/android/net/cts/NetworkValidationTest.kt similarity index 84% rename from tests/cts/net/src/android/net/cts/CaptivePortalApiTest.kt rename to tests/cts/net/src/android/net/cts/NetworkValidationTest.kt index ef2b0cee2f..52c383d906 100644 --- a/tests/cts/net/src/android/net/cts/CaptivePortalApiTest.kt +++ b/tests/cts/net/src/android/net/cts/NetworkValidationTest.kt @@ -29,6 +29,7 @@ import android.net.NetworkRequest import android.net.TestNetworkInterface import android.net.TestNetworkManager import android.net.Uri +import android.net.cts.NetworkValidationTestUtil.runAsShell import android.net.dhcp.DhcpDiscoverPacket import android.net.dhcp.DhcpPacket import android.net.dhcp.DhcpPacket.DHCP_MESSAGE_TYPE @@ -40,18 +41,18 @@ import android.os.HandlerThread import android.platform.test.annotations.AppModeFull import androidx.test.platform.app.InstrumentationRegistry import androidx.test.runner.AndroidJUnit4 -import com.android.compatibility.common.util.SystemUtil.runWithShellPermissionIdentity -import com.android.compatibility.common.util.ThrowingRunnable import com.android.net.module.util.Inet4AddressUtils.getBroadcastAddress import com.android.net.module.util.Inet4AddressUtils.getPrefixMaskAsInet4Address import com.android.server.util.NetworkStackConstants.IPV4_ADDR_ANY +import com.android.testutils.ArpResponder import com.android.testutils.DevSdkIgnoreRule import com.android.testutils.DhcpClientPacketFilter import com.android.testutils.DhcpOptionFilter import com.android.testutils.RecorderCallback.CallbackEntry import com.android.testutils.TapPacketReader +import com.android.testutils.TestHttpServer import com.android.testutils.TestableNetworkCallback -import fi.iki.elonen.NanoHTTPD +import fi.iki.elonen.NanoHTTPD.Response.Status import org.junit.After import org.junit.Assume.assumeFalse import org.junit.Before @@ -59,8 +60,6 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import java.net.Inet4Address -import java.util.concurrent.ArrayBlockingQueue -import java.util.concurrent.TimeUnit import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -79,7 +78,7 @@ private const val TEST_MTU = 1500.toShort() @AppModeFull(reason = "Instant apps cannot create test networks") @RunWith(AndroidJUnit4::class) -class CaptivePortalApiTest { +class NetworkValidationTest { @JvmField @Rule val ignoreRule = DevSdkIgnoreRule(ignoreClassUpTo = Build.VERSION_CODES.Q) @@ -89,10 +88,10 @@ class CaptivePortalApiTest { private val eth by lazy { context.assertHasService(EthernetManager::class.java) } private val cm by lazy { context.assertHasService(ConnectivityManager::class.java) } - private val handlerThread = HandlerThread(CaptivePortalApiTest::class.java.simpleName) + private val handlerThread = HandlerThread(NetworkValidationTest::class.java.simpleName) private val serverIpAddr = InetAddresses.parseNumericAddress("192.0.2.222") as Inet4Address private val clientIpAddr = InetAddresses.parseNumericAddress("192.0.2.111") as Inet4Address - private val httpServer = HttpServer() + private val httpServer = TestHttpServer() private val ethRequest = NetworkRequest.Builder() // ETHERNET|TEST transport networks do not have NET_CAPABILITY_TRUSTED .removeCapability(NET_CAPABILITY_TRUSTED) @@ -151,7 +150,15 @@ class CaptivePortalApiTest { } @Test - fun testApiCallbacks() { + fun testCapportApiCallbacks() { + httpServer.addResponse(capportUrl, Status.OK, content = """ + |{ + | "captive": true, + | "user-portal-url": "$TEST_LOGIN_URL", + | "venue-info-url": "$TEST_VENUE_INFO_URL" + |} + """.trimMargin()) + // Handle the DHCP handshake that includes the capport API URL val discover = reader.assertDhcpPacketReceived( DhcpDiscoverPacket::class.java, TEST_TIMEOUT_MS, DHCP_MESSAGE_TYPE_DISCOVER) @@ -163,11 +170,9 @@ class CaptivePortalApiTest { assertEquals(clientIpAddr, request.mRequestedIp) reader.sendResponse(makeAckPacket(request.clientMac, request.transactionId)) - // Expect a request to the capport API - val capportReq = httpServer.recordedRequests.poll(TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS) - assertNotNull(capportReq, "The device did not fetch captive portal API data within timeout") - assertEquals(capportUrl.path, capportReq.uri) - assertEquals(capportUrl.query, capportReq.queryParameterString) + // The first request received by the server should be for the portal API + assertTrue(httpServer.requestsRecord.poll(TEST_TIMEOUT_MS, 0)?.matches(capportUrl) ?: false, + "The device did not fetch captive portal API data within timeout") // Expect network callbacks with capport info val testCb = TestableNetworkCallback(TEST_TIMEOUT_MS) @@ -216,30 +221,6 @@ class CaptivePortalApiTest { listOf(serverIpAddr) /* gateways */, listOf(serverIpAddr) /* dnsServers */, serverIpAddr, TEST_DOMAIN_NAME, null /* hostname */, true /* metered */, TEST_MTU, false /* rapidCommit */, capportUrl.toString()) - - private fun parseDhcpPacket(bytes: ByteArray) = DhcpPacket.decodeFullPacket( - bytes, MAX_PACKET_LENGTH, DhcpPacket.ENCAP_L2) -} - -/** - * A minimal HTTP server running on localhost (loopback), on a random available port. - * - * The server records each request in [recordedRequests] and will not serve any further request - * until the last one is removed from the queue for verification. - */ -private class HttpServer : NanoHTTPD("localhost", 0 /* auto-select the port */) { - val recordedRequests = ArrayBlockingQueue(1 /* capacity */) - - override fun serve(session: IHTTPSession): Response { - recordedRequests.offer(session) - return newFixedLengthResponse(""" - |{ - | "captive": true, - | "user-portal-url": "$TEST_LOGIN_URL", - | "venue-info-url": "$TEST_VENUE_INFO_URL" - |} - """.trimMargin()) - } } private fun TapPacketReader.assertDhcpPacketReceived( @@ -259,12 +240,3 @@ private fun TapPacketReader.assertDhcpPacketReceived( private fun Context.assertHasService(manager: Class): T { return getSystemService(manager) ?: fail("Service $manager not found") } - -/** - * Wrapper around runWithShellPermissionIdentity with kotlin-like syntax. - */ -private fun runAsShell(vararg permissions: String, task: () -> T): T { - var ret: T? = null - runWithShellPermissionIdentity(ThrowingRunnable { ret = task() }, *permissions) - return ret ?: fail("ThrowingRunnable was not run") -} diff --git a/tests/cts/net/src/android/net/cts/NetworkValidationTestUtil.kt b/tests/cts/net/src/android/net/cts/NetworkValidationTestUtil.kt new file mode 100644 index 0000000000..5ef185432c --- /dev/null +++ b/tests/cts/net/src/android/net/cts/NetworkValidationTestUtil.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.net.cts + +import android.Manifest +import android.net.util.NetworkStackUtils +import android.provider.DeviceConfig +import com.android.compatibility.common.util.SystemUtil.runWithShellPermissionIdentity +import com.android.compatibility.common.util.ThrowingRunnable +import kotlin.test.fail + +/** + * Collection of utility methods for configuring network validation. + */ +internal object NetworkValidationTestUtil { + + /** + * Clear the test network validation URLs. + */ + fun clearValidationTestUrlsDeviceConfig() { + setHttpsUrlDeviceConfig(null) + setHttpUrlDeviceConfig(null) + setUrlExpirationDeviceConfig(null) + } + + /** + * Set the test validation HTTPS URL. + * + * @see NetworkStackUtils.TEST_CAPTIVE_PORTAL_HTTPS_URL + */ + fun setHttpsUrlDeviceConfig(url: String?) = + setConfig(NetworkStackUtils.TEST_CAPTIVE_PORTAL_HTTPS_URL, url) + + /** + * Set the test validation HTTP URL. + * + * @see NetworkStackUtils.TEST_CAPTIVE_PORTAL_HTTP_URL + */ + fun setHttpUrlDeviceConfig(url: String?) = + setConfig(NetworkStackUtils.TEST_CAPTIVE_PORTAL_HTTP_URL, url) + + /** + * Set the test validation URL expiration. + * + * @see NetworkStackUtils.TEST_URL_EXPIRATION_TIME + */ + fun setUrlExpirationDeviceConfig(timestamp: Long?) = + setConfig(NetworkStackUtils.TEST_URL_EXPIRATION_TIME, timestamp?.toString()) + + private fun setConfig(configKey: String, value: String?) { + runAsShell(Manifest.permission.WRITE_DEVICE_CONFIG) { + DeviceConfig.setProperty( + DeviceConfig.NAMESPACE_CONNECTIVITY, configKey, value, false /* makeDefault */) + } + } + + /** + * Wrapper around runWithShellPermissionIdentity with kotlin-like syntax. + */ + fun runAsShell(vararg permissions: String, task: () -> T): T { + var ret: T? = null + runWithShellPermissionIdentity(ThrowingRunnable { ret = task() }, *permissions) + return ret ?: fail("ThrowingRunnable did not return") + } +} \ No newline at end of file