diff --git a/.github/actions/build-android/action.yml b/.github/actions/build-android/action.yml index a92c7efb..744839f3 100644 --- a/.github/actions/build-android/action.yml +++ b/.github/actions/build-android/action.yml @@ -49,8 +49,19 @@ runs: uses: actions/setup-go@v5 with: go-version-file: "netbird/go.mod" + cache-dependency-path: "netbird/go.sum" + + - name: Cache Android NDK + id: ndk-cache + uses: actions/cache@v4 + with: + # ANDROID_HOME is set by the runner image but not visible to ${{ env.X }} + # in composite actions; the ubuntu-latest image pins it to this path. + path: /usr/local/lib/android/sdk/ndk/23.1.7779620 + key: ndk-23.1.7779620 - name: Setup NDK + if: steps.ndk-cache.outputs.cache-hit != 'true' shell: bash run: ${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager --install "ndk;23.1.7779620" @@ -58,7 +69,15 @@ runs: shell: bash run: echo "ANDROID_NDK_HOME=${ANDROID_HOME}/ndk/23.1.7779620" >> $GITHUB_ENV + - name: Cache gomobile binary + id: gomobile-cache + uses: actions/cache@v4 + with: + path: ~/go/bin/gomobile + key: gomobile-v0.0.0-20251113184115-a159579294ab + - name: Install gomobile + if: steps.gomobile-cache.outputs.cache-hit != 'true' shell: bash run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab diff --git a/.github/workflows/build-debug.yml b/.github/workflows/build-debug.yml index 936762e4..515e6f66 100644 --- a/.github/workflows/build-debug.yml +++ b/.github/workflows/build-debug.yml @@ -5,6 +5,7 @@ on: push: branches: - main + workflow_dispatch: permissions: contents: read @@ -85,9 +86,14 @@ jobs: tool/build/reports/tests/ retention-days: 3 + # Classic instrumented tests: everything outside io.netbird.client.e2e. + # They need no secrets and no external infrastructure, so they can run on + # every PR, forks included. The e2e package (production API, setup keys) + # runs from the private mobile-e2e repo instead. instrumented-tests: needs: build-debug runs-on: ubuntu-latest + timeout-minutes: 30 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -113,6 +119,34 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm + - name: AVD cache + id: avd-cache + uses: actions/cache@v4 + with: + # ANDROID_HOME is set by the runner image but not visible to + # ${{ env.X }} at expression-eval time; hardcoded to the path on + # ubuntu-latest. + path: | + ~/.android/avd/* + ~/.android/adb* + /usr/local/lib/android/sdk/system-images/android-30/google_apis/x86_64 + key: avd-api30-google_apis-x86_64-pixel_3a-v1 + + - name: Create AVD snapshot + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 30 + target: google_apis + arch: x86_64 + profile: pixel_3a + disk-size: 4096M + heap-size: 512M + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim + disable-animations: true + script: echo "Generated AVD snapshot for caching." + - name: Run instrumented tests uses: reactivecircus/android-emulator-runner@v2 with: @@ -122,8 +156,10 @@ jobs: profile: pixel_3a disk-size: 4096M heap-size: 512M + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim disable-animations: true - script: ./gradlew connectedDebugAndroidTest --no-daemon -Pandroid.testInstrumentationRunnerArguments.notClass=io.netbird.client.NetworkConnectivityStressTest + script: ./gradlew --no-daemon connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.notPackage=io.netbird.client.e2e - name: Upload test results if: always() diff --git a/app/build.gradle.kts b/app/build.gradle.kts index febc2632..75fa55da 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -45,7 +45,18 @@ android { versionName = rootProject.extra["appVersionName"] as String testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - testInstrumentationRunnerArguments["timeout_msec"] = "3600000" + testInstrumentationRunnerArguments["timeout_msec"] = "300000" + + // Pass setup keys from the environment to the e2e tests (a -P arg still overrides). + mapOf( + "setupKey" to "INSTRUMENTATION_NB_SETUP_KEY", + "exitNodeSetupKey" to "INSTRUMENTATION_EXIT_NODE_SETUP_KEY" + ).forEach { (arg, envVar) -> + val value = System.getenv(envVar) + if (!value.isNullOrBlank()) { + testInstrumentationRunnerArguments[arg] = value + } + } } buildTypes { diff --git a/app/src/androidTest/README.md b/app/src/androidTest/README.md index 71c504a9..7c94b2ef 100644 --- a/app/src/androidTest/README.md +++ b/app/src/androidTest/README.md @@ -22,7 +22,7 @@ adb install -r -t app/build/outputs/apk/debug/app-debug.apk adb install -r -t app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk # 3. Run the test directly via adb -adb shell am instrument -w -e class io.netbird.client.NetworkConnectivityStressTest \ +adb shell am instrument -w -e class io.netbird.client.e2e.NetworkConnectivityStressTest \ io.netbird.client.test/androidx.test.runner.AndroidJUnitRunner ``` diff --git a/app/src/androidTest/java/io/netbird/client/e2e/DnsResolutionTest.java b/app/src/androidTest/java/io/netbird/client/e2e/DnsResolutionTest.java new file mode 100644 index 00000000..3f0236ca --- /dev/null +++ b/app/src/androidTest/java/io/netbird/client/e2e/DnsResolutionTest.java @@ -0,0 +1,128 @@ +package io.netbird.client.e2e; + +import io.netbird.client.MainActivity; + +import android.os.Bundle; +import android.util.Log; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * DNS resolution test — the Android port of the Robot + * {@code client-tests.robot} case "Should resolve the domain and hostname". + * + *

With the tunnel up, the {@code dnstest} peer's internal name must resolve + * to its private address {@code 172.20.3.158} through the NetBird DNS. The + * original runs {@code dig }; here we run a real {@code nslookup} on the + * device (via the shell, like the ping tests), so it exercises the device + * resolver / VpnService DNS exactly as a user's traffic would. + * + *

Mirrors the two original assertions: + *

+ * + *

The original additionally checks the main system resolver is a NetBird + * {@code 100.x} address via {@code /etc/resolv.conf} / {@code resolvectl}. That + * is Linux-specific and not portable to Android (no {@code resolv.conf} in the + * usual sense; the VpnService owns DNS), so it is omitted — the resolution + * results above already prove tunnel DNS is in effect. + * + *

Only the setup key is injected: + *

