Skip to content

Commit 51de4b7

Browse files
committed
ci: add setup-key auth instrumentation test
Adds SetupKeyAuthTest, a UiAutomator-driven instrumentation test that drives the in-app "Change server" screen end-to-end with a setup key and waits for the success dialog. The test taps "Use NetBird", so the management URL is the one hard-coded in the app (Preferences.defaultServer()). The setup key comes from an instrumentation runner argument so CI can inject it as a secret without baking it into the APK. Wires it into the existing build-debug workflow as a workflow_dispatch-only job that reuses the netbird-aar artifact, so PR builds are unaffected and the AAR is built only once per run. Required repo config: - Secret: INSTRUMENTATION_NB_SETUP_KEY (UUID, ideally reusable + ephemeral)
1 parent af289eb commit 51de4b7

2 files changed

Lines changed: 182 additions & 1 deletion

File tree

.github/workflows/build-debug.yml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ on:
55
push:
66
branches:
77
- main
8+
workflow_dispatch:
89

910
permissions:
1011
contents: read
@@ -87,7 +88,10 @@ jobs:
8788

8889
instrumented-tests:
8990
needs: build-debug
91+
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
9092
runs-on: ubuntu-latest
93+
timeout-minutes: 30
94+
environment: instrumentation-test-secrets
9195
steps:
9296
- name: Checkout repository
9397
uses: actions/checkout@v4
@@ -107,6 +111,15 @@ jobs:
107111
name: netbird-aar
108112
path: gomobile
109113

114+
- name: Verify required secrets
115+
env:
116+
INSTRUMENTATION_NB_SETUP_KEY: ${{ secrets.INSTRUMENTATION_NB_SETUP_KEY }}
117+
run: |
118+
if [ -z "$INSTRUMENTATION_NB_SETUP_KEY" ]; then
119+
echo "::error::INSTRUMENTATION_NB_SETUP_KEY repository secret is not configured"
120+
exit 1
121+
fi
122+
110123
- name: Enable KVM group perms
111124
run: |
112125
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
@@ -115,6 +128,8 @@ jobs:
115128
116129
- name: Run instrumented tests
117130
uses: reactivecircus/android-emulator-runner@v2
131+
env:
132+
INSTRUMENTATION_NB_SETUP_KEY: ${{ secrets.INSTRUMENTATION_NB_SETUP_KEY }}
118133
with:
119134
api-level: 30
120135
target: google_apis
@@ -123,7 +138,7 @@ jobs:
123138
disk-size: 4096M
124139
heap-size: 512M
125140
disable-animations: true
126-
script: ./gradlew connectedDebugAndroidTest --no-daemon -Pandroid.testInstrumentationRunnerArguments.notClass=io.netbird.client.NetworkConnectivityStressTest
141+
script: ./gradlew --no-daemon connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.notClass=io.netbird.client.NetworkConnectivityStressTest -Pandroid.testInstrumentationRunnerArguments.setupKey="$INSTRUMENTATION_NB_SETUP_KEY"
127142

