diff options
Diffstat (limited to 'android/test')
36 files changed, 1557 insertions, 0 deletions
diff --git a/android/test/build.gradle.kts b/android/test/build.gradle.kts new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/android/test/build.gradle.kts diff --git a/android/test/common/build.gradle.kts b/android/test/common/build.gradle.kts new file mode 100644 index 0000000000..78d0124aa9 --- /dev/null +++ b/android/test/common/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id(Dependencies.Plugin.androidLibraryId) + id(Dependencies.Plugin.kotlinAndroidId) + id(Dependencies.Plugin.kotlinParcelizeId) +} + +android { + namespace = "net.mullvad.mullvadvpn.test.common" + compileSdk = Versions.Android.compileSdkVersion + + defaultConfig { + minSdk = Versions.Android.minSdkVersion + targetSdk = Versions.Android.targetSdkVersion + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = Versions.jvmTarget + } +} + +androidComponents { + beforeVariants { variantBuilder -> + variantBuilder.apply { + enable = name != "release" + } + } +} + +dependencies { + implementation(project(Dependencies.Mullvad.endpointLib)) + + implementation(Dependencies.AndroidX.testCore) + implementation(Dependencies.AndroidX.testRunner) + implementation(Dependencies.AndroidX.testRules) + implementation(Dependencies.AndroidX.testUiAutomator) + implementation(Dependencies.junit) + implementation(Dependencies.Kotlin.stdlib) + + androidTestUtil(Dependencies.AndroidX.testOrchestrator) +} diff --git a/android/test/common/src/main/AndroidManifest.xml b/android/test/common/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..cc947c5679 --- /dev/null +++ b/android/test/common/src/main/AndroidManifest.xml @@ -0,0 +1 @@ +<manifest /> diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/AppConstants.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/AppConstants.kt new file mode 100644 index 0000000000..05b47ef99b --- /dev/null +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/AppConstants.kt @@ -0,0 +1,6 @@ +package net.mullvad.mullvadvpn.test.common.constant + +const val MULLVAD_PACKAGE = "net.mullvad.mullvadvpn" +const val SETTINGS_COG_ID = "net.mullvad.mullvadvpn:id/settings" +const val TUNNEL_INFO_ID = "net.mullvad.mullvadvpn:id/tunnel_info" +const val TUNNEL_OUT_ADDRESS_ID = "net.mullvad.mullvadvpn:id/out_address" diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/TimeoutConstants.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/TimeoutConstants.kt new file mode 100644 index 0000000000..0da1d02aaf --- /dev/null +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/TimeoutConstants.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.test.common.constant + +const val APP_LAUNCH_TIMEOUT = 5000L +const val CONNECTION_TIMEOUT = 30000L +const val DEFAULT_INTERACTION_TIMEOUT = 3000L +const val LOGIN_TIMEOUT = 30000L +const val LOGIN_FAILURE_TIMEOUT = 60000L +const val WEB_TIMEOUT = 30000L diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/extension/UiAutomatorExtensions.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/extension/UiAutomatorExtensions.kt new file mode 100644 index 0000000000..cb953b920e --- /dev/null +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/extension/UiAutomatorExtensions.kt @@ -0,0 +1,80 @@ +package net.mullvad.mullvadvpn.test.common.extension + +import android.os.Build +import androidx.test.uiautomator.By +import androidx.test.uiautomator.BySelector +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until +import java.util.regex.Pattern +import net.mullvad.mullvadvpn.test.common.constant.DEFAULT_INTERACTION_TIMEOUT + +fun UiDevice.findObjectByCaseInsensitiveText(text: String): UiObject2 { + return findObjectWithTimeout(By.text(Pattern.compile(text, Pattern.CASE_INSENSITIVE))) +} + +fun UiObject2.findObjectByCaseInsensitiveText(text: String): UiObject2 { + return findObjectWithTimeout(By.text(Pattern.compile(text, Pattern.CASE_INSENSITIVE))) +} + +fun UiDevice.findObjectWithTimeout( + selector: BySelector, + timeout: Long = DEFAULT_INTERACTION_TIMEOUT +): UiObject2 { + + wait( + Until.hasObject(selector), + timeout + ) + + return try { + findObject(selector) + } catch (e: NullPointerException) { + throw IllegalArgumentException( + "No matches for selector within timeout ($timeout): $selector" + ) + } +} + +fun UiDevice.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove( + timeout: Long = DEFAULT_INTERACTION_TIMEOUT +) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + // Skipping as notification permissions are not shown. + return + } + + val selector = By.text("Allow") + + wait( + Until.hasObject(selector), + timeout + ) + + try { + findObjectWithTimeout(selector).click() + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException( + "Failed to allow notification permission within timeout ($timeout)" + ) + } +} + +fun UiObject2.findObjectWithTimeout( + selector: BySelector, + timeout: Long = DEFAULT_INTERACTION_TIMEOUT +): UiObject2 { + + wait( + Until.hasObject(selector), + timeout + ) + + return try { + findObject(selector) + } catch (e: NullPointerException) { + throw IllegalArgumentException( + "No matches for selector within timeout ($timeout): $selector" + ) + } +} diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt new file mode 100644 index 0000000000..1d6e9358a8 --- /dev/null +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt @@ -0,0 +1,89 @@ +package net.mullvad.mullvadvpn.test.common.interactor + +import android.content.Context +import android.content.Intent +import android.widget.ImageButton +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import net.mullvad.mullvadvpn.lib.endpoint.CustomApiEndpointConfiguration +import net.mullvad.mullvadvpn.lib.endpoint.putApiEndpointConfigurationExtra +import net.mullvad.mullvadvpn.test.common.constant.APP_LAUNCH_TIMEOUT +import net.mullvad.mullvadvpn.test.common.constant.CONNECTION_TIMEOUT +import net.mullvad.mullvadvpn.test.common.constant.LOGIN_TIMEOUT +import net.mullvad.mullvadvpn.test.common.constant.MULLVAD_PACKAGE +import net.mullvad.mullvadvpn.test.common.constant.SETTINGS_COG_ID +import net.mullvad.mullvadvpn.test.common.constant.TUNNEL_INFO_ID +import net.mullvad.mullvadvpn.test.common.constant.TUNNEL_OUT_ADDRESS_ID +import net.mullvad.mullvadvpn.test.common.extension.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove +import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout + +class AppInteractor( + private val device: UiDevice, + private val targetContext: Context +) { + fun launch(customApiEndpointConfiguration: CustomApiEndpointConfiguration? = null) { + device.pressHome() + // Wait for launcher + device.wait( + Until.hasObject(By.pkg(device.launcherPackageName).depth(0)), + APP_LAUNCH_TIMEOUT + ) + + val intent = + targetContext.packageManager.getLaunchIntentForPackage(MULLVAD_PACKAGE)?.apply { + // Clear out any previous instances + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + if (customApiEndpointConfiguration != null) { + putApiEndpointConfigurationExtra(customApiEndpointConfiguration) + } + } + targetContext.startActivity(intent) + device.wait( + Until.hasObject(By.pkg(MULLVAD_PACKAGE).depth(0)), + APP_LAUNCH_TIMEOUT + ) + } + + fun launchAndEnsureLoggedIn(accountToken: String) { + launch() + device.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove() + attemptLogin(accountToken) + ensureLoggedIn() + } + + fun attemptLogin(accountToken: String) { + device.findObjectWithTimeout(By.text("Login")) + val loginObject = device.findObjectWithTimeout(By.clazz("android.widget.EditText")) + .apply { text = accountToken } + loginObject.parent.findObject(By.clazz(ImageButton::class.java)).click() + } + + fun ensureLoggedIn() { + device.findObjectWithTimeout(By.text("UNSECURED CONNECTION"), LOGIN_TIMEOUT) + } + + fun extractIpAddress(): String { + device.findObjectWithTimeout(By.res(TUNNEL_INFO_ID)).click() + return device.findObjectWithTimeout( + By.res(TUNNEL_OUT_ADDRESS_ID), + CONNECTION_TIMEOUT + ).text.extractIpAddress() + } + + fun clickSettingsCog() { + device.findObjectWithTimeout(By.res(SETTINGS_COG_ID)).click() + } + + fun clickListItemByText(text: String) { + device.findObjectWithTimeout(By.text(text)).click() + } + + fun clickActionButtonByText(text: String) { + device.findObjectWithTimeout(By.text(text)).click() + } + + private fun String.extractIpAddress(): String { + return split(" ")[1].split(" ")[0] + } +} diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/CaptureScreenshotOnFailedTestRule.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/CaptureScreenshotOnFailedTestRule.kt new file mode 100644 index 0000000000..138d09cc28 --- /dev/null +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/CaptureScreenshotOnFailedTestRule.kt @@ -0,0 +1,114 @@ +package net.mullvad.mullvadvpn.test.common.rule + +import android.content.ContentResolver +import android.content.ContentValues +import android.graphics.Bitmap +import android.os.Build +import android.os.Environment +import android.os.Environment.DIRECTORY_PICTURES +import android.provider.MediaStore +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.nio.file.Paths +import java.time.OffsetDateTime +import java.time.temporal.ChronoUnit +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +class CaptureScreenshotOnFailedTestRule(private val testTag: String) : TestWatcher() { + + override fun failed(e: Throwable?, description: Description) { + Log.d(testTag, "Capturing screenshot of failed test: " + description.methodName) + val timestamp = OffsetDateTime.now().truncatedTo(ChronoUnit.MILLIS) + val screenshotName = "$timestamp-${description.methodName}.jpeg" + captureScreenshot(testTag, screenshotName) + } + + private fun captureScreenshot(baseDir: String, filename: String) { + val contentResolver = getInstrumentation().targetContext.applicationContext.contentResolver + val contentValues = createBaseScreenshotContentValues() + + getInstrumentation().uiAutomation.takeScreenshot().apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + writeToMediaStore( + contentValues = contentValues, + contentResolver = contentResolver, + baseDir = baseDir, + filename = filename + ) + } else { + writeToExternalStorage( + contentValues = contentValues, + contentResolver = contentResolver, + baseDir = baseDir, + filename = filename + ) + } + } + } + + @RequiresApi(29) + private fun Bitmap.writeToMediaStore( + contentValues: ContentValues, + contentResolver: ContentResolver, + baseDir: String, + filename: String + ) { + contentValues.apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, filename) + put( + MediaStore.Images.Media.RELATIVE_PATH, + "$DIRECTORY_PICTURES/$baseDir" + ) + } + + val uri = + contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) + + if (uri != null) { + contentResolver.openOutputStream(uri).use { + try { + this.compress(Bitmap.CompressFormat.JPEG, 50, it) + } catch (e: IOException) { + Log.e(testTag, "Unable to store screenshot: ${e.message}") + } + } + contentResolver.update(uri, contentValues, null, null) + } else { + Log.e(testTag, "Unable to store screenshot") + } + } + + private fun Bitmap.writeToExternalStorage( + contentValues: ContentValues, + contentResolver: ContentResolver, + baseDir: String, + filename: String + ) { + val screenshotBaseDirectory = Paths.get( + Environment.getExternalStoragePublicDirectory(DIRECTORY_PICTURES).path, + baseDir, + ).toFile().apply { + if (exists().not()) { + mkdirs() + } + } + FileOutputStream(File(screenshotBaseDirectory, filename)).use { outputStream -> + try { + this.compress(Bitmap.CompressFormat.JPEG, 50, outputStream) + } catch (e: IOException) { + Log.e(testTag, "Unable to store screenshot: ${e.message}") + } + } + contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) + } + + private fun createBaseScreenshotContentValues() = ContentValues().apply { + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis()) + } +} diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/ForgetAllVpnAppsInSettingsTestRule.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/ForgetAllVpnAppsInSettingsTestRule.kt new file mode 100644 index 0000000000..eebdb291ab --- /dev/null +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/ForgetAllVpnAppsInSettingsTestRule.kt @@ -0,0 +1,37 @@ +package net.mullvad.mullvadvpn.test.common.rule + +import android.content.Intent +import android.provider.Settings +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import net.mullvad.mullvadvpn.test.common.extension.findObjectByCaseInsensitiveText +import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +class ForgetAllVpnAppsInSettingsTestRule : TestWatcher() { + override fun starting(description: Description) { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + targetContext.startActivity( + Intent(Settings.ACTION_VPN_SETTINGS).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + ) + val vpnSettingsButtons = + device.findObjects(By.res(SETTINGS_PACKAGE, VPN_SETTINGS_BUTTON_ID)) + vpnSettingsButtons?.forEach { button -> + button.click() + device.findObjectWithTimeout(By.text(FORGET_VPN_VPN_BUTTON_TEXT)).click() + device.findObjectByCaseInsensitiveText(FORGET_VPN_VPN_CONFIRM_BUTTON_TEXT).click() + } + } + + companion object { + private const val FORGET_VPN_VPN_BUTTON_TEXT = "Forget VPN" + private const val FORGET_VPN_VPN_CONFIRM_BUTTON_TEXT = "Forget" + private const val SETTINGS_PACKAGE = "com.android.settings" + private const val VPN_SETTINGS_BUTTON_ID = "settings_button" + } +} diff --git a/android/test/e2e/README.md b/android/test/e2e/README.md new file mode 100644 index 0000000000..7c1271ad97 --- /dev/null +++ b/android/test/e2e/README.md @@ -0,0 +1,56 @@ +# End-to-end (e2e) test module +## Overview +The tests in this module are end-to-end tests that rely on the publicly accessible Mullvad infrastucture and APIs. It's therefore required to provide a valid account token (not expired) that can be used to login, connect etc. It's also required to provide an invalid account token which for example is used for negative tests of the login flow. The invalid account token should not exist in the Mullvad infrastucture, however it must be at least 9 characters for some tests to properly run due to input validation. + +## How to run the tests +### Locally +Set tokens in the below command and then execute the command in the `android` directory to run the tests on a local device: +``` +./gradlew :test:e2e:connectedDebugAndroidTest \ + -Pvalid_test_account_token=XXXX \ + -Pinvalid_test_account_token=XXXX +``` + +For convenience, the tokens can also be set in `<REPO-ROOT>/android/local.properties` in the following way: +``` +valid_test_account_token=XXXX +invalid_test_account_token=XXXX +``` + +It's also possible to provide the tokens to the test runner during test execution. However note that this requires [the APKs to be installed manually](https://developer.android.com/training/testing/instrumented-tests/androidx-test-libraries/runner#architecture). +``` +adb shell 'CLASSPATH=$(pm path androidx.test.services) app_process / \ + androidx.test.services.shellexecutor.ShellMain am instrument -w \ + -e clearPackageData true \ + -e valid_test_account_token XXXX \ + -e invalid_test_account_token XXXX \ + -e targetInstrumentation net.mullvad.mullvadvpn.test.e2e/androidx.test.runner.AndroidJUnitRunner \ + androidx.test.orchestrator/.AndroidTestOrchestrator' +``` + +### Firebase Test Lab +Firebase Test Lab can be used to run the tests on vast collection of physical and virtual devices. + +1. Setup the gcloud CLI by following the [official documentation](https://firebase.google.com/docs/test-lab/android/command-line). + +2. Set tokens in the below command and then execute the command in the `android` directory to run the tests (on a Pixel 5e): +``` +gcloud firebase test android run \ + --type instrumentation \ + --app ./android/app/build/outputs/apk/debug/app-debug.apk \ + --test ./android/test/e2e/build/outputs/apk/debug/e2e-debug.apk \ + --device model=redfin,version=30,locale=en,orientation=portrait \ + --use-orchestrator \ + --environment-variables clearPackageData=true,valid_test_account_token=XXXX,invalid_test_account_token=XXXX +``` + +If using gcloud via the docker image, the following can be executed in the `android` directory to run the tests (on a Pixel 5e): +``` +docker run --rm --volumes-from gcloud-config -v ${PWD}:/android gcr.io/google.com/cloudsdktool/google-cloud-cli gcloud firebase test android run \ + --type instrumentation \ + --app ./android/app/build/outputs/apk/debug/app-debug.apk \ + --test ./android/test/e2e/build/outputs/apk/debug/e2e-debug.apk \ + --device model=redfin,version=30,locale=en,orientation=portrait \ + --use-orchestrator \ + --environment-variables clearPackageData=true,valid_test_account_token=XXXX,invalid_test_account_token=XXXX +``` diff --git a/android/test/e2e/build.gradle.kts b/android/test/e2e/build.gradle.kts new file mode 100644 index 0000000000..6310ca5b12 --- /dev/null +++ b/android/test/e2e/build.gradle.kts @@ -0,0 +1,89 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties +import java.util.Properties + +plugins { + id(Dependencies.Plugin.androidTestId) + id(Dependencies.Plugin.kotlinAndroidId) +} + +android { + namespace = "net.mullvad.mullvadvpn.test.e2e" + compileSdk = Versions.Android.compileSdkVersion + + defaultConfig { + minSdk = Versions.Android.minSdkVersion + targetSdk = Versions.Android.targetSdkVersion + testApplicationId = "net.mullvad.mullvadvpn.test.e2e" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + targetProjectPath = ":app" + + fun Properties.addRequiredPropertyAsBuildConfigField(name: String) { + val value = getProperty(name) ?: throw GradleException("Missing property: $name") + buildConfigField( + type = "String", + name = name, + value = "\"$value\"" + ) + } + + Properties().apply { + load(project.file("e2e.properties").inputStream()) + addRequiredPropertyAsBuildConfigField("API_BASE_URL") + addRequiredPropertyAsBuildConfigField("API_VERSION") + } + + fun MutableMap<String, String>.addOptionalPropertyAsArgument(name: String) { + val value = rootProject.properties.getOrDefault(name, null) as? String + ?: gradleLocalProperties(rootProject.projectDir).getProperty(name) + + if (value != null) { + put(name, value) + } + } + + testInstrumentationRunnerArguments += mutableMapOf<String, String>().apply { + put("clearPackageData", "true") + put("useTestStorageService", "true") + addOptionalPropertyAsArgument("valid_test_account_token") + addOptionalPropertyAsArgument("invalid_test_account_token") + } + } + + testOptions { + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = Versions.jvmTarget + } +} + +configure<org.owasp.dependencycheck.gradle.extension.DependencyCheckExtension> { + // Skip the lintClassPath configuration, which relies on many dependencies that has been flagged + // to have CVEs, as it's related to the lint tooling rather than the project's compilation class + // path. The alternative would be to suppress specific CVEs, however that could potentially + // result in suppressed CVEs in project compilation class path. + skipConfigurations = listOf("lintClassPath") + suppressionFile = "$projectDir/../test-suppression.xml" +} + +dependencies { + implementation(project(Projects.testCommon)) + implementation(project(Dependencies.Mullvad.endpointLib)) + + implementation(Dependencies.AndroidX.testCore) + // Fixes: https://github.com/android/android-test/issues/1589 + implementation(Dependencies.AndroidX.testMonitor) + implementation(Dependencies.AndroidX.testRunner) + implementation(Dependencies.AndroidX.testRules) + implementation(Dependencies.AndroidX.testUiAutomator) + implementation(Dependencies.androidVolley) + implementation(Dependencies.Kotlin.stdlib) + + androidTestUtil(Dependencies.AndroidX.testOrchestrator) +} diff --git a/android/test/e2e/e2e.properties b/android/test/e2e/e2e.properties new file mode 100644 index 0000000000..f9420786c8 --- /dev/null +++ b/android/test/e2e/e2e.properties @@ -0,0 +1,2 @@ +API_BASE_URL=https://api.mullvad.net +API_VERSION=v1 diff --git a/android/test/e2e/src/main/AndroidManifest.xml b/android/test/e2e/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..9ba07f4905 --- /dev/null +++ b/android/test/e2e/src/main/AndroidManifest.xml @@ -0,0 +1,10 @@ +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + + <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> + <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> + + <uses-permission android:name="android.permission.INTERNET" /> + <instrumentation + android:name="androidx.test.runner.AndroidJUnitRunner" + android:targetPackage="net.mullvad.mullvadvpn" /> +</manifest> diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/ConnectionTest.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/ConnectionTest.kt new file mode 100644 index 0000000000..8f72fef0be --- /dev/null +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/ConnectionTest.kt @@ -0,0 +1,38 @@ +package net.mullvad.mullvadvpn.test.e2e + +import androidx.test.uiautomator.By +import junit.framework.Assert.assertEquals +import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout +import net.mullvad.mullvadvpn.test.common.rule.ForgetAllVpnAppsInSettingsTestRule +import net.mullvad.mullvadvpn.test.e2e.misc.CleanupAccountTestRule +import net.mullvad.mullvadvpn.test.e2e.misc.ConnCheckState +import net.mullvad.mullvadvpn.test.e2e.misc.SimpleMullvadHttpClient +import org.junit.Rule +import org.junit.Test + +class ConnectionTest : EndToEndTest() { + + @Rule + @JvmField + val cleanupAccountTestRule = CleanupAccountTestRule() + + @Rule + @JvmField + val forgetAllVpnAppsInSettingsTestRule = ForgetAllVpnAppsInSettingsTestRule() + + @Test + fun testConnectAndVerifyWithConnectionCheck() { + // Given + app.launchAndEnsureLoggedIn(validTestAccountToken) + + // When + device.findObjectWithTimeout(By.text("Secure my connection")).click() + device.findObjectWithTimeout(By.text("OK")).click() + device.findObjectWithTimeout(By.text("SECURE CONNECTION")) + val expected = ConnCheckState(true, app.extractIpAddress()) + + // Then + val result = SimpleMullvadHttpClient(targetContext).runConnectionCheck() + assertEquals(expected, result) + } +} diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/EndToEndTest.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/EndToEndTest.kt new file mode 100644 index 0000000000..f8f5bb8f6c --- /dev/null +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/EndToEndTest.kt @@ -0,0 +1,54 @@ +package net.mullvad.mullvadvpn.test.e2e + +import android.Manifest +import android.content.Context +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import androidx.test.runner.AndroidJUnit4 +import androidx.test.uiautomator.UiDevice +import net.mullvad.mullvadvpn.test.common.interactor.AppInteractor +import net.mullvad.mullvadvpn.test.common.rule.CaptureScreenshotOnFailedTestRule +import net.mullvad.mullvadvpn.test.e2e.constant.INVALID_TEST_ACCOUNT_TOKEN_ARGUMENT_KEY +import net.mullvad.mullvadvpn.test.e2e.constant.LOG_TAG +import net.mullvad.mullvadvpn.test.e2e.constant.VALID_TEST_ACCOUNT_TOKEN_ARGUMENT_KEY +import net.mullvad.mullvadvpn.test.e2e.extension.getRequiredArgument +import org.junit.Before +import org.junit.Rule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +abstract class EndToEndTest { + + @Rule + @JvmField + val rule = CaptureScreenshotOnFailedTestRule(LOG_TAG) + + @Rule + @JvmField + val permissionRule: GrantPermissionRule = GrantPermissionRule.grant( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.READ_EXTERNAL_STORAGE + ) + + lateinit var device: UiDevice + lateinit var targetContext: Context + lateinit var app: AppInteractor + lateinit var validTestAccountToken: String + lateinit var invalidTestAccountToken: String + + @Before + fun setup() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + targetContext = InstrumentationRegistry.getInstrumentation().targetContext + + validTestAccountToken = InstrumentationRegistry.getArguments() + .getRequiredArgument(VALID_TEST_ACCOUNT_TOKEN_ARGUMENT_KEY) + invalidTestAccountToken = InstrumentationRegistry.getArguments() + .getRequiredArgument(INVALID_TEST_ACCOUNT_TOKEN_ARGUMENT_KEY) + + app = AppInteractor( + device, + targetContext + ) + } +} diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LaunchAppTest.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LaunchAppTest.kt new file mode 100644 index 0000000000..64f534990c --- /dev/null +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LaunchAppTest.kt @@ -0,0 +1,13 @@ +package net.mullvad.mullvadvpn.test.e2e + +import androidx.test.runner.AndroidJUnit4 +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LaunchAppTest : EndToEndTest() { + @Test + fun testLaunchApp() { + app.launch() + } +} diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LoginTest.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LoginTest.kt new file mode 100644 index 0000000000..9e106d45a2 --- /dev/null +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LoginTest.kt @@ -0,0 +1,60 @@ +package net.mullvad.mullvadvpn.test.e2e + +import androidx.test.runner.AndroidJUnit4 +import androidx.test.uiautomator.By +import junit.framework.Assert.assertNotNull +import net.mullvad.mullvadvpn.test.common.constant.LOGIN_FAILURE_TIMEOUT +import net.mullvad.mullvadvpn.test.common.extension.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove +import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout +import net.mullvad.mullvadvpn.test.e2e.misc.CleanupAccountTestRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LoginTest : EndToEndTest() { + + @Rule + @JvmField + val cleanupAccountTestRule = CleanupAccountTestRule() + + @Test + fun testLoginWithInvalidCredentials() { + // Given + val invalidDummyAccountToken = invalidTestAccountToken + + // When + app.launch() + device.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove() + app.attemptLogin(invalidDummyAccountToken) + + // Then + device.findObjectWithTimeout(By.text("Login failed"), LOGIN_FAILURE_TIMEOUT) + } + + @Test + fun testLoginWithValidCredentials() { + // Given + val token = validTestAccountToken + + // When + app.launchAndEnsureLoggedIn(token) + + // Then + app.ensureLoggedIn() + } + + @Test + fun testLogout() { + // Given + app.launchAndEnsureLoggedIn(validTestAccountToken) + + // When + app.clickSettingsCog() + app.clickListItemByText("Account") + app.clickActionButtonByText("Log out") + + // Then + assertNotNull(device.findObjectWithTimeout(By.text("Login"))) + } +} diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/WebLinkTest.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/WebLinkTest.kt new file mode 100644 index 0000000000..9893074a88 --- /dev/null +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/WebLinkTest.kt @@ -0,0 +1,27 @@ +package net.mullvad.mullvadvpn.test.e2e + +import androidx.test.uiautomator.By +import net.mullvad.mullvadvpn.test.common.constant.WEB_TIMEOUT +import net.mullvad.mullvadvpn.test.common.extension.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove +import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout +import org.junit.Test + +class WebLinkTest : EndToEndTest() { + @Test + fun testOpenFaqFromApp() { + // Given + app.launch() + + // When + device.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove() + device.findObjectWithTimeout(By.text("Login")) + app.clickSettingsCog() + app.clickListItemByText("FAQs & Guides") + + // Then + device.findObjectWithTimeout( + selector = By.text("Mullvad help center"), + timeout = WEB_TIMEOUT + ) + } +} diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/ConnCheckConstants.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/ConnCheckConstants.kt new file mode 100644 index 0000000000..5357ce0e75 --- /dev/null +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/ConnCheckConstants.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.test.e2e.constant + +const val CONN_CHECK_URL = "https://am.i.mullvad.net/json" diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/Constants.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/Constants.kt new file mode 100644 index 0000000000..98fd52a333 --- /dev/null +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/Constants.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.test.e2e.constant + +const val LOG_TAG = "mullvad-e2e" +const val VALID_TEST_ACCOUNT_TOKEN_ARGUMENT_KEY = "valid_test_account_token" +const val INVALID_TEST_ACCOUNT_TOKEN_ARGUMENT_KEY = "invalid_test_account_token" diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/TextConstants.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/TextConstants.kt new file mode 100644 index 0000000000..cfc4080ea4 --- /dev/null +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/TextConstants.kt @@ -0,0 +1,4 @@ +package net.mullvad.mullvadvpn.test.e2e.constant + +const val CONNECTION_CHECK_IS_CONNECTED = "Using Mullvad VPN" +const val CONNECTION_CHECK_IS_NOT_CONNECTED = "Not using Mullvad VPN" diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/extension/BundleExtensions.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/extension/BundleExtensions.kt new file mode 100644 index 0000000000..c96c28bb09 --- /dev/null +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/extension/BundleExtensions.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.test.e2e.extension + +import android.os.Bundle + +fun Bundle.getRequiredArgument(argument: String): String { + return getString(argument) + ?: throw IllegalArgumentException("Missing required argument: $argument") +} diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/interactor/MullvadAccountInteractor.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/interactor/MullvadAccountInteractor.kt new file mode 100644 index 0000000000..8f3f55166f --- /dev/null +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/interactor/MullvadAccountInteractor.kt @@ -0,0 +1,12 @@ +package net.mullvad.mullvadvpn.test.e2e.interactor + +import net.mullvad.mullvadvpn.test.e2e.misc.SimpleMullvadHttpClient + +class MullvadAccountInteractor( + private val httpClient: SimpleMullvadHttpClient, + private val testAccountToken: String +) { + fun cleanupAccount() { + httpClient.removeAllDevices(testAccountToken) + } +} diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/interactor/SystemSettingsInteractor.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/interactor/SystemSettingsInteractor.kt new file mode 100644 index 0000000000..9e5f0aa665 --- /dev/null +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/interactor/SystemSettingsInteractor.kt @@ -0,0 +1,36 @@ +package net.mullvad.mullvadvpn.test.e2e.interactor + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import net.mullvad.mullvadvpn.test.common.extension.findObjectByCaseInsensitiveText + +class SystemSettingsInteractor( + private val uiDevice: UiDevice, + private val context: Context +) { + fun openVpnSettings() { + val intent = Intent("com.intent.MAIN").apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + intent.component = ComponentName.unflattenFromString( + "com.android.settings/.Settings\$VpnSettingsActivity" + ) + context.startActivity(intent) + Thread.sleep(1000) + } + + fun removeAllVpnPermissions() { + openVpnSettings() + uiDevice.findObjects(By.descContains("Settings")).forEach { + it.click() + Thread.sleep(1000) + uiDevice.findObjectByCaseInsensitiveText("forget vpn").click() + Thread.sleep(1000) + uiDevice.findObjectByCaseInsensitiveText("forget").click() + } + } +} diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/CleanupAccountTestRule.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/CleanupAccountTestRule.kt new file mode 100644 index 0000000000..2b69436f6d --- /dev/null +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/CleanupAccountTestRule.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.test.e2e.misc + +import android.util.Log +import androidx.test.platform.app.InstrumentationRegistry +import net.mullvad.mullvadvpn.test.e2e.constant.LOG_TAG +import net.mullvad.mullvadvpn.test.e2e.constant.VALID_TEST_ACCOUNT_TOKEN_ARGUMENT_KEY +import net.mullvad.mullvadvpn.test.e2e.extension.getRequiredArgument +import net.mullvad.mullvadvpn.test.e2e.interactor.MullvadAccountInteractor +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +class CleanupAccountTestRule : TestWatcher() { + override fun starting(description: Description) { + Log.d(LOG_TAG, "Cleaning up account before test: ${description.methodName}") + val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + val validTestAccountToken = InstrumentationRegistry.getArguments() + .getRequiredArgument(VALID_TEST_ACCOUNT_TOKEN_ARGUMENT_KEY) + MullvadAccountInteractor(SimpleMullvadHttpClient(targetContext), validTestAccountToken) + .cleanupAccount() + } +} diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/ConnCheckState.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/ConnCheckState.kt new file mode 100644 index 0000000000..744e80124e --- /dev/null +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/ConnCheckState.kt @@ -0,0 +1,6 @@ +package net.mullvad.mullvadvpn.test.e2e.misc + +data class ConnCheckState( + val isConnected: Boolean, + val ipAddress: String +) diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/SimpleMullvadHttpClient.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/SimpleMullvadHttpClient.kt new file mode 100644 index 0000000000..bc56737b04 --- /dev/null +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/SimpleMullvadHttpClient.kt @@ -0,0 +1,208 @@ +package net.mullvad.mullvadvpn.test.e2e.misc + +import android.content.Context +import android.util.Log +import androidx.test.services.events.TestEventException +import com.android.volley.Request +import com.android.volley.toolbox.JsonArrayRequest +import com.android.volley.toolbox.JsonObjectRequest +import com.android.volley.toolbox.RequestFuture +import com.android.volley.toolbox.StringRequest +import com.android.volley.toolbox.Volley +import net.mullvad.mullvadvpn.test.e2e.BuildConfig +import net.mullvad.mullvadvpn.test.e2e.constant.CONN_CHECK_URL +import net.mullvad.mullvadvpn.test.e2e.constant.LOG_TAG +import org.json.JSONArray +import org.json.JSONObject + +class SimpleMullvadHttpClient(context: Context) { + + private val queue = Volley.newRequestQueue(context) + + fun removeAllDevices(accountToken: String) { + Log.v(LOG_TAG, "Remove all devices") + val token = login(accountToken) + val devices = getDeviceList(token) + devices.forEach { + removeDevice(token, it) + } + Log.v(LOG_TAG, "All devices removed") + } + + fun login(accountToken: String): String { + Log.v(LOG_TAG, "Attempt login with account token: $accountToken") + val json = JSONObject().apply { + put("account_number", accountToken) + } + return sendSimpleSynchronousRequest(Request.Method.POST, AUTH_URL, json)!!.let { response -> + response.getString("access_token").also { accessToken -> + Log.v(LOG_TAG, "Successfully logged in and received access token: $accessToken") + } + } + } + + fun getDeviceList(accessToken: String): List<String> { + Log.v(LOG_TAG, "Get devices") + + val response = sendSimpleSynchronousRequestArray( + Request.Method.GET, + DEVICE_LIST_URL, + token = accessToken + ) + + return response!!.iterator<JSONObject>().asSequence().toList() + .also { + it + .map { jsonObject -> + jsonObject.getString("name") + } + .also { deviceNames -> + Log.v(LOG_TAG, "Devices received: $deviceNames") + } + } + .map { it.getString("id") } + .toList() + } + + fun removeDevice(token: String, deviceId: String) { + Log.v(LOG_TAG, "Remove device: $deviceId") + sendSimpleSynchronousRequestString( + Request.Method.DELETE, + "$DEVICE_LIST_URL/$deviceId", + token = token + ) + } + + fun runConnectionCheck(): ConnCheckState? { + return sendSimpleSynchronousRequestString( + Request.Method.GET, + CONN_CHECK_URL + ) + ?.let { respose -> + JSONObject(respose) + } + ?.let { json -> + ConnCheckState( + isConnected = json.getBoolean("mullvad_exit_ip"), + ipAddress = json.getString("ip") + ) + } + } + + private fun sendSimpleSynchronousRequest( + method: Int, + url: String, + body: JSONObject? = null, + token: String? = null + ): JSONObject? { + val future = RequestFuture.newFuture<JSONObject>() + val request = object : JsonObjectRequest( + method, + url, + body, + future, + future + ) { + override fun getHeaders(): MutableMap<String, String> { + val headers = HashMap<String, String>() + if (body != null) { + headers.put("Content-Type", "application/json") + } + if (token != null) { + headers.put("Authorization", "Bearer $token") + } + return headers + } + } + queue.add(request) + return try { + future.get().also { response -> + Log.v(LOG_TAG, "Json object request response: $response") + } + } catch (e: Exception) { + Log.v(LOG_TAG, "Json object request error: ${e.message}") + throw TestEventException(REQUEST_ERROR_MESSAGE) + } + } + + private fun sendSimpleSynchronousRequestString( + method: Int, + url: String, + body: String? = null, + token: String? = null + ): String? { + val future = RequestFuture.newFuture<String>() + val request = object : StringRequest( + method, + url, + future, + future + ) { + override fun getHeaders(): MutableMap<String, String> { + val headers = HashMap<String, String>() + if (body != null) { + headers.put("Content-Type", "application/json") + } + if (token != null) { + headers.put("Authorization", "Bearer $token") + } + return headers + } + } + queue.add(request) + return try { + future.get().also { response -> + Log.v(LOG_TAG, "String request response: $response") + } + } catch (e: Exception) { + Log.v(LOG_TAG, "String request error: ${e.message}") + throw TestEventException(REQUEST_ERROR_MESSAGE) + } + } + + private fun sendSimpleSynchronousRequestArray( + method: Int, + url: String, + body: JSONArray? = null, + token: String? = null + ): JSONArray? { + val future = RequestFuture.newFuture<JSONArray>() + val request = object : JsonArrayRequest( + method, + url, + null, + future, + future + ) { + override fun getHeaders(): MutableMap<String, String> { + val headers = HashMap<String, String>() + headers.put("Content-Type", "application/json") + if (token != null) { + headers.put("Authorization", "Bearer $token") + } + return headers + } + } + queue.add(request) + return try { + future.get().also { response -> + Log.v(LOG_TAG, "Json array request response: $response") + } + } catch (e: Exception) { + Log.v(LOG_TAG, "Json array request error: ${e.message}") + throw TestEventException(REQUEST_ERROR_MESSAGE) + } + } + + operator fun <T> JSONArray.iterator(): Iterator<T> = + (0 until this.length()).asSequence().map { this.get(it) as T }.iterator() + + companion object { + private const val AUTH_URL = + "${BuildConfig.API_BASE_URL}/auth/${BuildConfig.API_VERSION}/token" + private const val DEVICE_LIST_URL = + "${BuildConfig.API_BASE_URL}/accounts/${BuildConfig.API_VERSION}/devices" + private const val REQUEST_ERROR_MESSAGE = + "Unable to verify account due to invalid account or connectivity issues." + } +} diff --git a/android/test/mockapi/build.gradle.kts b/android/test/mockapi/build.gradle.kts new file mode 100644 index 0000000000..7fd7634e63 --- /dev/null +++ b/android/test/mockapi/build.gradle.kts @@ -0,0 +1,62 @@ +plugins { + id(Dependencies.Plugin.androidTestId) + id(Dependencies.Plugin.kotlinAndroidId) +} + +android { + namespace = "net.mullvad.mullvadvpn.test.mockapi" + compileSdk = Versions.Android.compileSdkVersion + + defaultConfig { + minSdk = Versions.Android.minSdkVersion + targetSdk = Versions.Android.targetSdkVersion + testApplicationId = "net.mullvad.mullvadvpn.test.mockapi" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + targetProjectPath = ":app" + + testInstrumentationRunnerArguments.putAll( + mapOf( + "clearPackageData" to "true", + "useTestStorageService" to "true" + ) + ) + } + + testOptions { + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = Versions.jvmTarget + } +} + +configure<org.owasp.dependencycheck.gradle.extension.DependencyCheckExtension> { + // Skip the lintClassPath configuration, which relies on many dependencies that has been flagged + // to have CVEs, as it's related to the lint tooling rather than the project's compilation class + // path. The alternative would be to suppress specific CVEs, however that could potentially + // result in suppressed CVEs in project compilation class path. + skipConfigurations = listOf("lintClassPath") + suppressionFile = "$projectDir/../test-suppression.xml" +} + +dependencies { + implementation(project(Projects.testCommon)) + implementation(project(Dependencies.Mullvad.endpointLib)) + + implementation(Dependencies.AndroidX.testCore) + // Fixes: https://github.com/android/android-test/issues/1589 + implementation(Dependencies.AndroidX.testMonitor) + implementation(Dependencies.AndroidX.testRunner) + implementation(Dependencies.AndroidX.testRules) + implementation(Dependencies.AndroidX.testUiAutomator) + implementation(Dependencies.Kotlin.stdlib) + implementation(Dependencies.mockkWebserver) + + androidTestUtil(Dependencies.AndroidX.testOrchestrator) +} diff --git a/android/test/mockapi/src/main/AndroidManifest.xml b/android/test/mockapi/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..931f79d291 --- /dev/null +++ b/android/test/mockapi/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + + <uses-permission android:name="android.permission.INTERNET" /> + <instrumentation + android:name="androidx.test.runner.AndroidJUnitRunner" + android:targetPackage="net.mullvad.mullvadvpn" /> +</manifest> diff --git a/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/Extensions.kt b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/Extensions.kt new file mode 100644 index 0000000000..60bd0e293a --- /dev/null +++ b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/Extensions.kt @@ -0,0 +1,26 @@ +package net.mullvad.mullvadvpn.test.mockapi + +import okhttp3.mockwebserver.MockResponse +import okio.Buffer +import org.json.JSONException +import org.json.JSONObject + +fun MockResponse.addJsonHeader(): MockResponse { + return addHeader("Content-Type", "application/json") +} + +fun Buffer.getAccountToken(): String? { + return try { + JSONObject(readUtf8()).getString("account_number") + } catch (ex: JSONException) { + null + } +} + +fun Buffer.getPubKey(): String? { + return try { + JSONObject(readUtf8()).getString("pubkey") + } catch (ex: JSONException) { + null + } +} diff --git a/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/JsonUtils.kt b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/JsonUtils.kt new file mode 100644 index 0000000000..d6ee2bc6cb --- /dev/null +++ b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/JsonUtils.kt @@ -0,0 +1,41 @@ +package net.mullvad.mullvadvpn.test.mockapi + +import java.time.OffsetDateTime +import org.json.JSONArray +import org.json.JSONObject + +fun accountInfoJson( + id: String, + expiry: OffsetDateTime +) = JSONObject().apply { + put("id", id) + put("expiry", expiry.toString()) + put("max_ports", 5) + put("can_add_ports", true) + put("max_devices", 5) + put("can_add_devices", true) +} + +fun deviceJson( + id: String, + name: String, + publicKey: String, + creationDate: OffsetDateTime +) = JSONObject().apply { + put("id", id) + put("name", name) + put("pubkey", publicKey) + put("hijack_dns", true) + put("created", creationDate.toString()) + put("ipv4_address", "127.0.0.1/32") + put("ipv6_address", "fc00::1/128") + put("ports", JSONArray()) +} + +fun accessTokenJsonResponse( + accessToken: String, + expiry: OffsetDateTime +) = JSONObject().apply { + put("access_token", accessToken) + put("expiry", expiry.toString()) +} diff --git a/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/LoginMockApiTest.kt b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/LoginMockApiTest.kt new file mode 100644 index 0000000000..2a72b41373 --- /dev/null +++ b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/LoginMockApiTest.kt @@ -0,0 +1,70 @@ +package net.mullvad.mullvadvpn.test.mockapi + +import androidx.test.runner.AndroidJUnit4 +import androidx.test.uiautomator.By +import java.time.OffsetDateTime +import java.time.temporal.ChronoUnit +import net.mullvad.mullvadvpn.test.common.extension.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove +import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LoginMockApiTest : MockApiTest() { + @Test + fun testLoginWithInvalidCredentials() { + // Arrange + val validAccountToken = "1234123412341234" + apiDispatcher.apply { + expectedAccountToken = null + accountExpiry = + OffsetDateTime.now().plusDays(1).truncatedTo(ChronoUnit.SECONDS) + } + app.launch(endpoint) + + // Act + device.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove() + app.attemptLogin(validAccountToken) + + // Assert + device.findObjectWithTimeout(By.text("Login failed")) + } + + @Test + fun testLoginWithValidCredentialsToUnexpiredAccount() { + // Arrange + val validAccountToken = "1234123412341234" + apiDispatcher.apply { + expectedAccountToken = validAccountToken + accountExpiry = + OffsetDateTime.now().plusDays(1).truncatedTo(ChronoUnit.SECONDS) + } + + // Act + app.launch(endpoint) + device.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove() + app.attemptLogin(validAccountToken) + + // Assert + device.findObjectWithTimeout(By.text("UNSECURED CONNECTION")) + } + + @Test + fun testLoginWithValidCredentialsToExpiredAccount() { + // Arrange + val validAccountToken = "1234123412341234" + apiDispatcher.apply { + expectedAccountToken = validAccountToken + accountExpiry = + OffsetDateTime.now().minusDays(1).truncatedTo(ChronoUnit.SECONDS) + } + + // Act + app.launch(endpoint) + device.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove() + app.attemptLogin(validAccountToken) + + // Assert + device.findObjectWithTimeout(By.text("Out of time")) + } +} diff --git a/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/MockApiDispatcher.kt b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/MockApiDispatcher.kt new file mode 100644 index 0000000000..b41ad34fa6 --- /dev/null +++ b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/MockApiDispatcher.kt @@ -0,0 +1,130 @@ +package net.mullvad.mullvadvpn.test.mockapi + +import android.util.Log +import java.time.OffsetDateTime +import java.time.temporal.ChronoUnit +import net.mullvad.mullvadvpn.test.mockapi.constant.ACCOUNT_URL_PATH +import net.mullvad.mullvadvpn.test.mockapi.constant.AUTH_TOKEN_URL_PATH +import net.mullvad.mullvadvpn.test.mockapi.constant.DEVICES_URL_PATH +import net.mullvad.mullvadvpn.test.mockapi.constant.DUMMY_ACCESS_TOKEN +import net.mullvad.mullvadvpn.test.mockapi.constant.DUMMY_DEVICE_NAME +import net.mullvad.mullvadvpn.test.mockapi.constant.DUMMY_ID +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.RecordedRequest +import okio.Buffer +import org.json.JSONArray + +class MockApiDispatcher : Dispatcher() { + + var expectedAccountToken: String? = null + var accountExpiry: OffsetDateTime? = null + + private var cachedPubKeyFromAppUnderTest: String? = null + + override fun dispatch(request: RecordedRequest): MockResponse { + Log.d("mullvad", "Request: $request") + return when (request.path) { + AUTH_TOKEN_URL_PATH -> handleLoginRequest(request.body) + DEVICES_URL_PATH -> { + when (request.method) { + "get", "GET" -> handleDeviceListRequest() + "post", "POST" -> handleDeviceCreationRequest(request.body) + else -> MockResponse().setResponseCode(404) + } + } + "$DEVICES_URL_PATH/$DUMMY_ID" -> handleDeviceInfoRequest() + ACCOUNT_URL_PATH -> handleAccountInfoRequest() + else -> MockResponse().setResponseCode(404) + } + } + + private fun handleLoginRequest(requestBody: Buffer): MockResponse { + val accountToken = requestBody.getAccountToken() + + return if (accountToken != null && accountToken == expectedAccountToken) { + MockResponse() + .setResponseCode(200) + .addJsonHeader() + .setBody( + accessTokenJsonResponse( + accessToken = DUMMY_ACCESS_TOKEN, + expiry = OffsetDateTime.now().plusDays(1).truncatedTo(ChronoUnit.SECONDS) + ).toString() + ) + } else { + MockResponse().setResponseCode(400) + } + } + + private fun handleAccountInfoRequest(): MockResponse { + return accountExpiry?.let { expiry -> + MockResponse() + .setResponseCode(200) + .addJsonHeader() + .setBody( + accountInfoJson( + id = DUMMY_ID, + expiry = expiry + ).toString() + ) + } ?: MockResponse().setResponseCode(400) + } + + private fun handleDeviceInfoRequest(): MockResponse { + return cachedPubKeyFromAppUnderTest?.let { cachedKey -> + MockResponse() + .setResponseCode(200) + .addJsonHeader() + .setBody( + deviceJson( + id = DUMMY_ID, + name = DUMMY_DEVICE_NAME, + publicKey = cachedKey, + creationDate = OffsetDateTime.now().minusDays(1) + .truncatedTo(ChronoUnit.SECONDS) + ).toString() + ) + } ?: MockResponse().setResponseCode(400) + } + + private fun handleDeviceCreationRequest(body: Buffer): MockResponse { + return body.getPubKey() + .also { newKey -> + cachedPubKeyFromAppUnderTest = newKey + } + ?.let { newKey -> + MockResponse() + .setResponseCode(201) + .addJsonHeader() + .setBody( + deviceJson( + id = DUMMY_ID, + name = DUMMY_DEVICE_NAME, + publicKey = newKey, + creationDate = OffsetDateTime.now().minusDays(1) + .truncatedTo(ChronoUnit.SECONDS) + ).toString() + ) + } ?: MockResponse().setResponseCode(400) + } + + private fun handleDeviceListRequest(): MockResponse { + return cachedPubKeyFromAppUnderTest?.let { cachedKey -> + MockResponse() + .setResponseCode(200) + .addJsonHeader() + .setBody( + JSONArray().put( + deviceJson( + id = DUMMY_ID, + name = DUMMY_DEVICE_NAME, + publicKey = cachedKey, + creationDate = OffsetDateTime.now().minusDays(1) + .truncatedTo(ChronoUnit.SECONDS) + ) + ).toString() + ) + } ?: MockResponse().setResponseCode(400) + } +} diff --git a/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/MockApiTest.kt b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/MockApiTest.kt new file mode 100644 index 0000000000..ca4338bb2e --- /dev/null +++ b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/MockApiTest.kt @@ -0,0 +1,80 @@ +package net.mullvad.mullvadvpn.test.mockapi + +import android.Manifest.permission.READ_EXTERNAL_STORAGE +import android.Manifest.permission.WRITE_EXTERNAL_STORAGE +import android.content.Context +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import androidx.test.runner.AndroidJUnit4 +import androidx.test.uiautomator.UiDevice +import java.net.InetAddress +import java.net.InetSocketAddress +import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpoint +import net.mullvad.mullvadvpn.lib.endpoint.CustomApiEndpointConfiguration +import net.mullvad.mullvadvpn.test.common.interactor.AppInteractor +import net.mullvad.mullvadvpn.test.common.rule.CaptureScreenshotOnFailedTestRule +import net.mullvad.mullvadvpn.test.mockapi.constant.LOG_TAG +import net.mullvad.mullvadvpn.test.mockapi.constant.MOCK_SERVER_LOCALHOST_ADDRESS +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +abstract class MockApiTest { + + @Rule + @JvmField + val rule = CaptureScreenshotOnFailedTestRule(LOG_TAG) + + @Rule + @JvmField + val permissionRule: GrantPermissionRule = GrantPermissionRule.grant( + WRITE_EXTERNAL_STORAGE, + READ_EXTERNAL_STORAGE + ) + + protected val apiDispatcher = MockApiDispatcher() + private val mockWebServer = MockWebServer().apply { + dispatcher = apiDispatcher + } + + lateinit var device: UiDevice + lateinit var targetContext: Context + lateinit var app: AppInteractor + lateinit var endpoint: CustomApiEndpointConfiguration + + @Before + open fun setup() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + targetContext = InstrumentationRegistry.getInstrumentation().targetContext + + app = AppInteractor( + device, + targetContext + ) + + mockWebServer.start() + endpoint = createEndpoint(mockWebServer.port) + } + + @After + open fun teardown() { + mockWebServer.shutdown() + } + + private fun createEndpoint(port: Int): CustomApiEndpointConfiguration { + val mockApiSocket = InetSocketAddress( + InetAddress.getByName(MOCK_SERVER_LOCALHOST_ADDRESS), + port + ) + val api = ApiEndpoint( + address = mockApiSocket, + disableAddressCache = true, + disableTls = true, + forceDirectConnection = true + ) + return CustomApiEndpointConfiguration(api) + } +} diff --git a/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/constant/Constants.kt b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/constant/Constants.kt new file mode 100644 index 0000000000..713a712bf4 --- /dev/null +++ b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/constant/Constants.kt @@ -0,0 +1,14 @@ +package net.mullvad.mullvadvpn.test.mockapi.constant + +const val LOG_TAG = "mullvad-mockapi" + +const val MOCK_SERVER_LOCALHOST_ADDRESS = "127.0.0.1" + +const val AUTH_TOKEN_URL_PATH = "/auth/v1/token" +const val DEVICES_URL_PATH = "/accounts/v1/devices" +const val ACCOUNT_URL_PATH = "/accounts/v1/accounts/me" + +const val DUMMY_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" +const val DUMMY_DEVICE_NAME = "mole mole" +const val DUMMY_ACCESS_TOKEN = + "mva_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" diff --git a/android/test/test-suppression.xml b/android/test/test-suppression.xml new file mode 100644 index 0000000000..2b57bc13e8 --- /dev/null +++ b/android/test/test-suppression.xml @@ -0,0 +1,95 @@ +<?xml version="1.0" encoding="UTF-8"?> +<suppressions xmlns="https://jeremylong.github.io/DependencyCheck/dependency-suppression.1.3.xsd"> + <!-- + CVEs in the e2e project are deemed less severe than CVEs in the main projects as CVEs in the e2e + project doesn't affect release or debug versions of the app. + --> + <suppress until="2023-05-01Z"> + <notes><![CDATA[ + This CVE is tracked externally and is therefore suppressed in the automatic audit checks. + ]]></notes> + <packageUrl regex="true">^pkg:maven/com\.google\.protobuf/protobuf\-java@.*$</packageUrl> + <cve>CVE-2022-3171</cve> + <cve>CVE-2022-3509</cve> + <cve>CVE-2022-3510</cve> + <cve>CVE-2021-22569</cve> + </suppress> + <suppress until="2023-05-01Z"> + <notes><![CDATA[ + These CVEs affects the Apache Commons Net's FTP client that this app doesn't use. + https://www.openwall.com/lists/oss-security/2022/12/03/1 + + File names: + - commons-beanutils-1.9.4.jar + - commons-collections-3.2.2.jar + - commons-digester-2.1.jar + - commons-logging-1.2.jar + - commons-validator-1.7.jar + ]]></notes> + <packageUrl regex="true">^pkg:maven/commons\-.*/commons\-.*@.*$</packageUrl> + <cve>CVE-2021-37533</cve> + </suppress> + <suppress until="2023-05-01Z"> + <notes><![CDATA[ + This CVE is tracked externally and is therefore suppressed in the automatic audit checks. + https://nvd.nist.gov/vuln/detail/CVE-2021-29425 + + File name: commons-io-2.4.jar + ]]></notes> + <packageUrl regex="true">^pkg:maven/commons\-io/commons\-io@.*$</packageUrl> + <cve>CVE-2021-29425</cve> + </suppress> + <suppress until="2023-05-01Z"> + <notes><![CDATA[ + These CVEs are tracked externally and is therefore suppressed in the automatic audit checks. + ]]></notes> + <packageUrl regex="true">^pkg:maven/io\.netty/netty\-.*@.*$</packageUrl> + <cve>CVE-2021-37136</cve> + <cve>CVE-2021-37137</cve> + <cve>CVE-2021-43797</cve> + <cve>CVE-2021-21295</cve> + <cve>CVE-2021-21409</cve> + <cve>CVE-2021-21290</cve> + <cve>CVE-2022-24823</cve> + <cve>CVE-2022-41881</cve> + <cve>CVE-2022-41915</cve> + </suppress> + <suppress until="2023-05-01Z"> + <notes><![CDATA[ + This CVE is tracked externally and is therefore suppressed in the automatic audit checks. + https://nvd.nist.gov/vuln/detail/CVE-2022-25647 + + File name: gson-2.8.6.jar + ]]></notes> + <packageUrl regex="true">^pkg:maven/com\.google\.code\.gson/gson@.*$</packageUrl> + <cve>CVE-2022-25647</cve> + </suppress> + <suppress until="2023-05-01Z"> + <notes><![CDATA[ + This CVE only affect Multiplatform Gradle Projects, which this project is not. + https://nvd.nist.gov/vuln/detail/CVE-2022-24329 + ]]></notes> + <packageUrl regex="true">^pkg:maven/org\.jetbrains\.kotlin/kotlin\-stdlib.*@.*$</packageUrl> + <cve>CVE-2022-24329</cve> + </suppress> + <suppress until="2023-06-01Z"> + <notes><![CDATA[ + This CVE is limited to processing of screenshots, which this app doesn't use. + https://nvd.nist.gov/vuln/detail/CVE-2021-4277 + + File name: legacy-support-core-utils-1.0.0.aar + ]]></notes> + <packageUrl regex="true">^pkg:maven/androidx\.legacy/legacy\-support\-core\-utils@.*$</packageUrl> + <cve>CVE-2021-4277</cve> + </suppress> + <suppress until="2023-06-01Z"> + <notes><![CDATA[ + This CVE is limited to processing of screenshots, which this app doesn't use. + https://nvd.nist.gov/vuln/detail/CVE-2021-4277 + + File name: common-30.3.1.jar + ]]></notes> + <packageUrl regex="true">^pkg:maven/com\.android\.tools/common@.*$</packageUrl> + <cve>CVE-2021-4277</cve> + </suppress> +</suppressions> |