+ *   ./gradlew connectedDebugAndroidTest \
+ *     -Pandroid.testInstrumentationRunnerArguments.setupKey=<UUID>
+ * 
+ */ +@RunWith(AndroidJUnit4.class) +public class DnsResolutionTest { + + private static final String TAG = "NBDnsTest"; + + /** dnstest peer's internal hostname and its expected private address. */ + private static final String PEER_FQDN = "ip-172-20-3-158.eu-central-1.compute.internal"; + private static final String PEER_UNQUALIFIED = "ip-172-20-3-158"; + private static final String EXPECTED_IP = "172.20.3.158"; + + /** Matches the Robot suite's peer-connected window (3 min). */ + private static final long CONNECT_TIMEOUT_SEC = 20; + /** Time budget for DNS to start resolving once the engine is connected. */ + private static final long RESOLVE_TIMEOUT_SEC = 20; + private VpnTestHarness harness; + private String profileName; + + @Before + + public void skipIfPreviousFailed() { + + FailFast.skipIfAborted(); + + } + + + @After + public void tearDown() throws Exception { + if (profileName != null && harness != null) { + harness.disableTouchVisualization(); + LoginFlow.removeProfile(E2eAppRule.activity(), harness.device(), profileName); + } + } + + @Test + public void resolvesPeerNameThroughTunnel() throws Exception { + Bundle args = InstrumentationRegistry.getArguments(); + String setupKey = args.getString("setupKey"); + + assertNotNull("setupKey instrumentation argument is required", setupKey); + assertTrue("setupKey must not be blank", !setupKey.trim().isEmpty()); + + MainActivity activity = E2eAppRule.activity(); + assertNotNull("MainActivity must be available", activity); + harness = new VpnTestHarness(activity); + harness.enableTouchVisualization(); + + harness.grantVpnConsent(); + + profileName = LoginFlow.createAndSwitchToFreshProfile(activity, harness.device(), "dns"); + LoginFlow.loginWithSetupKey(activity, harness.device(), setupKey); + + boolean connected = harness.connectAndAwait(CONNECT_TIMEOUT_SEC); + if (!connected) { + LoginFlow.dumpScreenshot(harness.device(), "vpn-connect-timeout"); + } + assertTrue("VPN did not reach connected state within " + CONNECT_TIMEOUT_SEC + "s", + connected); + + // FQDN resolves to the peer's private address through the tunnel DNS. + boolean fqdnResolved = harness.waitForResolve(PEER_FQDN, EXPECTED_IP, RESOLVE_TIMEOUT_SEC); + if (!fqdnResolved) { + LoginFlow.dumpScreenshot(harness.device(), "dns-fqdn-unresolved"); + } + assertTrue(PEER_FQDN + " did not resolve to " + EXPECTED_IP + " within " + + RESOLVE_TIMEOUT_SEC + "s", fqdnResolved); + + // Search-domain: the unqualified name resolves to the same address. + boolean searchResolved = + harness.waitForResolve(PEER_UNQUALIFIED, EXPECTED_IP, RESOLVE_TIMEOUT_SEC); + if (!searchResolved) { + LoginFlow.dumpScreenshot(harness.device(), "dns-search-unresolved"); + } + assertTrue(PEER_UNQUALIFIED + " (search domain) did not resolve to " + EXPECTED_IP + + " within " + RESOLVE_TIMEOUT_SEC + "s", searchResolved); + + Log.i(TAG, "DNS resolution verified: " + PEER_FQDN + " and " + PEER_UNQUALIFIED + + " -> " + EXPECTED_IP); + } +} diff --git a/app/src/androidTest/java/io/netbird/client/e2e/E2eAppRule.java b/app/src/androidTest/java/io/netbird/client/e2e/E2eAppRule.java new file mode 100644 index 00000000..3c09cd2a --- /dev/null +++ b/app/src/androidTest/java/io/netbird/client/e2e/E2eAppRule.java @@ -0,0 +1,75 @@ +package io.netbird.client.e2e; + +import io.netbird.client.MainActivity; + +import android.app.Activity; +import android.app.Instrumentation; +import android.content.Intent; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry; +import androidx.test.runner.lifecycle.Stage; + +import java.util.Collection; + +/** + * Provides the single shared {@link MainActivity} for the e2e suite WITHOUT a + * JUnit rule: {@link #activity()} returns the currently-resumed MainActivity, + * launching one if none is up. This survives the test framework finishing + * activities between test classes (which made a suite-level ActivityTestRule / + * ActivityScenario hand back a destroyed activity). Because the launch reuses + * the existing task, the app is not torn down and recreated between cases. + */ +final class E2eAppRule { + + private static final long LAUNCH_TIMEOUT_MS = 10_000; + + private E2eAppRule() { + } + + /** The running MainActivity, launched on demand if not already resumed. */ + static MainActivity activity() { + MainActivity existing = resumedMainActivity(); + if (existing != null) { + return existing; + } + + Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + Intent intent = new Intent(instrumentation.getTargetContext(), MainActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + instrumentation.startActivitySync(intent); + instrumentation.waitForIdleSync(); + + long deadline = System.currentTimeMillis() + LAUNCH_TIMEOUT_MS; + MainActivity activity = resumedMainActivity(); + while (activity == null && System.currentTimeMillis() < deadline) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + activity = resumedMainActivity(); + } + if (activity == null) { + throw new IllegalStateException("MainActivity did not reach RESUMED within " + + (LAUNCH_TIMEOUT_MS / 1000) + "s"); + } + return activity; + } + + private static MainActivity resumedMainActivity() { + MainActivity[] found = new MainActivity[1]; + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + Collection resumed = ActivityLifecycleMonitorRegistry.getInstance() + .getActivitiesInStage(Stage.RESUMED); + for (Activity a : resumed) { + if (a instanceof MainActivity) { + found[0] = (MainActivity) a; + break; + } + } + }); + return found[0]; + } +} diff --git a/app/src/androidTest/java/io/netbird/client/e2e/E2eSuite.java b/app/src/androidTest/java/io/netbird/client/e2e/E2eSuite.java new file mode 100644 index 00000000..fdcd4384 --- /dev/null +++ b/app/src/androidTest/java/io/netbird/client/e2e/E2eSuite.java @@ -0,0 +1,42 @@ +package io.netbird.client.e2e; + +import io.netbird.client.MainActivity; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.uiautomator.UiDevice; + +import org.junit.BeforeClass; +import org.junit.runner.RunWith; +import org.junit.runners.Suite; + +/** + * Runs the on-device client e2e tests (the Android port of the Robot + * {@code client-tests.robot} suite). Tests obtain the shared {@link MainActivity} + * via {@link E2eAppRule#activity()}, which reuses the running activity (launching + * one only if none is up), so the app is not restarted between cases. + * + *

Run with: + *

+ *   -Pandroid.testInstrumentationRunnerArguments.class=io.netbird.client.e2e.E2eSuite
+ * 
+ */ +@RunWith(Suite.class) +@Suite.SuiteClasses({ + SetupKeyAuthTest.class, + PeerConnectivityTest.class, + PortAclTest.class, + DnsResolutionTest.class, + ExitNodeRouteTest.class, +}) +public class E2eSuite { + + /** + * Force relay is a global setting that defaults ON and would stop the + * relay-less peer connecting. Turn it off ONCE, before any test runs. + */ + @BeforeClass + public static void disableForceRelay() throws Exception { + UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()); + LoginFlow.setForceRelay(E2eAppRule.activity(), device, false); + } +} diff --git a/app/src/androidTest/java/io/netbird/client/e2e/ExitNodeRouteTest.java b/app/src/androidTest/java/io/netbird/client/e2e/ExitNodeRouteTest.java new file mode 100644 index 00000000..191cabed --- /dev/null +++ b/app/src/androidTest/java/io/netbird/client/e2e/ExitNodeRouteTest.java @@ -0,0 +1,116 @@ +package io.netbird.client.e2e; + +import io.netbird.client.MainActivity; + +import android.os.Bundle; +import android.util.Log; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Exit-node route test — the Android port of the Robot + * {@code client-tests.robot} case "Should use exit node route". + * + *

The client logs in with a setup key whose profile routes all traffic + * through an exit node (the Robot suite's {@code EXIT_NODE_TEST_SETUP_KEY}). + * With the tunnel up, a request to {@code https://api.ipify.org} must report + * the exit node's public IP ({@code 3.121.38.77}) rather than the device's own + * — proving egress goes through the exit node. This is a real HTTPS request + * over the tunnel, the equivalent of the Robot {@code GET https://api.ipify.org}. + * + *

Unlike the other cases this needs a separate setup key (the + * exit-node profile), injected as its own argument — mirroring the original's + * distinct {@code EXIT_NODE_TEST_SETUP_KEY}: + *

+ *   ./gradlew connectedDebugAndroidTest \
+ *     -Pandroid.testInstrumentationRunnerArguments.exitNodeSetupKey=<UUID>
+ * 
+ * + *

If {@code exitNodeSetupKey} is not provided the test fails fast on its own + * assertion, so the rest of the suite is unaffected. + */ +@RunWith(AndroidJUnit4.class) +public class ExitNodeRouteTest { + + private static final String TAG = "NBExitNodeTest"; + + /** Where to ask for our public egress IP. */ + private static final String EGRESS_CHECK_URL = "https://api.ipify.org"; + /** The exit node's public IP — egress must appear to come from here. */ + private static final String EXIT_NODE_PUBLIC_IP = "3.121.38.77"; + + /** Matches the Robot suite's peer-connected window (3 min). */ + private static final long CONNECT_TIMEOUT_SEC = 20; + /** Time budget for the exit-node route to take effect after connecting. */ + private static final long EGRESS_TIMEOUT_SEC = 20; + private VpnTestHarness harness; + private String profileName; + + @Before + + public void skipIfPreviousFailed() { + + FailFast.skipIfAborted(); + + } + + + @After + public void tearDown() throws Exception { + if (profileName != null && harness != null) { + harness.disableTouchVisualization(); + LoginFlow.removeProfile(E2eAppRule.activity(), harness.device(), profileName); + } + } + + @Test + public void egressGoesThroughExitNode() throws Exception { + Bundle args = InstrumentationRegistry.getArguments(); + // Separate key from the basic tests, like the Robot EXIT_NODE_TEST_SETUP_KEY. + String setupKey = args.getString("exitNodeSetupKey"); + + assertNotNull("exitNodeSetupKey instrumentation argument is required", setupKey); + assertTrue("exitNodeSetupKey must not be blank", !setupKey.trim().isEmpty()); + + MainActivity activity = E2eAppRule.activity(); + assertNotNull("MainActivity must be available", activity); + harness = new VpnTestHarness(activity); + harness.enableTouchVisualization(); + + harness.grantVpnConsent(); + + profileName = LoginFlow.createAndSwitchToFreshProfile(activity, harness.device(), "exit-node"); + LoginFlow.loginWithSetupKey(activity, harness.device(), setupKey); + + boolean connected = harness.connectAndAwait(CONNECT_TIMEOUT_SEC); + if (!connected) { + LoginFlow.dumpScreenshot(harness.device(), "vpn-connect-timeout"); + } + assertTrue("VPN did not reach connected state within " + CONNECT_TIMEOUT_SEC + "s", + connected); + + // Best-effort: log the route table for debugging (the egress IP below + // is the real assertion). Mirrors the Robot route-table dump. + Log.i(TAG, "ip route:\n" + harness.shell("ip route show table all")); + + // The real check: our public egress IP must be the exit node's. + boolean viaExitNode = harness.waitForHttpBodyContains( + EGRESS_CHECK_URL, EXIT_NODE_PUBLIC_IP, EGRESS_TIMEOUT_SEC); + if (!viaExitNode) { + LoginFlow.dumpScreenshot(harness.device(), "exit-node-egress-mismatch"); + } + assertTrue("Egress IP from " + EGRESS_CHECK_URL + " was not the exit node's " + + EXIT_NODE_PUBLIC_IP + " within " + EGRESS_TIMEOUT_SEC + "s", viaExitNode); + + Log.i(TAG, "Egress verified through exit node " + EXIT_NODE_PUBLIC_IP); + } +} diff --git a/app/src/androidTest/java/io/netbird/client/e2e/FailFast.java b/app/src/androidTest/java/io/netbird/client/e2e/FailFast.java new file mode 100644 index 00000000..7980fa13 --- /dev/null +++ b/app/src/androidTest/java/io/netbird/client/e2e/FailFast.java @@ -0,0 +1,32 @@ +package io.netbird.client.e2e; + +import androidx.test.internal.runner.listener.InstrumentationRunListener; + +import org.junit.AssumptionViolatedException; +import org.junit.runner.Description; +import org.junit.runner.notification.Failure; + +/** + * Aborts the run after the first failure. The Gradle UTP layer ignores the + * {@code failFast} runner argument, so we do it inside the test process: this + * {@link org.junit.runner.notification.RunListener} (registered via the runner + * {@code listener} argument) flips a flag on the first failure, and + * {@link #skipIfAborted()} — called from each test's {@code @Before} — turns + * every subsequent test into a skipped (assumption-failed) result. + */ +public final class FailFast extends InstrumentationRunListener { + + private static volatile boolean aborted = false; + + @Override + public void testFailure(Failure failure) { + aborted = true; + } + + /** Skip (not fail) the current test if an earlier test already failed. */ + static void skipIfAborted() { + if (aborted) { + throw new AssumptionViolatedException("Skipped: a previous test already failed (fail-fast)"); + } + } +} diff --git a/app/src/androidTest/java/io/netbird/client/e2e/LoginFlow.java b/app/src/androidTest/java/io/netbird/client/e2e/LoginFlow.java new file mode 100644 index 00000000..d9823576 --- /dev/null +++ b/app/src/androidTest/java/io/netbird/client/e2e/LoginFlow.java @@ -0,0 +1,363 @@ +package io.netbird.client.e2e; + +import io.netbird.client.MainActivity; +import io.netbird.client.R; + +import android.os.Bundle; +import android.util.Log; +import android.view.View; + +import java.io.File; +import java.util.List; +import java.util.Random; + +import androidx.navigation.NavController; +import androidx.navigation.NavOptions; +import androidx.navigation.Navigation; +import androidx.test.uiautomator.By; +import androidx.test.uiautomator.BySelector; +import androidx.test.uiautomator.UiDevice; +import androidx.test.uiautomator.UiObject2; +import androidx.test.uiautomator.UiScrollable; +import androidx.test.uiautomator.UiSelector; +import androidx.test.uiautomator.Until; + +import io.netbird.client.ui.server.ChangeServerFragment; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; + +/** + * Shared "log in with a setup key via the UI" flow, extracted so both + * {@link SetupKeyAuthTest} (login smoke test) and {@link PeerConnectivityTest} + * (doc test case 1 — connectivity to a peer) drive the exact same screens. + * + *

This mirrors what a user does by hand: open "Change server", type the + * setup key, submit, and dismiss the success dialog. It targets the management + * URL hard-coded in the app ({@code Preferences.defaultServer()}, the + * production server) via the "Use NetBird" button. + * + *

The setup key comes from the {@code setupKey} instrumentation argument so + * CI can inject it as a secret: + *

+ *   ./gradlew connectedDebugAndroidTest \
+ *     -Pandroid.testInstrumentationRunnerArguments.setupKey=<UUID>
+ * 
+ */ +final class LoginFlow { + + private static final String TAG = "NBLoginFlow"; + static final String PACKAGE = "io.netbird.client"; + private static final String DEFAULT_PROFILE = "default"; + + private static final long UI_TIMEOUT_MS = 5_000; + private static final long LOGIN_TIMEOUT_MS = 15_000; + + private LoginFlow() { + } + + /** + * Drives the "Change server" UI to log in with {@code setupKey} against the + * app's default (production) management server, then dismisses the success + * dialog so the caller lands back on the Home screen. + * + * @param activity the running MainActivity (from the test's ActivityTestRule) + * @param device the shared UiDevice + * @param setupKey the NetBird setup key (already trimmed/validated by caller) + * @throws Exception if any expected view never appears or login does not + * complete within {@link #LOGIN_TIMEOUT_MS} + */ + static void loginWithSetupKey(MainActivity activity, UiDevice device, String setupKey) + throws Exception { + device.waitForIdle(); + + // Try the navigation up to 2 times — on first launch the MainActivity + // pushes firstInstallFragment after onCreate, which can race with our + // navigate() call from the test thread. + UiObject2 setupKeyLabel = null; + for (int attempt = 1; attempt <= 2 && setupKeyLabel == null; attempt++) { + Log.i(TAG, "navigateToChangeServer attempt " + attempt); + navigateToChangeServer(activity); + dismissConfirmChangeServerDialog(device); + setupKeyLabel = device.wait( + Until.findObject(By.res(PACKAGE, "text_setup_key_label")), UI_TIMEOUT_MS); + } + if (setupKeyLabel == null) { + dumpScreenshot(device, "navigation-failed"); + fail("text_setup_key_label not found after 2 navigation attempts"); + } + setupKeyLabel.click(); + + UiObject2 setupKeyField = device.wait( + Until.findObject(By.res(PACKAGE, "edit_text_setup_key")), UI_TIMEOUT_MS); + assertNotNull("edit_text_setup_key must be present", setupKeyField); + setupKeyField.setText(setupKey.trim()); + + // "Use NetBird" submits with the app's default management URL. + UiObject2 submit = device.wait( + Until.findObject(By.res(PACKAGE, "btn_use_netbird")), UI_TIMEOUT_MS); + assertNotNull("btn_use_netbird must be present", submit); + submit.click(); + + // Either the success dialog ("btn_close") shows up, or the form + // re-enables itself with an error. + long deadline = System.currentTimeMillis() + LOGIN_TIMEOUT_MS; + while (System.currentTimeMillis() < deadline) { + UiObject2 closeBtn = device.findObject(By.res(PACKAGE, "btn_close")); + if (closeBtn != null) { + Log.i(TAG, "Setup-key login succeeded"); + closeBtn.click(); + device.waitForIdle(); + return; + } + // If the "Change server" button is enabled again, the request came + // back with an error. + UiObject2 submitAgain = device.findObject(By.res(PACKAGE, "btn_change_server")); + if (submitAgain != null && submitAgain.isEnabled()) { + fail("Login failed: submit button re-enabled without success dialog"); + } + Thread.sleep(200); + } + dumpScreenshot(device, "login-timeout"); + fail("Login did not complete within " + (LOGIN_TIMEOUT_MS / 1000) + "s"); + } + + /** + * Skip the first-install teaser and jump straight to the "Change server" + * screen. {@code hideAlert=true} suppresses the "are you sure?" warning + * dialog so this is non-interactive. + */ + private static void navigateToChangeServer(MainActivity activity) throws InterruptedException { + assertNotNull("MainActivity must be available", activity); + + activity.runOnUiThread(() -> { + NavController nav = Navigation.findNavController(activity, R.id.nav_host_fragment_content_main); + Bundle bundle = new Bundle(); + bundle.putBoolean(ChangeServerFragment.HideAlertBundleArg, true); + // Same nav options the FirstInstallFragment uses when the user taps + // its "change_server" link, so we land in the same place. + NavOptions opts = new NavOptions.Builder() + .setPopUpTo(R.id.firstInstallFragment, true) + .build(); + nav.navigate(R.id.nav_change_server, bundle, opts); + }); + // Let the fragment transaction commit before UiAutomator looks for views. + Thread.sleep(1500); + } + + /** + * The Change Server fragment shows a "this will erase the local config" + * confirmation dialog whenever it opens — even when the caller passed + * {@code hideAlert=true}, because that arg is not currently honoured by the + * fragment. Tap Yes to dismiss so we can interact with the form. + */ + private static void dismissConfirmChangeServerDialog(UiDevice device) { + UiObject2 yes = device.wait( + Until.findObject(By.res(PACKAGE, "btn_yes")), UI_TIMEOUT_MS); + if (yes != null) { + Log.i(TAG, "Dismissing change-server confirmation dialog"); + yes.click(); + device.waitForIdle(); + } + } + + /** + * Create a fresh, isolated profile through the Profiles UI and switch to + * it, mirroring the Robot suite's {@code netbird profile add test-} + * step (which it runs from the CLI). Driving it through the UI keeps the + * test on the same user-facing path the rest of the flow uses. + * + * @param scenario short label for the calling test, woven into the profile + * name so it is identifiable in the UI / on the recording + * @return the generated profile name (pass it to {@link #removeProfile} to + * clean up afterwards) + */ + static String createAndSwitchToFreshProfile(MainActivity activity, UiDevice device, String scenario) + throws Exception { + String profileName = "e2e-" + sanitizeScenario(scenario) + "-" + randomLowercase(4); + + navigateTo(activity, R.id.nav_profiles); + + UiObject2 addBtn = device.wait( + Until.findObject(By.res(PACKAGE, "btn_add_profile")), UI_TIMEOUT_MS); + assertNotNull("btn_add_profile must be present", addBtn); + addBtn.click(); + + UiObject2 nameField = device.wait( + Until.findObject(By.res(PACKAGE, "edit_text_dialog")), UI_TIMEOUT_MS); + assertNotNull("edit_text_dialog must be present", nameField); + nameField.setText(profileName); + confirmDialog(device); + + // The new profile is added but not active yet — switch to it so the + // subsequent login writes into this profile. + UiObject2 switchBtn = rowAction(device, profileName, "btn_switch"); + if (switchBtn == null) { + dumpScreenshot(device, "profile-switch-missing"); + fail("btn_switch not found for profile " + profileName); + } + switchBtn.click(); + confirmDialog(device); + device.waitForIdle(); + + Log.i(TAG, "Created and switched to profile " + profileName); + return profileName; + } + + /** + * Remove a profile created by {@link #createAndSwitchToFreshProfile}. A + * profile can't be removed while active, so we switch to the built-in + * "default" profile first, then remove the test profile. We assert on the + * profile UI: a missing switch/remove button fails the test — it's a real + * UI defect, not something to swallow. + */ + static void removeProfile(MainActivity activity, UiDevice device, String profileName) + throws InterruptedException { + navigateTo(activity, R.id.nav_profiles); + + UiObject2 switchBtn = rowAction(device, DEFAULT_PROFILE, "btn_switch"); + if (switchBtn == null) { + dumpScreenshot(device, "default-switch-missing"); + fail("btn_switch not found for the " + DEFAULT_PROFILE + " profile"); + } + switchBtn.click(); + confirmDialog(device); + device.waitForIdle(); + + // Switching profiles bounces back to Home, so return to the profiles + // screen before looking for the test profile's remove button. + navigateTo(activity, R.id.nav_profiles); + + UiObject2 removeBtn = rowAction(device, profileName, "btn_remove"); + if (removeBtn == null) { + dumpScreenshot(device, "remove-button-missing"); + fail("btn_remove not found for profile " + profileName); + } + removeBtn.click(); + confirmDialog(device); + device.waitForIdle(); + Log.i(TAG, "Removed profile " + profileName); + } + + /** + * Set the "Force relay connection" toggle (a global setting) to {@code + * enabled} via the Advanced screen. Each test sets the value it needs rather + * than relying on the previous test's state, so the toggle is reset between + * tests. Only clicks when the current state differs. + */ + static void setForceRelay(MainActivity activity, UiDevice device, boolean enabled) + throws InterruptedException { + navigateTo(activity, R.id.nav_advanced); + + // The switch is far down the scrollable Advanced screen — scroll to it. + scrollTo(device, "Force relay connection"); + + UiObject2 toggle = rowControl(device, "Force relay connection", "switch_control"); + if (toggle == null) { + dumpScreenshot(device, "force-relay-switch-missing"); + fail("Force relay connection switch not found in Advanced screen"); + } + if (toggle.isChecked() != enabled) { + toggle.click(); + // Toggling pops a "reconnection needed" warning; dismiss it. + confirmDialog(device); + Log.i(TAG, "Set force relay connection to " + enabled); + } else { + Log.i(TAG, "Force relay connection already " + enabled); + } + } + + /** + * Find the action button with {@code resId} inside the profile row whose + * {@code text_profile_name} matches {@code profileName}. Returns null if no + * such row/button is visible. + */ + private static UiObject2 rowAction(UiDevice device, String profileName, String resId) { + return rowControl(device, "text_profile_name", profileName, resId, true); + } + + /** Like {@link #rowAction} but the row is matched by visible text on any label. */ + private static UiObject2 rowControl(UiDevice device, String rowText, String resId) { + return rowControl(device, null, rowText, resId, false); + } + + private static UiObject2 rowControl(UiDevice device, String labelResId, String text, + String resId, boolean labelByRes) { + BySelector labelSel = labelByRes + ? By.res(PACKAGE, labelResId).text(text) + : By.text(text); + UiObject2 label = device.wait(Until.findObject(labelSel), UI_TIMEOUT_MS); + if (label == null) { + return null; + } + // The control lives in the same row; walk up and search within it. + UiObject2 row = label.getParent(); + BySelector sel = By.res(PACKAGE, resId); + for (int i = 0; i < 5 && row != null; i++) { + UiObject2 ctrl = row.findObject(sel); + if (ctrl != null) { + return ctrl; + } + row = row.getParent(); + } + return null; + } + + /** Scroll a scrollable screen until an element with {@code text} is visible. */ + private static void scrollTo(UiDevice device, String text) { + try { + UiScrollable scrollable = new UiScrollable(new UiSelector().scrollable(true)); + scrollable.scrollTextIntoView(text); + } catch (Exception e) { + Log.w(TAG, "scrollTo(" + text + ") failed: " + e.getMessage()); + } + device.waitForIdle(); + } + + private static void confirmDialog(UiDevice device) { + UiObject2 ok = device.wait( + Until.findObject(By.res(PACKAGE, "btn_ok_dialog")), UI_TIMEOUT_MS); + assertNotNull("btn_ok_dialog must be present", ok); + ok.click(); + device.waitForIdle(); + } + + private static void navigateTo(MainActivity activity, int destId) throws InterruptedException { + assertNotNull("MainActivity must be available", activity); + activity.runOnUiThread(() -> { + NavController nav = Navigation.findNavController(activity, R.id.nav_host_fragment_content_main); + nav.navigate(destId); + }); + Thread.sleep(1000); + } + + private static String sanitizeScenario(String scenario) { + String s = scenario.toLowerCase().replaceAll("[^a-z0-9-]+", "-").replaceAll("(^-+|-+$)", ""); + return s.isEmpty() ? "test" : s; + } + + private static String randomLowercase(int len) { + Random random = new Random(); + StringBuilder sb = new StringBuilder(len); + for (int i = 0; i < len; i++) { + sb.append((char) ('a' + random.nextInt(26))); + } + return sb.toString(); + } + + /** + * Take a screenshot via UiAutomator and write it under /sdcard/Pictures so + * the CI workflow can {@code adb pull} it alongside the screen recording. + */ + static void dumpScreenshot(UiDevice device, String name) { + try { + File png = new File("/sdcard/Pictures/" + name + ".png"); + //noinspection ResultOfMethodCallIgnored + png.getParentFile().mkdirs(); + boolean ok = device.takeScreenshot(png); + Log.i(TAG, "Screenshot " + (ok ? "saved to " : "FAILED for ") + png); + } catch (Throwable t) { + Log.w(TAG, "Failed to dump screenshot: " + t.getMessage()); + } + } +} diff --git a/app/src/androidTest/java/io/netbird/client/NetworkConnectivityStressTest.java b/app/src/androidTest/java/io/netbird/client/e2e/NetworkConnectivityStressTest.java similarity index 99% rename from app/src/androidTest/java/io/netbird/client/NetworkConnectivityStressTest.java rename to app/src/androidTest/java/io/netbird/client/e2e/NetworkConnectivityStressTest.java index a7b1931b..3ba6514b 100644 --- a/app/src/androidTest/java/io/netbird/client/NetworkConnectivityStressTest.java +++ b/app/src/androidTest/java/io/netbird/client/e2e/NetworkConnectivityStressTest.java @@ -1,4 +1,8 @@ -package io.netbird.client; +package io.netbird.client.e2e; + +import io.netbird.client.MainActivity; +import io.netbird.client.StateListener; +import io.netbird.client.StateListenerAdapter; import android.app.Instrumentation; import android.app.UiAutomation; @@ -60,7 +64,7 @@ *

Run with

*
  * ./gradlew connectedAndroidTest \
- *   -Pandroid.testInstrumentationRunnerArguments.class=io.netbird.client.NetworkConnectivityStressTest
+ *   -Pandroid.testInstrumentationRunnerArguments.class=io.netbird.client.e2e.NetworkConnectivityStressTest
  * 
*/ @RunWith(AndroidJUnit4.class) diff --git a/app/src/androidTest/java/io/netbird/client/e2e/PeerConnectivityTest.java b/app/src/androidTest/java/io/netbird/client/e2e/PeerConnectivityTest.java new file mode 100644 index 00000000..11cd3f96 --- /dev/null +++ b/app/src/androidTest/java/io/netbird/client/e2e/PeerConnectivityTest.java @@ -0,0 +1,140 @@ +package io.netbird.client.e2e; + +import io.netbird.client.MainActivity; + +import android.os.Bundle; +import android.util.Log; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * End-to-end connectivity test — the Android port of the Robot + * {@code client-tests.robot} "Should be able to connect to peer" cases. + * + *

Mirrors the Robot {@code Try Peer Connectivity} keyword. It creates a + * fresh, isolated profile (via the Profiles UI — the Android equivalent of + * {@code netbird profile add test-}), logs in to the production + * management server with a setup key (shared UI flow in {@link LoginFlow}), + * brings the VPN up, then verifies the data plane by pinging a remote peer's + * FQDN through the tunnel. The NetBird tunnel DNS resolves the name to + * the peer's overlay IP. The remote peers are live, externally-running NetBird + * containers; this test does not create or tear them down. The profile is + * removed in teardown. + * + *

Two cases, exactly as in the Robot suite (peer FQDNs hard-coded as in the + * original): + *

+ * + *

Only the setup key is injected: + *

+ * + *
+ *   ./gradlew connectedDebugAndroidTest \
+ *     -Pandroid.testInstrumentationRunnerArguments.setupKey=<UUID>
+ * 
+ * + *

Timeouts follow the Robot {@code Wait For Peer Ready} keyword: up to + * ~3 minutes for the peer to come up and the tunnel to carry traffic. + */ +@RunWith(AndroidJUnit4.class) +public class PeerConnectivityTest { + + private static final String TAG = "NBPeerConnTest"; + + /** Peer reachable with relay support (Robot "with relay support" case). */ + private static final String PEER_FQDN_RELAY = "pingtest.netbird.cloud"; + /** Peer reachable without relay (Robot "without relay support" case). */ + private static final String PEER_FQDN_NO_RELAY = "pingtest-pre-relay.netbird.cloud"; + + /** Matches the Robot suite's peer-connected window (3 min). */ + private static final long CONNECT_TIMEOUT_SEC = 20; + /** How long to keep retrying the ping once the engine reports connected. */ + private static final long PING_TIMEOUT_SEC = 20; + + private VpnTestHarness harness; + private String profileName; + + @Before + public void skipIfPreviousFailed() { + FailFast.skipIfAborted(); + } + + @After + public void tearDown() throws Exception { + if (profileName != null && harness != null) { + harness.disableTouchVisualization(); + LoginFlow.removeProfile(E2eAppRule.activity(), harness.device(), profileName); + } + } + + @Test + public void connectsWithRelay() throws Exception { + connectAndPing(PEER_FQDN_RELAY, "relay"); + } + + @Test + public void connectsWithoutRelay() throws Exception { + connectAndPing(PEER_FQDN_NO_RELAY, "no-relay"); + } + + /** + * Shared body: fresh profile → login → connect → ping {@code peerFqdn} + * through the tunnel. This is the Android equivalent of the Robot + * {@code Try Peer Connectivity ${peer_name}} keyword. Force-relay is + * disabled once for the whole suite (see {@link E2eSuite}). + */ + private void connectAndPing(String peerFqdn, String scenario) throws Exception { + Bundle args = InstrumentationRegistry.getArguments(); + String setupKey = args.getString("setupKey"); + + assertNotNull("setupKey instrumentation argument is required", setupKey); + assertTrue("setupKey must not be blank", !setupKey.trim().isEmpty()); + + MainActivity activity = E2eAppRule.activity(); + harness = new VpnTestHarness(activity); + harness.enableTouchVisualization(); + + harness.grantVpnConsent(); + + // 1. Create a fresh, isolated profile (Android equivalent of the Robot + // suite's `netbird profile add test-`), then log in into it. + profileName = LoginFlow.createAndSwitchToFreshProfile(activity, harness.device(), scenario); + LoginFlow.loginWithSetupKey(activity, harness.device(), setupKey); + + // 2. Bring the VPN up and wait for the engine to report connected. + boolean connected = harness.connectAndAwait(CONNECT_TIMEOUT_SEC); + if (!connected) { + LoginFlow.dumpScreenshot(harness.device(), "vpn-connect-timeout"); + } + assertTrue("VPN did not reach connected state within " + CONNECT_TIMEOUT_SEC + "s", + connected); + + // 3. Verify the data plane: the remote peer must be reachable over the + // tunnel by its FQDN (the tunnel DNS resolves it to the overlay IP). + boolean reachable = harness.waitForPing(peerFqdn, PING_TIMEOUT_SEC); + if (!reachable) { + LoginFlow.dumpScreenshot(harness.device(), "peer-ping-failed"); + } + assertTrue("Peer " + peerFqdn + " was not reachable over the tunnel within " + + PING_TIMEOUT_SEC + "s", reachable); + + Log.i(TAG, "Peer " + peerFqdn + " reachable over the NetBird tunnel"); + } +} diff --git a/app/src/androidTest/java/io/netbird/client/e2e/PortAclTest.java b/app/src/androidTest/java/io/netbird/client/e2e/PortAclTest.java new file mode 100644 index 00000000..3d486ab3 --- /dev/null +++ b/app/src/androidTest/java/io/netbird/client/e2e/PortAclTest.java @@ -0,0 +1,165 @@ +package io.netbird.client.e2e; + +import io.netbird.client.MainActivity; + +import android.os.Bundle; +import android.util.Log; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * ACL / allowed-ports test — the Android port of the Robot + * {@code client-tests.robot} case "Should connect only to the allowed ports". + * + *

The {@code acltest.netbird.cloud} peer has an ACL that blocks ICMP + * but allows TCP port 80. So, with the tunnel up, the original asserts: + *

+ * + *

This is the proof the ACL is actually enforced: the peer is reachable + * (control plane connected, port 80 open) yet ICMP is filtered. + * + *

Reuses the same fresh-profile + login + connect flow as + * {@link PeerConnectivityTest}. Only the setup key is injected: + *

+ *   ./gradlew connectedDebugAndroidTest \
+ *     -Pandroid.testInstrumentationRunnerArguments.setupKey=<UUID>
+ * 
+ */ +@RunWith(AndroidJUnit4.class) +public class PortAclTest { + + private static final String TAG = "NBPortAclTest"; + + /** Peer with an ICMP-blocking, port-80-allowing ACL. */ + private static final String PEER_FQDN = "acltest.netbird.cloud"; + private static final int ALLOWED_PORT = 80; + + /** Matches the Robot suite's peer-connected window (3 min). */ + private static final long CONNECT_TIMEOUT_SEC = 20; + /** + * Time budget for the allowed port to become reachable once the engine is + * connected (peer + ACL need a moment to settle). Loosely mirrors the + * Robot peer-ready/handshake windows. + */ + private static final long PORT_TIMEOUT_SEC = 20; + private static final int TCP_CONNECT_TIMEOUT_MS = 3000; + /** + * How long to keep trying ICMP before concluding it is blocked. The ACL + * drops it, so this should always time out — kept short so a genuinely + * blocked peer does not slow the test much, but long enough that a slow + * first packet is not mistaken for a block (the port check above already + * proved the peer is reachable, so a few seconds is plenty). + */ + private static final long PING_BLOCKED_PROBE_SEC = 10; + private VpnTestHarness harness; + private String profileName; + + @Before + + public void skipIfPreviousFailed() { + + FailFast.skipIfAborted(); + + } + + + @After + public void tearDown() throws Exception { + if (profileName != null && harness != null) { + harness.disableTouchVisualization(); + LoginFlow.removeProfile(E2eAppRule.activity(), harness.device(), profileName); + } + } + + @Test + public void connectsOnlyToAllowedPorts() throws Exception { + Bundle args = InstrumentationRegistry.getArguments(); + String setupKey = args.getString("setupKey"); + + assertNotNull("setupKey instrumentation argument is required", setupKey); + assertTrue("setupKey must not be blank", !setupKey.trim().isEmpty()); + + MainActivity activity = E2eAppRule.activity(); + assertNotNull("MainActivity must be available", activity); + harness = new VpnTestHarness(activity); + harness.enableTouchVisualization(); + + harness.grantVpnConsent(); + + // Fresh profile + login, like the Robot suite's per-test InitNetBird. + profileName = LoginFlow.createAndSwitchToFreshProfile(activity, harness.device(), "port-acl"); + LoginFlow.loginWithSetupKey(activity, harness.device(), setupKey); + + boolean connected = harness.connectAndAwait(CONNECT_TIMEOUT_SEC); + if (!connected) { + LoginFlow.dumpScreenshot(harness.device(), "vpn-connect-timeout"); + } + assertTrue("VPN did not reach connected state within " + CONNECT_TIMEOUT_SEC + "s", + connected); + + // Allowed port: TCP 80 must be reachable. This also serves as the + // "peer is ready" signal (the Robot suite uses ping for that, but here + // ping is blocked, so we rely on the allowed port instead). + boolean portOpen = waitForTcp(PEER_FQDN, ALLOWED_PORT, PORT_TIMEOUT_SEC); + if (!portOpen) { + LoginFlow.dumpScreenshot(harness.device(), "acl-port80-unreachable"); + } + assertTrue("Allowed port " + ALLOWED_PORT + " on " + PEER_FQDN + + " was not reachable within " + PORT_TIMEOUT_SEC + "s", portOpen); + + // Blocked protocol: ICMP must NOT get through (ACL drops it). The peer + // is provably reachable (port 80 just connected), so any ping success + // here would mean the ACL is not being enforced. + boolean pingGotThrough = pingSucceedsWithin(PEER_FQDN, PING_BLOCKED_PROBE_SEC); + if (pingGotThrough) { + LoginFlow.dumpScreenshot(harness.device(), "acl-icmp-leaked"); + } + assertFalse("ICMP to " + PEER_FQDN + " should be blocked by the ACL, but ping succeeded", + pingGotThrough); + + Log.i(TAG, "ACL enforced: port " + ALLOWED_PORT + " open, ICMP blocked on " + PEER_FQDN); + } + + /** Retry a TCP connect until it succeeds or the timeout elapses. */ + private boolean waitForTcp(String host, int port, long timeoutSec) throws InterruptedException { + long deadline = System.currentTimeMillis() + (timeoutSec * 1000L); + while (System.currentTimeMillis() < deadline) { + if (harness.tcpConnects(host, port, TCP_CONNECT_TIMEOUT_MS)) { + return true; + } + Thread.sleep(3000); + } + return false; + } + + /** + * Probe ICMP for up to {@code timeoutSec}; returns true as soon as any ping + * gets through. Used to assert ICMP is blocked, so a true result is a + * failure for the caller. + */ + private boolean pingSucceedsWithin(String host, long timeoutSec) throws InterruptedException { + long deadline = System.currentTimeMillis() + (timeoutSec * 1000L); + while (System.currentTimeMillis() < deadline) { + if (harness.pingOnce(host)) { + return true; + } + Thread.sleep(2000); + } + return false; + } +} diff --git a/app/src/androidTest/java/io/netbird/client/e2e/SetupKeyAuthTest.java b/app/src/androidTest/java/io/netbird/client/e2e/SetupKeyAuthTest.java new file mode 100644 index 00000000..7ab55dea --- /dev/null +++ b/app/src/androidTest/java/io/netbird/client/e2e/SetupKeyAuthTest.java @@ -0,0 +1,51 @@ +package io.netbird.client.e2e; + +import android.os.Bundle; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.uiautomator.UiDevice; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Login smoke test: drives the "Change server" UI to authenticate against the + * default (production) NetBird management server with a setup key — exactly the + * flow a user would use, but automated. + * + *

This test stops at a successful login (the success dialog). For the + * end-to-end "can actually reach a peer" check (doc test case 1) see + * {@link PeerConnectivityTest}. The shared login steps live in + * {@link LoginFlow}. + * + *

The setup key is read from an instrumentation runner argument so CI can + * inject it as a secret without baking it into the APK: + *

+ *   ./gradlew connectedDebugAndroidTest \
+ *     -Pandroid.testInstrumentationRunnerArguments.setupKey=<UUID>
+ * 
+ */ +@RunWith(AndroidJUnit4.class) +public class SetupKeyAuthTest { + @Before + public void skipIfPreviousFailed() { + FailFast.skipIfAborted(); + } + + @Test + public void loginWithSetupKeyViaUi() throws Exception { + Bundle args = InstrumentationRegistry.getArguments(); + String setupKey = args.getString("setupKey"); + + assertNotNull("setupKey instrumentation argument is required", setupKey); + assertTrue("setupKey must not be blank", !setupKey.trim().isEmpty()); + + UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()); + LoginFlow.loginWithSetupKey(E2eAppRule.activity(), device, setupKey); + } +} diff --git a/app/src/androidTest/java/io/netbird/client/e2e/VpnTestHarness.java b/app/src/androidTest/java/io/netbird/client/e2e/VpnTestHarness.java new file mode 100644 index 00000000..77732a42 --- /dev/null +++ b/app/src/androidTest/java/io/netbird/client/e2e/VpnTestHarness.java @@ -0,0 +1,279 @@ +package io.netbird.client.e2e; + +import io.netbird.client.MainActivity; +import io.netbird.client.StateListener; + +import android.app.Instrumentation; +import android.app.UiAutomation; +import android.os.ParcelFileDescriptor; +import android.util.Log; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.uiautomator.UiDevice; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.URL; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Shared machinery for the on-device client e2e tests (the Android port of the + * Robot {@code client-tests.robot} suite). Each test creates one of these from + * its {@link MainActivity}, then composes the building blocks it needs: + * + * + * + *

Connecting goes through {@link MainActivity#switchConnection(boolean)} and + * a {@link StateListener}, not the Lottie connect button, so it is robust to UI + * timing. Network probes run as shell commands / plain sockets so they observe + * the tunnel exactly as a user's traffic would. + */ +final class VpnTestHarness { + + private static final String TAG = "NBVpnHarness"; + + private final MainActivity activity; + private final UiDevice device; + private final UiAutomation uiAutomation; + + VpnTestHarness(MainActivity activity) { + Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + this.activity = activity; + this.device = UiDevice.getInstance(instrumentation); + this.uiAutomation = instrumentation.getUiAutomation(); + } + + UiDevice device() { + return device; + } + + /** Show touch feedback on screen so recordings reveal what the test taps. */ + void enableTouchVisualization() { + shell("settings put system show_touches 1"); + shell("settings put system pointer_location 1"); + } + + void disableTouchVisualization() { + shell("settings put system show_touches 0"); + shell("settings put system pointer_location 0"); + } + + /** + * Pre-grant the VPN consent (ACTIVATE_VPN appop) for this package so the + * VpnService starts without a system dialog. Best-effort: if the command is + * unavailable the test still works as long as consent was granted before. + */ + void grantVpnConsent() { + String out = shell("appops set " + LoginFlow.PACKAGE + " ACTIVATE_VPN allow"); + Log.i(TAG, "appops ACTIVATE_VPN -> " + out.trim()); + } + + /** + * Start the engine via the app's own API and block until a state listener + * reports {@code onConnected}, or the timeout elapses. + * + * @return true if the engine reported connected within the timeout + */ + boolean connectAndAwait(long timeoutSec) throws InterruptedException { + CountDownLatch connectedLatch = new CountDownLatch(1); + + StateListener listener = new StateListener() { + @Override public void onEngineStarted() {} + @Override public void onEngineStopped() {} + @Override public void onAddressChanged(String fqdn, String ip) {} + @Override public void onConnected() { + Log.i(TAG, "Engine reported connected"); + connectedLatch.countDown(); + } + @Override public void onConnecting() {} + @Override public void onDisconnected() {} + @Override public void onDisconnecting() {} + @Override public void onPeersListChanged(long count) { + Log.i(TAG, "Peers list changed: " + count); + } + }; + + activity.runOnUiThread(() -> activity.registerServiceStateListener(listener)); + try { + activity.runOnUiThread(() -> activity.switchConnection(true)); + return connectedLatch.await(timeoutSec, TimeUnit.SECONDS); + } finally { + activity.runOnUiThread(() -> activity.unregisterServiceStateListener(listener)); + } + } + + /** Retry {@link #pingOnce(String)} until it succeeds or the timeout elapses. */ + boolean waitForPing(String target, long timeoutSec) throws InterruptedException { + long deadline = System.currentTimeMillis() + (timeoutSec * 1000L); + int attempt = 0; + while (System.currentTimeMillis() < deadline) { + attempt++; + if (pingOnce(target)) { + Log.i(TAG, "Ping to " + target + " succeeded on attempt " + attempt); + return true; + } + Thread.sleep(3000); + } + Log.w(TAG, "Ping to " + target + " failed after " + attempt + " attempts"); + return false; + } + + /** Ping a host (FQDN or IP) once through the tunnel. */ + boolean pingOnce(String target) { + String output = shell(String.format("ping -c 1 -W %d %s", PING_W_SEC, target)); + if (output.contains("1 received") || output.contains("1 packets received")) { + return true; + } + // Some ROMs print a different summary; fall back to a positive RTT line. + return output.contains("time=") && !output.contains("100% packet loss"); + } + + /** + * Try to open a TCP connection to {@code host:port}, the equivalent of the + * Robot {@code Open Connection ... port=N connection_timeout=1} Telnet + * check. Returns true if the socket connects within {@code timeoutMs}. + */ + boolean tcpConnects(String host, int port, int timeoutMs) { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(host, port), timeoutMs); + Log.i(TAG, "TCP connect to " + host + ":" + port + " succeeded"); + return true; + } catch (IOException e) { + Log.i(TAG, "TCP connect to " + host + ":" + port + " failed: " + e.getMessage()); + return false; + } + } + + /** + * Resolve {@code host} via {@code ping}, which is the only resolver tool + * present on these devices (no nslookup/getent on Android 11). ping prints + * the resolved address in its first line — {@code PING host (1.2.3.4) ...} — + * and does the lookup through the device resolver / NetBird VpnService DNS, + * like a user's traffic. Returns that address, or null if it didn't resolve. + */ + String resolve(String host) { + String out = shell("ping -c 1 -W 2 " + host); + Matcher m = PING_RESOLVED_IP.matcher(out); + return m.find() ? m.group(1) : null; + } + + private static final Pattern PING_RESOLVED_IP = + Pattern.compile("\\(([0-9]{1,3}(?:\\.[0-9]{1,3}){3})\\)"); + + /** Retry {@link #resolve(String)} until it returns {@code expectedIp} or the timeout elapses. */ + boolean waitForResolve(String host, String expectedIp, long timeoutSec) throws InterruptedException { + long deadline = System.currentTimeMillis() + (timeoutSec * 1000L); + String last = null; + while (System.currentTimeMillis() < deadline) { + last = resolve(host); + if (expectedIp.equals(last)) { + Log.i(TAG, "Resolved " + host + " -> " + last); + return true; + } + Thread.sleep(3000); + } + Log.w(TAG, "Resolve " + host + " did not yield " + expectedIp + " (last: " + last + ")"); + return false; + } + + /** + * Issue a real HTTPS GET and return the response body, or null on failure. + * Used by the exit-node test to ask {@code https://api.ipify.org} what the + * public egress IP is — the Android equivalent of the Robot + * {@code GET https://api.ipify.org}. With the tunnel routing through an + * exit node, the returned IP is the exit node's, not the device's. + */ + String httpGet(String urlString) { + HttpURLConnection conn = null; + try { + URL url = new URL(urlString); + conn = (HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(10_000); + conn.setRequestMethod("GET"); + int code = conn.getResponseCode(); + if (code != HttpURLConnection.HTTP_OK) { + Log.i(TAG, "GET " + urlString + " -> HTTP " + code); + return null; + } + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(conn.getInputStream()))) { + StringBuilder body = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + body.append(line); + } + return body.toString(); + } + } catch (IOException e) { + Log.i(TAG, "GET " + urlString + " failed: " + e.getMessage()); + return null; + } finally { + if (conn != null) { + conn.disconnect(); + } + } + } + + /** + * Poll {@link #httpGet(String)} until the response contains {@code + * expectedSubstring} (word-boundaried for dotted-quad IPs) or the timeout + * elapses. + */ + boolean waitForHttpBodyContains(String urlString, String expectedSubstring, long timeoutSec) + throws InterruptedException { + Pattern p = Pattern.compile("(^|[^0-9.])" + Pattern.quote(expectedSubstring) + "([^0-9.]|$)"); + long deadline = System.currentTimeMillis() + (timeoutSec * 1000L); + String last = null; + while (System.currentTimeMillis() < deadline) { + last = httpGet(urlString); + if (last != null && p.matcher(last).find()) { + Log.i(TAG, "GET " + urlString + " body matched " + expectedSubstring); + return true; + } + Thread.sleep(3000); + } + Log.w(TAG, "GET " + urlString + " never matched " + expectedSubstring + " (last: " + last + ")"); + return false; + } + + /** Run a shell command via the instrumentation UiAutomation and return stdout. */ + String shell(String command) { + try { + ParcelFileDescriptor pfd = uiAutomation.executeShellCommand(command); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(new ParcelFileDescriptor.AutoCloseInputStream(pfd)))) { + StringBuilder output = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append('\n'); + } + return output.toString(); + } + } catch (Exception e) { + Log.w(TAG, "Shell command failed: " + command + " - " + e.getMessage()); + return ""; + } + } + + /** Per-attempt ping timeout, in seconds. */ + private static final int PING_W_SEC = 5; +}