From ecb661016ff964d78fc7616ed8c0c546a2cc22fc Mon Sep 17 00:00:00 2001 From: Remi NGUYEN VAN Date: Tue, 24 Mar 2020 18:40:42 +0900 Subject: [PATCH] Add test for internet availability on portals Add a test verifying that when the device has detected a captive portal, or when the user is trying to login to a captive portal, the captive portal network does not become the default network if another network can provide internet access. This follows R CDD requirements. Test: atest CtsNetTestCases:android.net.cts.CaptivePortalTest Bug: 152280218 Change-Id: I6a97ed26dba665efdc67abb2371e0fc30ede020c --- tests/cts/net/Android.bp | 1 + .../src/android/net/cts/CaptivePortalTest.kt | 254 ++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 tests/cts/net/src/android/net/cts/CaptivePortalTest.kt diff --git a/tests/cts/net/Android.bp b/tests/cts/net/Android.bp index 76bb27e448..46fae33b9b 100644 --- a/tests/cts/net/Android.bp +++ b/tests/cts/net/Android.bp @@ -47,6 +47,7 @@ java_defaults { "mockwebserver", "junit", "junit-params", + "libnanohttpd", "truth-prebuilt", ], diff --git a/tests/cts/net/src/android/net/cts/CaptivePortalTest.kt b/tests/cts/net/src/android/net/cts/CaptivePortalTest.kt new file mode 100644 index 0000000000..4418e1740e --- /dev/null +++ b/tests/cts/net/src/android/net/cts/CaptivePortalTest.kt @@ -0,0 +1,254 @@ +/* + * 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.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 +import android.net.ConnectivityManager.NetworkCallback +import android.net.Network +import android.net.NetworkCapabilities +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.util.CtsNetUtils +import android.net.wifi.WifiManager +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 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 +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.runner.RunWith +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import kotlin.test.Test +import kotlin.test.assertNotEquals +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 LOCALHOST_HOSTNAME = "localhost" + +// Re-connecting to the AP, obtaining an IP address, revalidating can take a long time +private const val WIFI_CONNECT_TIMEOUT_MS = 120_000L +private const val TEST_TIMEOUT_MS = 10_000L + +private fun CompletableFuture.assertGet(timeoutMs: Long, message: String): T { + try { + return get(timeoutMs, TimeUnit.MILLISECONDS) + } catch (e: TimeoutException) { + throw AssertionFailedError(message) + } +} + +@AppModeFull(reason = "WRITE_DEVICE_CONFIG permission can't be granted to instant apps") +@RunWith(AndroidJUnit4::class) +class CaptivePortalTest { + private val context: android.content.Context by lazy { getInstrumentation().context } + private val wm by lazy { context.getSystemService(WifiManager::class.java) } + private val cm by lazy { context.getSystemService(ConnectivityManager::class.java) } + private val pm by lazy { context.packageManager } + private val utils by lazy { CtsNetUtils(context) } + + private val server = HttpServer() + + @Before + fun setUp() { + doAsShell(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) + } + clearTestUrls() + server.start() + } + + @After + fun tearDown() { + clearTestUrls() + server.stop() + } + + private fun assertEmptyOrLocalhostUrl(urlKey: String) { + val url = DeviceConfig.getProperty(NAMESPACE_CONNECTIVITY, urlKey) + assertTrue(TextUtils.isEmpty(url) || LOCALHOST_HOSTNAME == Uri.parse(url).host, + "$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)) + assumeTrue(pm.hasSystemFeature(FEATURE_WIFI)) + utils.connectToWifi() + utils.connectToCell() + + // Have network validation use a local server that serves a HTTPS error / HTTP redirect + server.addResponse(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)) + // 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) + + // Wait for a captive portal to be detected on the network + val wifiNetworkFuture = CompletableFuture() + val wifiCb = object : NetworkCallback() { + override fun onCapabilitiesChanged( + network: Network, + nc: NetworkCapabilities + ) { + if (nc.hasCapability(NET_CAPABILITY_CAPTIVE_PORTAL)) { + wifiNetworkFuture.complete(network) + } + } + } + cm.requestNetwork(NetworkRequest.Builder().addTransportType(TRANSPORT_WIFI).build(), wifiCb) + + try { + reconnectWifi() + val network = wifiNetworkFuture.assertGet(WIFI_CONNECT_TIMEOUT_MS, + "Captive portal not detected after ${WIFI_CONNECT_TIMEOUT_MS}ms") + + val wifiDefaultMessage = "Wifi should not be the default network when a captive " + + "portal was detected and another network (mobile data) can provide internet " + + "access." + assertNotEquals(network, cm.activeNetwork, wifiDefaultMessage) + + doAsShell(NETWORK_SETTINGS) { cm.startCaptivePortalApp(network) } + assertTrue(portalContentRequestCv.block(TEST_TIMEOUT_MS), "The captive portal login " + + "page was still not fetched ${TEST_TIMEOUT_MS}ms after startCaptivePortalApp.") + + assertNotEquals(network, cm.activeNetwork, wifiDefaultMessage) + } finally { + cm.unregisterNetworkCallback(wifiCb) + server.stop() + // disconnectFromCell should be called after connectToCell + utils.disconnectFromCell() + } + + clearTestUrls() + reconnectWifi() + } + + 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) + } + + private fun reconnectWifi() { + doAsShell(NETWORK_SETTINGS) { + assertTrue(wm.disconnect()) + assertTrue(wm.reconnect()) + } + } + + /** + * 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