128143
- name: Upload test results
129144
if: always()
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package io.netbird.client;
2+
3+
import android.os.Bundle;
4+
import android.os.Environment;
5+
import android.util.Log;
6+
import android.view.View;
7+
8+
import java.io.File;
9+
10+
import androidx.navigation.NavController;
11+
import androidx.navigation.NavOptions;
12+
import androidx.navigation.Navigation;
13+
import androidx.test.ext.junit.runners.AndroidJUnit4;
14+
import androidx.test.platform.app.InstrumentationRegistry;
15+
import androidx.test.rule.ActivityTestRule;
16+
import androidx.test.uiautomator.By;
17+
import androidx.test.uiautomator.UiDevice;
18+
import androidx.test.uiautomator.UiObject2;
19+
import androidx.test.uiautomator.Until;
20+
21+
import org.junit.Rule;
22+
import org.junit.Test;
23+
import org.junit.runner.RunWith;
24+
25+
import io.netbird.client.ui.server.ChangeServerFragment;
26+
27+
import static org.junit.Assert.assertNotNull;
28+
import static org.junit.Assert.assertTrue;
29+
import static org.junit.Assert.fail;
30+
31+
/**
32+
* Drives the "Change server" UI to authenticate against the default NetBird
33+
* management server with a setup key — exactly the flow a user would use, but
34+
* automated.
35+
*
36+
* <p>The setup key is read from an instrumentation runner argument so CI can
37+
* inject it as a secret without baking it into the APK:
38+
* <pre>
39+
* ./gradlew connectedDebugAndroidTest \
40+
* -Pandroid.testInstrumentationRunnerArguments.setupKey=&lt;UUID&gt;
41+
* </pre>
42+
*
43+
* <p>The test navigates straight to {@code nav_change_server} (skipping the
44+
* first-install teaser screen), fills the setup key and taps the
45+
* "Use NetBird" button, which uses the management URL hard-coded in the app
46+
* ({@code Preferences.defaultServer()}). Then it waits for the success dialog.
47+
*/
48+
@RunWith(AndroidJUnit4.class)
49+
public class SetupKeyAuthTest {
50+
51+
private static final String TAG = "NBSetupKeyAuthTest";
52+
private static final String PACKAGE = "io.netbird.client";
53+
private static final long UI_TIMEOUT_MS = 10_000;
54+
private static final long LOGIN_TIMEOUT_MS = 60_000;
55+
56+
@SuppressWarnings("deprecation")
57+
@Rule
58+
public ActivityTestRule<MainActivity> activityRule =
59+
new ActivityTestRule<>(MainActivity.class, true, true);
60+
61+
@Test
62+
public void loginWithSetupKeyViaUi() throws Exception {
63+
Bundle args = InstrumentationRegistry.getArguments();
64+
String setupKey = args.getString("setupKey");
65+
66+
assertNotNull("setupKey instrumentation argument is required", setupKey);
67+
assertTrue("setupKey must not be blank", !setupKey.trim().isEmpty());
68+
69+
UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
70+
device.waitForIdle();
71+
72+
// Try the navigation up to 5 times — on first launch the MainActivity
73+
// pushes firstInstallFragment after onCreate, which can race with our
74+
// navigate() call from the test thread.
75+
UiObject2 setupKeyLabel = null;
76+
for (int attempt = 1; attempt <= 5 && setupKeyLabel == null; attempt++) {
77+
Log.i(TAG, "navigateToChangeServer attempt " + attempt);
78+
navigateToChangeServer();
79+
setupKeyLabel = device.wait(
80+
Until.findObject(By.res(PACKAGE, "text_setup_key_label")), UI_TIMEOUT_MS);
81+
}
82+
if (setupKeyLabel == null) {
83+
dumpScreenshot(device, "navigation-failed");
84+
fail("text_setup_key_label not found after 5 navigation attempts");
85+
}
86+
setupKeyLabel.click();
87+
88+
UiObject2 setupKeyField = device.wait(
89+
Until.findObject(By.res(PACKAGE, "edit_text_setup_key")), UI_TIMEOUT_MS);
90+
assertNotNull("edit_text_setup_key must be present", setupKeyField);
91+
setupKeyField.setText(setupKey.trim());
92+
93+
// "Use NetBird" submits with the app's default management URL.
94+
UiObject2 submit = device.wait(
95+
Until.findObject(By.res(PACKAGE, "btn_use_netbird")), UI_TIMEOUT_MS);
96+
assertNotNull("btn_use_netbird must be present", submit);
97+
submit.click();
98+
99+
// Either the success dialog ("btn_close") shows up, or the form re-enables
100+
// itself with an error.
101+
long deadline = System.currentTimeMillis() + LOGIN_TIMEOUT_MS;
102+
while (System.currentTimeMillis() < deadline) {
103+
UiObject2 closeBtn = device.findObject(By.res(PACKAGE, "btn_close"));
104+
if (closeBtn != null) {
105+
Log.i(TAG, "Setup-key login succeeded");
106+
closeBtn.click();
107+
return;
108+
}
109+
// If the "Change server" button is enabled again, the request came
110+
// back with an error.
111+
UiObject2 submitAgain = device.findObject(By.res(PACKAGE, "btn_change_server"));
112+
if (submitAgain != null && submitAgain.isEnabled()) {
113+
fail("Login failed: submit button re-enabled without success dialog");
114+
}
115+
Thread.sleep(500);
116+
}
117+
fail("Login did not complete within " + (LOGIN_TIMEOUT_MS / 1000) + "s");
118+
}
119+
120+
/**
121+
* Skip the first-install teaser and jump straight to the "Change server" screen.
122+
* {@code hideAlert=true} suppresses the "are you sure?" warning dialog so this
123+
* is non-interactive.
124+
*/
125+
private void navigateToChangeServer() throws InterruptedException {
126+
MainActivity activity = activityRule.getActivity();
127+
assertNotNull("MainActivity must be available", activity);
128+
129+
activity.runOnUiThread(() -> {
130+
View host = activity.findViewById(R.id.nav_host_fragment_content_main);
131+
NavController nav = Navigation.findNavController(host);
132+
Bundle bundle = new Bundle();
133+
bundle.putBoolean(ChangeServerFragment.HideAlertBundleArg, true);
134+
// Same nav options the FirstInstallFragment uses when the user taps
135+
// its "change_server" link, so we land in the same place.
136+
NavOptions opts = new NavOptions.Builder()
137+
.setPopUpTo(R.id.firstInstallFragment, true)
138+
.build();
139+
nav.navigate(R.id.nav_change_server, bundle, opts);
140+
});
141+
// Let the fragment transaction commit before UiAutomator looks for views.
142+
Thread.sleep(1500);
143+
}
144+
145+
/**
146+
* Drop a PNG screenshot into the app's external files dir so the test
147+
* artifact upload picks it up (same root as build/reports/androidTests/).
148+
*/
149+
private static void dumpScreenshot(UiDevice device, String name) {
150+
try {
151+
File dir = InstrumentationRegistry.getInstrumentation()
152+
.getTargetContext()
153+
.getExternalFilesDir(Environment.DIRECTORY_PICTURES);
154+
if (dir == null) {
155+
return;
156+
}
157+
//noinspection ResultOfMethodCallIgnored
158+
dir.mkdirs();
159+
File png = new File(dir, name + ".png");
160+
boolean ok = device.takeScreenshot(png);
161+
Log.i(TAG, "Screenshot " + (ok ? "saved to " : "FAILED for ") + png);
162+
} catch (Throwable t) {
163+
Log.w(TAG, "Failed to dump screenshot: " + t.getMessage());
164+
}
165+
}
166+
}

0 commit comments

Comments
 (0)