From 2b8a7c4bc9bc5691d35b95ef67e949a06e93af73 Mon Sep 17 00:00:00 2001 From: Brion Date: Tue, 1 Sep 2026 17:55:35 +0530 Subject: [PATCH 1/3] Add an end-to-end test suite for the Quickstart sample Drive the Quickstart through real authentication against a real ThunderID server using Maestro: sign in and sign out, then register a new account and sign in as it. The suite lives in tests/e2e and one script, run-e2e.sh, owns the whole run, so CI executes exactly what a contributor runs locally. A PowerShell twin, run-e2e.ps1, covers contributors working on Windows. Maestro rather than Espresso because the sign-in and sign-up forms are rendered from the flow definition the server returns, not from static native views. The iOS and Flutter SDKs tag those fields identically, so one set of selectors works across all three platforms. Writing the suite surfaced three defects that affected users, not just tests: Compose keeps testTag inside its own semantics tree, so none of the flow fields were reachable from the platform accessibility tree. The components that render flow steps now opt their subtree in with testTagsAsResourceId. SignUp ignored FlowStatus.INCOMPLETE, which is what the server returns for every registration step, so the form never rendered at all and sign-up was unusable. SignIn already treated INCOMPLETE and PROMPT_ONLY alike. SignUp also tagged nothing, so it now tags its fields and actions the way SignIn does. The sample's sign-up sheet was never dismissed once the flow completed, leaving an empty sheet covering the app. Sign-in only appeared to work because a successful sign-in swaps the whole screen and tears the sheet down with it. Finally, restrict allowInsecureConnections to loopback hosts. It installed a trust-all manager and a permissive hostname verifier for any host, so an app that shipped with it enabled had no certificate validation on the channel carrying credentials, assertions and refresh tokens. It now applies only to localhost, 127.0.0.1, ::1 and 10.0.2.2, and raises a configuration error for anything else. Refs thunder-id/thunderid#5181 Signed-off-by: Brion --- .github/actions/run-e2e-suite/action.yml | 77 ++++ .github/workflows/nightly.yml | 43 ++ .github/workflows/pr-builder.yml | 12 + .gitignore | 3 + samples/quickstart/README.md | 87 +---- .../dev/thunderid/quickstart/AuthScreen.kt | 13 +- .../thunderid-config/thunderid-config.yaml | 34 ++ .../dev/thunderid/android/http/HttpClient.kt | 39 +- .../compose/components/TestTagExposure.kt | 24 ++ .../components/presentation/auth/SignIn.kt | 6 +- .../components/presentation/auth/SignUp.kt | 24 +- tests/e2e/README.md | 76 ++++ tests/e2e/flows/config.yaml | 6 + tests/e2e/flows/signin.yaml | 50 +++ tests/e2e/flows/signup.yaml | 87 +++++ .../e2e/flows/subflows/ensure-signed-out.yaml | 21 + tests/e2e/run-e2e.ps1 | 357 +++++++++++++++++ tests/e2e/run-e2e.sh | 366 ++++++++++++++++++ 18 files changed, 1233 insertions(+), 92 deletions(-) create mode 100644 .github/actions/run-e2e-suite/action.yml create mode 100644 .github/workflows/nightly.yml create mode 100644 samples/quickstart/thunderid-config/thunderid-config.yaml create mode 100644 src/main/kotlin/dev/thunderid/compose/components/TestTagExposure.kt create mode 100644 tests/e2e/README.md create mode 100644 tests/e2e/flows/config.yaml create mode 100644 tests/e2e/flows/signin.yaml create mode 100644 tests/e2e/flows/signup.yaml create mode 100644 tests/e2e/flows/subflows/ensure-signed-out.yaml create mode 100644 tests/e2e/run-e2e.ps1 create mode 100755 tests/e2e/run-e2e.sh diff --git a/.github/actions/run-e2e-suite/action.yml b/.github/actions/run-e2e-suite/action.yml new file mode 100644 index 0000000..6139073 --- /dev/null +++ b/.github/actions/run-e2e-suite/action.yml @@ -0,0 +1,77 @@ +# Runs the Quickstart E2E suite: a ThunderID server, the provisioned test application and user, +# the sample built onto an emulator, and the Maestro flows driving it. +# +# Shared by the PR builder and the nightly workflow so the two cannot drift. All of the actual +# work lives in tests/e2e/run-e2e.sh, which is also what a contributor runs locally, so a green +# run here and a green run on a laptop mean the same thing. + +name: Run E2E Suite +description: Build the Quickstart sample and drive it with Maestro against a real ThunderID server + +inputs: + thunderid-version: + description: ThunderID release to test against, without the leading "v". Defaults to the latest release. + required: false + default: "" + artifact-suffix: + description: Appended to the debug artifact name, so concurrent callers do not collide. + required: false + default: "" + +runs: + using: composite + steps: + - name: ☕ Set up JDK 17 + uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4 + with: + distribution: temurin + java-version: "17" + + - name: 🗄️ Cache Gradle + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle.kts', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: 🧭 Install Maestro + shell: bash + run: | + curl -Ls "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + + - name: 🖥️ Enable KVM + shell: bash + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: 🔬 Run E2E Suite + uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d # v2 + env: + THUNDERID_VERSION: ${{ inputs.thunderid-version }} + with: + api-level: 34 + target: google_apis + arch: x86_64 + profile: pixel_6 + # The script starts at the repository root. + script: cd tests/e2e && ./run-e2e.sh + + - name: 📤 Upload Debug Artifacts + if: ${{ failure() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: e2e-debug-android${{ inputs.artifact-suffix }} + # Maestro writes screenshots, the recorded hierarchy and its own logs here on failure, + # which is the only way to tell a genuine regression from a flake after the fact. + path: | + ~/.maestro/tests + tests/e2e/.thunderid-server/server.log + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..028d17e --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,43 @@ +# Runs the Quickstart E2E suite against the latest published ThunderID release, every night. +# +# The PR builder runs the same suite through the same composite action. The nightly exists +# because these flows talk to a freshly downloaded server release, so a scheduled run catches +# breakage introduced by a new server release rather than by a change in this repository. +# +# Uses: +# OS: ubuntu-latest + +name: 🌙 Nightly E2E + +on: + schedule: + # 02:30 UTC. + - cron: "30 2 * * *" + workflow_dispatch: + inputs: + thunderid-version: + description: ThunderID release to test against, without the leading "v". Blank uses the latest. + required: false + default: "" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + PRODUCT_NAME: "ThunderID" + +jobs: + e2e: + name: 🎭 E2E Tests + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: 📥 Checkout Code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: 🔬 Run E2E Suite + uses: ./.github/actions/run-e2e-suite + with: + thunderid-version: ${{ inputs.thunderid-version }} + artifact-suffix: -nightly diff --git a/.github/workflows/pr-builder.yml b/.github/workflows/pr-builder.yml index 3091566..a91ee37 100644 --- a/.github/workflows/pr-builder.yml +++ b/.github/workflows/pr-builder.yml @@ -153,3 +153,15 @@ jobs: - name: 🔨 Build Quickstart Sample working-directory: samples/quickstart run: ./gradlew build -x test + + e2e: + name: 🎭 E2E Tests + if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: 📥 Checkout Code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: 🔬 Run E2E Suite + uses: ./.github/actions/run-e2e-suite diff --git a/.gitignore b/.gitignore index 3b6de85..c599171 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ google-services.json # Android Profiling *.hprof + +# ThunderID server distribution downloaded by the E2E suite +.thunderid-server/ diff --git a/samples/quickstart/README.md b/samples/quickstart/README.md index 484f6bd..1fc0573 100644 --- a/samples/quickstart/README.md +++ b/samples/quickstart/README.md @@ -5,86 +5,13 @@ ThunderID Android Quickstart demonstrates the full authentication lifecycle usin **Flow demonstrated:** 1. App opens → unauthenticated state (sign-in screen) 2. User initiates sign-in / sign-up → SDK starts app-native Flow Execution -3. User completes the flow and logs in to ThunderID -4. Successful → authenticated state with profile information, token debugging, and sign-out button. +3. User completes the flow +4. Sign-in → authenticated state with profile information, token debugging, and sign-out button. + Sign-up creates the account but does not start a session, so it returns to the landing screen + and the new credentials have to be used to sign in. 5. User taps Sign Out → session terminated, returns to sign-in screen -## Prerequisites +## Setup & Run -- Android Studio 2022.3+ -- A running ThunderID instance - -## Setup - -```bash -cp config.properties.example config.properties -``` - - -### Configuration - -> [!NOTE] -> This sample uses app-native authentication (Flow Execution API), so only the base URL and application ID are required — no OAuth2 client ID or redirect URIs. - - -| Variable | Description | -|----------|-------------| -| `THUNDERID_BASE_URL` | Base URL of your ThunderID server (HTTPS) | -| `THUNDERID_APPLICATION_ID` | Application UUID from ThunderID console | - -💡 `config.properties` is gitignored. Never commit real credentials. - -> [!NOTE] -> If your ThunderID server is running on `localhost`, don't use `localhost` in `THUNDERID_BASE_URL` directly: -> - **Emulator**: use `https://10.0.2.2:8090` — the emulator's alias for your host machine's loopback. -> - **Physical device**: run `adb reverse tcp:8090 tcp:8090` to forward the port over USB and keep using -> `https://localhost:8090`, or point the URL at your host machine's LAN IP. - -### Attestation via Google Play Integrity (optional) - -If the application enforces Google Play Integrity attestation, set `THUNDERID_ATTESTATION_ENABLED=true` and -`THUNDERID_CLOUD_PROJECT_NUMBER` to the number (not the ID) of the Google Cloud project linked to your Play -Console app, then rebuild. When enabled, the sample mints a token via `PlayIntegrityTokenProvider` (Play -Integrity Standard API) and sends it with every native flow-initiate request. - -Testing this end-to-end requires: -- The app uploaded to a Play Console listing (an internal testing track is enough) with your test device's - Google account added as a tester, so Play recognizes the package name and signing certificate. -- The Play Integrity API enabled on the linked Google Cloud project. -- A release build signed with the certificate registered on the ThunderID application's attestation config - (`certificateSha256Digests`) — a debug-signed APK will fail the signing-identity check. - -### Passkeys (WebAuthn) - -Passkey registration/authentication via Jetpack Credential Manager -(`CreatePublicKeyCredentialRequest`/`GetPublicKeyCredentialOption` in `PasskeyClient`) requires the -server's passkey relying party (`rp.id`) to be a real HTTPS domain, not `localhost` or `10.0.2.2`. -Android verifies the caller is allowed to use that `rp.id` via **Digital Asset Links**: the domain -must serve `https:///.well-known/assetlinks.json` declaring this app's package name and -signing certificate SHA-256 fingerprint(s) under `delegate_permission/common.get_login_creds`. -Without this, Credential Manager rejects the ceremony. - -This sample ships `assetlinks.json.example` with a placeholder -`sha256_cert_fingerprints` entry for the sample's `applicationId` -(`dev.thunderid.Quickstart`). To exercise passkeys end-to-end: - -1. Rename/copy `assetlinks.json.example` to `assetlinks.json` and replace - `` with the SHA-256 fingerprint of the signing - certificate for the build you'll test with (get it via - `keytool -list -v -keystore -alias ` or - `./gradlew signingReport` for a debug build). -2. Host that file at `https:///.well-known/assetlinks.json` — the domain - must serve valid HTTPS (self-signed certs will not work). -3. Make sure the server's passkey `rp.id` matches that same domain — the SDK's `PasskeyClient` - passes whatever `rp.id`/relying-party options the server returns straight through to Credential - Manager. -4. No `AndroidManifest.xml` change is required for this — unlike iOS's Associated Domains - entitlement, Digital Asset Links verification is purely a server-hosted file requirement and - does not need an App Links intent filter (this sample doesn't declare one). - -Exposing a local ThunderID instance under a real, HTTPS-reachable domain (e.g. via a tunnel) is -left to you — this sample only wires up the template file/documentation, not the tunnel itself. - -## Run - -Open in Android Studio, sync Gradle, and run on an API 24+ emulator or device. +See [Try the Android Sample App](https://thunderid.dev/docs/v1.0.x/sdks/android/guides/try-the-sample-app) +in the Android SDK docs for prerequisites, configuration, attestation, passkeys, and run instructions. diff --git a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt index f65f8e3..831dd3b 100644 --- a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt +++ b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt @@ -178,7 +178,7 @@ fun AuthScreen(applicationId: String) { ) { when (showSheet) { "login" -> LoginSheetContent(applicationId = applicationId) - "signup" -> SignUpSheetContent() + "signup" -> SignUpSheetContent(onComplete = { showSheet = null }) } } } @@ -212,7 +212,7 @@ private fun LoginSheetContent(applicationId: String) { } @Composable -private fun SignUpSheetContent() { +private fun SignUpSheetContent(onComplete: () -> Unit) { Column( modifier = Modifier .fillMaxWidth() @@ -222,7 +222,14 @@ private fun SignUpSheetContent() { ) { SheetTitle("Create account") Spacer(Modifier.height(8.dp)) - SignUp(modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp)) + // Registration finishes without establishing a session, so the app stays on AuthScreen and + // this sheet is not torn down with it the way the sign-in sheet is (a successful sign-in + // swaps the whole screen for HomeScreen). Without closing it here the completed flow + // leaves an empty sheet covering the app. + SignUp( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + onComplete = onComplete, + ) } } diff --git a/samples/quickstart/thunderid-config/thunderid-config.yaml b/samples/quickstart/thunderid-config/thunderid-config.yaml new file mode 100644 index 0000000..f167a70 --- /dev/null +++ b/samples/quickstart/thunderid-config/thunderid-config.yaml @@ -0,0 +1,34 @@ +--- +resource_type: application +# Application used exclusively by the Android Quickstart E2E suite. It has no OAuth profile: the +# mobile SDKs authenticate app-natively via the Flow Execution API, so the sample app needs only +# the base URL and this application ID. +# +# The ID is fixed so the sample's Config.plist and the Maestro flows can be committed against a +# known value instead of discovering it at run time. +id: 019e5b10-1001-7a2b-9c3d-4e5f60718293 +ouHandle: default +name: Mobile Quickstart E2E App +description: Application for the ThunderID mobile SDK quickstart E2E suites +type: mobile +# A mobile application must normally prove its binary identity via platform attestation before it +# may initiate a flow directly, otherwise /flow/execute fails with FES-1016. Apple App Attest is +# unavailable in the iOS Simulator and Play Integrity requires real Play Services, so neither can +# be satisfied on the emulated devices this suite drives. devMode skips that check. +# +# Test-only. Never enable this on a real tenant: it removes the guarantee that the client +# initiating a flow is the genuine, unmodified app. +# +# NOTE: POST /import currently drops this block silently (it reports success, then stores +# attestation: null), so run-e2e.sh re-applies it over PUT /applications/{id}. Once the +# import path preserves it, that extra step can go. +attestation: + devMode: true +authFlowHandle: default-flow +# The suite registers a fresh user through the UI rather than relying on a seeded account, so +# self-service registration has to be enabled for this application. +registrationFlowHandle: default-flow +isRegistrationFlowEnabled: true +signOutFlowHandle: default-flow +allowedUserTypes: + - Person diff --git a/src/main/kotlin/dev/thunderid/android/http/HttpClient.kt b/src/main/kotlin/dev/thunderid/android/http/HttpClient.kt index f738dc5..c8caf9e 100644 --- a/src/main/kotlin/dev/thunderid/android/http/HttpClient.kt +++ b/src/main/kotlin/dev/thunderid/android/http/HttpClient.kt @@ -63,9 +63,25 @@ internal class HttpClient( } val connection = (URL(urlString).openConnection() as HttpURLConnection).apply { + // The bypass is deliberately limited to loopback. Its only legitimate use is + // reaching a development server through the self-signed certificate ThunderID + // generates for localhost, and an attacker cannot sit in the middle of a + // loopback connection. Honouring the flag for arbitrary hosts would turn a + // convenience switch into a full man-in-the-middle hole on the channel + // carrying credentials, assertions and refresh tokens, in any build that + // happened to ship with it enabled. if (allowInsecureConnections && this is HttpsURLConnection) { - sslSocketFactory = insecureSslSocketFactory() - hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true } + if (isLoopbackHost(URL(urlString).host)) { + sslSocketFactory = insecureSslSocketFactory() + hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true } + } else { + throw IAMException( + ThunderIDErrorCode.INVALID_CONFIGURATION, + "allowInsecureConnections only applies to loopback hosts " + + "(localhost, 127.0.0.1, ::1, 10.0.2.2); refusing to disable " + + "certificate validation for '${URL(urlString).host}'", + ) + } } requestMethod = method setRequestProperty("Content-Type", "application/json") @@ -128,6 +144,14 @@ internal class HttpClient( .fromJson(body, T::class.java) } + /** + * Whether [host] is a loopback address, and therefore unreachable by a network attacker. + * + * `10.0.2.2` is included because that is the Android emulator's alias for the host machine's + * loopback interface, which is how an emulator reaches a development server. + */ + private fun isLoopbackHost(host: String?): Boolean = host in LOOPBACK_HOSTS + private fun insecureSslSocketFactory(): javax.net.ssl.SSLSocketFactory { val trustAll = object : X509TrustManager { @@ -147,4 +171,15 @@ internal class HttpClient( ctx.init(null, arrayOf(trustAll), SecureRandom()) return ctx.socketFactory } + + private companion object { + /** + * Hosts a network attacker cannot occupy, and therefore the only ones for which + * certificate validation may be relaxed. + * + * `10.0.2.2` is the Android emulator's alias for the host machine's loopback interface, + * which is how an emulator reaches a development server. + */ + val LOOPBACK_HOSTS = setOf("localhost", "127.0.0.1", "::1", "[::1]", "10.0.2.2") + } } diff --git a/src/main/kotlin/dev/thunderid/compose/components/TestTagExposure.kt b/src/main/kotlin/dev/thunderid/compose/components/TestTagExposure.kt new file mode 100644 index 0000000..ade6863 --- /dev/null +++ b/src/main/kotlin/dev/thunderid/compose/components/TestTagExposure.kt @@ -0,0 +1,24 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package dev.thunderid.compose.components + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId + +/** + * Publish the `Modifier.testTag` values in this subtree as Android resource IDs. + * + * Compose keeps `testTag` inside its own semantics tree, where only the Compose test framework + * can read it. Anything driving the app through the platform accessibility tree — UI Automator, + * and therefore black-box runners such as Maestro or Appium — sees nothing unless an ancestor + * opts the subtree in, which is what this does. + * + * It is applied at the root of the components that tag flow-driven fields and actions, so a + * consuming app gets addressable elements without having to know about this opt-in. The tags are + * derived from the server's flow definition and carry no user data. + */ +@OptIn(ExperimentalComposeUiApi::class) +internal fun Modifier.exposeTestTagsAsResourceIds(): Modifier = semantics { testTagsAsResourceId = true } diff --git a/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt b/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt index 5536bd7..250e670 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt @@ -62,6 +62,7 @@ import dev.thunderid.compose.components.actions.adapters.GitHubButton import dev.thunderid.compose.components.actions.adapters.GoogleButton import dev.thunderid.compose.components.actions.adapters.OutlinedTriggerButton import dev.thunderid.compose.components.actions.adapters.PasskeyButton +import dev.thunderid.compose.components.exposeTestTagsAsResourceIds import dev.thunderid.compose.i18n.FlowTemplateResolver import dev.thunderid.compose.i18n.ThunderIDI18n import kotlinx.coroutines.CancellationException @@ -180,7 +181,10 @@ fun SignIn( val thunderState = LocalThunderID.current val i18n = thunderState.i18n BaseSignIn(applicationId = applicationId, modifier = modifier, onComplete = onComplete, onError = onError) { signInState -> - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column( + modifier = Modifier.padding(16.dp).exposeTestTagsAsResourceIds(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { val error = signInState.error if (error != null) { // An error response carries no UI of its own — the previous step's diff --git a/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignUp.kt b/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignUp.kt index 6a85398..7bf9d26 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignUp.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignUp.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp @@ -32,6 +33,7 @@ import dev.thunderid.android.FlowStatus import dev.thunderid.compose.LocalThunderID import dev.thunderid.compose.ThunderIDState import dev.thunderid.compose.components.actions.BaseSignUpButton +import dev.thunderid.compose.components.exposeTestTagsAsResourceIds import kotlinx.coroutines.launch /** State passed to the [BaseSignUp] builder slot. */ @@ -82,7 +84,10 @@ fun SignUp( val thunderState = LocalThunderID.current val i18n = thunderState.i18n BaseSignUp(modifier = modifier, onComplete = onComplete, onError = onError) { state -> - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column( + modifier = Modifier.padding(16.dp).exposeTestTagsAsResourceIds(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { BasicText(i18n.resolve("signUp.title")) state.error?.let { BasicText(it) } state.inputs.forEach { input -> @@ -93,12 +98,17 @@ fun SignUp( Modifier .fillMaxWidth() .defaultMinSize(minHeight = 44.dp) + .testTag("thunderid-field-${input.name}") .semantics { contentDescription = input.name }, ) } state.actions.forEach { action -> - BaseSignUpButton(label = action.label ?: i18n.resolve("signUp.submit")) { - state.submit(action.id ?: action.ref ?: "") + val actionId = action.id ?: action.ref ?: "" + BaseSignUpButton( + label = action.label ?: i18n.resolve("signUp.submit"), + modifier = Modifier.testTag("thunderid-action-$actionId"), + ) { + state.submit(actionId) } } if (state.isLoading) BasicText(i18n.resolve("signUp.loading")) @@ -170,12 +180,14 @@ private suspend fun handleSignUpResponse( onComplete?.invoke() } - FlowStatus.PROMPT_ONLY -> { + // A registration flow reports INCOMPLETE, not PROMPT_ONLY, for every step before the last + // one, and carries that step's inputs and actions in `data` exactly as PROMPT_ONLY does. + // Rendering only PROMPT_ONLY therefore dropped the whole form, leaving an empty sheet. + // SignIn already treats the two the same way. + FlowStatus.PROMPT_ONLY, FlowStatus.INCOMPLETE -> { state.update(response) } - FlowStatus.INCOMPLETE -> {} - FlowStatus.ERROR -> { val msg = response.failureReason ?: "Sign-up failed" state.error = msg diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..16cf5c1 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,76 @@ +# Quickstart E2E + +Maestro flows that drive the Quickstart sample through real authentication against a real +ThunderID server on an Android emulator. The flows live in [`flows/`](./flows); the +scripts alongside them get a server into the right state to run against. + +## Running locally + +```bash +# Everything: start a server, provision it, build and install the sample, run the flows +./run-e2e.sh + +# Iterate faster once the server is up and the sample is installed +./run-e2e.sh --skip-server --skip-build + +# Run a single flow +./run-e2e.sh flows/signin.yaml +``` + +On Windows, use the PowerShell twin, which takes the same stages: + +```powershell +.\run-e2e.ps1 +.\run-e2e.ps1 -SkipServer -SkipBuild +.\run-e2e.ps1 flows\signin.yaml +``` + +Every stage is idempotent. A server that is already serving on `:8090` is reused rather than +restarted, and provisioning re-applies cleanly over an existing application and user. + +## What gets provisioned + +| Resource | Value | +|---|---| +| Application | `019e5b10-1001-7a2b-9c3d-4e5f60718293` (`Mobile Quickstart E2E App`) | +| Test user | `e2e_mobile_user` / `TestPassword@123` | + +The application is declared in +[`samples/quickstart/thunderid-config/thunderid-config.yaml`](../../samples/quickstart/thunderid-config/thunderid-config.yaml), +alongside the sample it configures. The sign-up flow +registers an additional throwaway user per run, named `e2e_signup_`. + +## Things worth knowing + +**The application must be `type: mobile` with `attestation.devMode: true`.** A mobile application +normally has to prove its binary identity through platform attestation before it can initiate a +flow directly, and `/flow/execute` rejects it with `FES-1016` otherwise. Play Integrity needs real +Play Services and a registered signing certificate, so the check cannot be satisfied on the +emulator these flows run on. `devMode` is test-only and must never be enabled on a real tenant. + +**`POST /import` silently drops the `attestation` block.** It reports the import as successful and +then stores `attestation: null`, which is why `run-e2e.sh` re-applies it over +`PUT /applications/{id}`. Once the import path preserves it, that step can be removed. + +**Sign-up does not sign the user in.** The registration flow completes without issuing an +assertion, so the app returns to the landing screen with the account created but no session. The +sign-up flow therefore signs in afterwards with the credentials it just registered, which is also +what proves the new account actually works. + +**Every flow starts from a known state.** Tokens live in `EncryptedSharedPreferences`, which +`clearState: true` does clear, but a flow that fails part way through can still leave the app +mid-session. Each flow therefore starts with the `ensure-signed-out` subflow rather than assuming +a clean device. + +**The emulator reaches the host at `10.0.2.2`, not `localhost`.** The server listens on the host +loopback, so the sample's base URL is `https://10.0.2.2:8090`. The debug build sets +`allowInsecureConnections`, which is what lets it accept the server's self-signed certificate. + +**Maestro only sees `testTag` because the SDK opts in.** Compose keeps test tags inside its own +semantics tree; the SDK applies `testTagsAsResourceId` at the root of its flow-rendering +components so they surface as resource IDs in the platform accessibility tree. + +**`npx thunderid` cannot be used in CI.** It renders an interactive TUI and aborts with +`bubbletea: could not open TTY` whenever stdout is not a terminal. `run-e2e.sh` downloads the +release directly and calls the distribution's own `setup.sh` and `start.sh`, which take the same +arguments non-interactively. diff --git a/tests/e2e/flows/config.yaml b/tests/e2e/flows/config.yaml new file mode 100644 index 0000000..b8efaed --- /dev/null +++ b/tests/e2e/flows/config.yaml @@ -0,0 +1,6 @@ +# Maestro workspace configuration for the Android Quickstart E2E suite. +# +# Only the top-level flows are test entry points. Without this, `maestro test .maestro/` would +# also recurse into subflows/ and run the shared building blocks as if they were tests. +flows: + - "*.yaml" diff --git a/tests/e2e/flows/signin.yaml b/tests/e2e/flows/signin.yaml new file mode 100644 index 0000000..ac730e5 --- /dev/null +++ b/tests/e2e/flows/signin.yaml @@ -0,0 +1,50 @@ +appId: dev.thunderid.Quickstart +name: Sign in and sign out +tags: + - auth +env: + E2E_USERNAME: e2e_mobile_user + E2E_PASSWORD: TestPassword@123 +--- +- launchApp: + clearState: true +- runFlow: subflows/ensure-signed-out.yaml + +- tapOn: "Sign in" + +# The credentials step is rendered from the flow definition the server returns, not from static +# native views. The SDK tags each input with the field's identifier and each button with the +# action's ref, so these ids track the server's flow response. +# +# Compose keeps testTag inside its own semantics tree, so these ids are only visible to Maestro +# because the SDK opts the subtree in via testTagsAsResourceId. +- extendedWaitUntil: + visible: + id: "thunderid-field-username" + timeout: 25000 + +- tapOn: + id: "thunderid-field-username" +- inputText: ${E2E_USERNAME} + +- tapOn: + id: "thunderid-field-password" +- inputText: ${E2E_PASSWORD} + +- tapOn: + id: "thunderid-action-action_001" + +# Authenticated state. +- extendedWaitUntil: + visible: "Session active" + timeout: 30000 + +# ...and back out again, so the flow leaves the app as it found it. +- scrollUntilVisible: + element: "Sign out" + direction: DOWN + timeout: 20000 +- tapOn: "Sign out" +- extendedWaitUntil: + visible: "Get started" + timeout: 20000 diff --git a/tests/e2e/flows/signup.yaml b/tests/e2e/flows/signup.yaml new file mode 100644 index 0000000..e450898 --- /dev/null +++ b/tests/e2e/flows/signup.yaml @@ -0,0 +1,87 @@ +appId: dev.thunderid.Quickstart +name: Sign up a new user, then sign in as them +tags: + - auth +env: + E2E_PASSWORD: TestPassword@123 +--- +- launchApp: + clearState: true +- runFlow: subflows/ensure-signed-out.yaml + +# Registration has to claim a username that does not exist yet, so each run generates its own. +# This leaves a user behind on the target server per run, which is fine for a disposable CI +# instance; against a long-lived dev server, expect the accounts to accumulate. +- evalScript: ${output.username = 'e2e_signup_' + Date.now()} + +- tapOn: "Get started" + +# The registration step is server-rendered like the sign-in step, but its flow definition uses +# different action refs, so the submit button id differs from signin.yaml. +- extendedWaitUntil: + visible: + id: "thunderid-field-username" + timeout: 25000 + +- tapOn: + id: "thunderid-field-username" +- inputText: ${output.username} + +- tapOn: + id: "thunderid-field-password" +- inputText: ${E2E_PASSWORD} + +- tapOn: + id: "thunderid-action-action_credentials" + +# Registration is multi-step: the credentials step is followed by a step collecting the +# attributes the user schema requires (email here). Each step is a fresh server-rendered view +# with its own action ref. +- extendedWaitUntil: + visible: + id: "thunderid-field-email" + timeout: 25000 + +- tapOn: + id: "thunderid-field-email" +- inputText: ${output.username}@example.com + +- tapOn: + id: "thunderid-action-action_schema_attrs" + +# Registration completes without issuing an assertion, so the user is created but NOT signed in +# and the app falls back to the landing screen. Signing in with the credentials just registered +# is what proves the account is actually usable. +- extendedWaitUntil: + visible: "Get started" + timeout: 30000 + +- tapOn: "Sign in" +- extendedWaitUntil: + visible: + id: "thunderid-field-username" + timeout: 25000 + +- tapOn: + id: "thunderid-field-username" +- inputText: ${output.username} + +- tapOn: + id: "thunderid-field-password" +- inputText: ${E2E_PASSWORD} + +- tapOn: + id: "thunderid-action-action_001" + +- extendedWaitUntil: + visible: "Session active" + timeout: 30000 + +- scrollUntilVisible: + element: "Sign out" + direction: DOWN + timeout: 20000 +- tapOn: "Sign out" +- extendedWaitUntil: + visible: "Get started" + timeout: 20000 diff --git a/tests/e2e/flows/subflows/ensure-signed-out.yaml b/tests/e2e/flows/subflows/ensure-signed-out.yaml new file mode 100644 index 0000000..43eaf4b --- /dev/null +++ b/tests/e2e/flows/subflows/ensure-signed-out.yaml @@ -0,0 +1,21 @@ +appId: dev.thunderid.Quickstart +--- +# Bring the app to the unauthenticated landing screen, whatever state it starts in. +# +# The SDK persists tokens in EncryptedSharedPreferences, which `clearState: true` does clear, but +# the app can also be left mid-session by a previous flow that failed part way through. Starting +# from an explicit known state keeps the suite re-runnable either way. +- runFlow: + when: + visible: "Session active" + commands: + - scrollUntilVisible: + element: "Sign out" + direction: DOWN + timeout: 20000 + - tapOn: "Sign out" + - extendedWaitUntil: + visible: "Get started" + timeout: 20000 + +- assertVisible: "Sign in" diff --git a/tests/e2e/run-e2e.ps1 b/tests/e2e/run-e2e.ps1 new file mode 100644 index 0000000..7497eb3 --- /dev/null +++ b/tests/e2e/run-e2e.ps1 @@ -0,0 +1,357 @@ +<# +.SYNOPSIS + Run the Android Quickstart E2E suite end to end on Windows. + +.DESCRIPTION + Starts a ThunderID server, provisions the test application and user, builds and installs the + sample, then drives it with Maestro. The PowerShell counterpart of run-e2e.sh, for + contributors developing the Android SDK on Windows. + + Every stage is idempotent, so re-running is safe and is the normal way to iterate. + +.PARAMETER SkipServer + Skip starting and provisioning; the server is already up and provisioned. + +.PARAMETER SkipBuild + Skip building and installing; the sample is already on the device. + +.PARAMETER SkipTest + Set up everything but do not run Maestro. + +.PARAMETER MaestroArgs + Passed through to Maestro, so a specific flow path or extra options both work. + +.EXAMPLE + .\run-e2e.ps1 + +.EXAMPLE + .\run-e2e.ps1 -SkipServer flows\signin.yaml + +.NOTES + Environment variables, all optional: + THUNDERID_VERSION Release to run, without the leading "v" (default: latest release) + SERVER_URL Where the server is reachable (default https://localhost:8090) + ADMIN_USERNAME Admin user to bootstrap (default admin) + ADMIN_PASSWORD Admin password (default admin) + E2E_USERNAME Test user to create (default e2e_mobile_user) + E2E_PASSWORD Test user password (default TestPassword@123) + INSTALL_DIR Where to unpack the distribution (default .\.thunderid-server) + + Requires PowerShell 7 or later. The server presents a self-signed certificate on localhost, + which every request here opts out of validating with -SkipCertificateCheck; that switch does + not exist in Windows PowerShell 5.1. +#> + +[CmdletBinding()] +param( + [switch]$SkipServer, + [switch]$SkipBuild, + [switch]$SkipTest, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$MaestroArgs +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw "PowerShell 7 or later is required (found $($PSVersionTable.PSVersion)). Install it with: winget install Microsoft.PowerShell" +} + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path + +function Get-EnvOrDefault([string]$Name, [string]$Default) { + $value = [Environment]::GetEnvironmentVariable($Name) + if ([string]::IsNullOrWhiteSpace($value)) { return $Default } + return $value +} + +$ServerUrl = Get-EnvOrDefault 'SERVER_URL' 'https://localhost:8090' +$AdminUser = Get-EnvOrDefault 'ADMIN_USERNAME' 'admin' +$AdminPass = Get-EnvOrDefault 'ADMIN_PASSWORD' 'admin' +$E2eUser = Get-EnvOrDefault 'E2E_USERNAME' 'e2e_mobile_user' +$E2ePass = Get-EnvOrDefault 'E2E_PASSWORD' 'TestPassword@123' +$InstallDir = Get-EnvOrDefault 'INSTALL_DIR' (Join-Path $ScriptDir '.thunderid-server') + +# The sample owns its own application config, not the test script. +$ConfigFile = Join-Path $ScriptDir '../../samples/quickstart/thunderid-config/thunderid-config.yaml' + +# Must match the `id` in $ConfigFile and the application ID the sample is built with. +$AppId = '019e5b10-1001-7a2b-9c3d-4e5f60718293' + +# Every call goes to a host with a self-signed certificate. +$PSDefaultParameterValues['Invoke-RestMethod:SkipCertificateCheck'] = $true +$PSDefaultParameterValues['Invoke-WebRequest:SkipCertificateCheck'] = $true + +# ------------------------------------------------------------------------------------------- +# Start +# ------------------------------------------------------------------------------------------- +function Start-ThunderIDServer { + # Reuse a server that is already serving. All three mobile SDK suites bind the same port, so + # failing here would mean tearing down a perfectly good server just to start an identical one. + # Provisioning runs regardless and is idempotent, so the reused server still ends up correct. + try { + Invoke-WebRequest -Uri "$ServerUrl/health/liveness" -TimeoutSec 3 -UseBasicParsing | Out-Null + Write-Host "==> A server is already serving at $ServerUrl, reusing it" + return + } catch { + # Nothing listening, carry on and start one. + } + + $arch = switch ($env:PROCESSOR_ARCHITECTURE) { + 'AMD64' { 'x64' } + 'ARM64' { 'arm64' } + default { throw "Unsupported architecture $($env:PROCESSOR_ARCHITECTURE)." } + } + if ($arch -eq 'arm64') { + # Only win-x64 is published; it runs under emulation on ARM64 Windows. + Write-Host '==> No win-arm64 build is published, using win-x64 under emulation' + $arch = 'x64' + } + + $version = $env:THUNDERID_VERSION + if ([string]::IsNullOrWhiteSpace($version)) { + Write-Host '==> Resolving the latest ThunderID release' + $release = Invoke-RestMethod 'https://api.github.com/repos/thunder-id/thunderid/releases/latest' + $version = $release.tag_name -replace '^v', '' + if ([string]::IsNullOrWhiteSpace($version)) { + throw 'Could not resolve the latest release (rate limited?). Set THUNDERID_VERSION.' + } + } + + $archive = "thunderid-$version-win-$arch.zip" + $url = "https://github.com/thunder-id/thunderid/releases/download/v$version/$archive" + $distHome = Join-Path $InstallDir "thunderid-$version-win-$arch" + + if (-not (Test-Path $distHome)) { + Write-Host "==> Downloading $archive" + New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null + $zipPath = Join-Path $InstallDir $archive + Invoke-WebRequest -Uri $url -OutFile $zipPath + Expand-Archive -Path $zipPath -DestinationPath $InstallDir -Force + } + + Write-Host '==> Running first-time setup' + & (Join-Path $distHome 'setup.ps1') -AdminUsername $AdminUser -AdminPassword $AdminPass + if ($LASTEXITCODE -ne 0) { throw "setup.ps1 failed with exit code $LASTEXITCODE." } + + Write-Host '==> Starting the server' + # Start-Process detaches the server from this session, so it outlives the script the same way + # setsid does on Linux. + $logPath = Join-Path $InstallDir 'server.log' + Start-Process -FilePath 'pwsh' ` + -ArgumentList '-NoProfile', '-File', (Join-Path $distHome 'start.ps1') ` + -WorkingDirectory $distHome ` + -RedirectStandardOutput $logPath ` + -RedirectStandardError (Join-Path $InstallDir 'server.err.log') ` + -WindowStyle Hidden | Out-Null + + Write-Host "==> Waiting for $ServerUrl to accept connections" + foreach ($i in 1..120) { + try { + Invoke-WebRequest -Uri "$ServerUrl/health/liveness" -TimeoutSec 3 -UseBasicParsing | Out-Null + Write-Host ' up' + return + } catch { + Start-Sleep -Seconds 2 + } + } + + Write-Host 'ERROR: the server did not come up within 240s. Last 50 log lines:' -ForegroundColor Red + if (Test-Path $logPath) { Get-Content $logPath -Tail 50 } + throw 'Server did not start.' +} + +# ------------------------------------------------------------------------------------------- +# Admin token +# +# /applications and /users require a bearer token; the Direct-Auth-Secret header does not apply +# to them (it only gates /auth/, /register/passkey/ and /access/). Mirrors mint_admin_token() in +# the product's tests/e2e/run-e2e.sh: the CONSOLE client runs an authorization-code + PKCE +# exchange, whose credentials step is submitted over the Flow Execution API. +# ------------------------------------------------------------------------------------------- +function Get-AdminToken { + $redirectUri = "$ServerUrl/console" + + $bytes = [byte[]]::new(32) + [System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes) + $verifier = (($bytes | ForEach-Object { $_.ToString('x2') }) -join '').Substring(0, 43) + + $sha = [System.Security.Cryptography.SHA256]::Create() + $hash = $sha.ComputeHash([System.Text.Encoding]::ASCII.GetBytes($verifier)) + $challenge = [Convert]::ToBase64String($hash).Replace('+', '-').Replace('/', '_').TrimEnd('=') + + $query = @( + 'client_id=CONSOLE' + "redirect_uri=$([uri]::EscapeDataString($redirectUri))" + 'scope=system' + "resource=$([uri]::EscapeDataString("$ServerUrl/mcp"))" + 'response_type=code' + "code_challenge=$challenge" + 'code_challenge_method=S256' + ) -join '&' + + $authorize = Invoke-WebRequest -Uri "$ServerUrl/oauth2/authorize?$query" ` + -MaximumRedirection 0 -SkipHttpErrorCheck -ErrorAction SilentlyContinue + $location = $authorize.Headers['Location'] + if ($location -is [array]) { $location = $location[0] } + if ([string]::IsNullOrWhiteSpace($location)) { + throw 'The authorize request returned no Location header.' + } + + $authId = [regex]::Match($location, '[?&]authId=([^&]*)').Groups[1].Value + $execId = [regex]::Match($location, '[?&]executionId=([^&]*)').Groups[1].Value + if (-not $authId -or -not $execId) { + throw "Could not parse authId/executionId from the authorize redirect. Location: $location" + } + + # The console login flow runs an SSO check ahead of the credentials prompt. This is a fresh, + # cookie-less login, so the first call advances past that check and mints a challenge token; + # the second submits the admin credentials with it. + $prompt = Invoke-RestMethod -Method Post -Uri "$ServerUrl/flow/execute" ` + -ContentType 'application/json' -Body (@{executionId = $execId} | ConvertTo-Json) + if (-not $prompt.challengeToken) { throw 'Flow execution returned no challenge token.' } + + $flow = Invoke-RestMethod -Method Post -Uri "$ServerUrl/flow/execute" ` + -ContentType 'application/json' -Body (@{ + executionId = $execId + challengeToken = $prompt.challengeToken + action = 'action_001' + inputs = @{username = $AdminUser; password = $AdminPass} + } | ConvertTo-Json) + if (-not $flow.assertion) { throw 'Admin login returned no assertion.' } + + $callback = Invoke-RestMethod -Method Post -Uri "$ServerUrl/oauth2/auth/callback" ` + -ContentType 'application/json' ` + -Body (@{authId = $authId; assertion = $flow.assertion} | ConvertTo-Json) + $code = [regex]::Match([string]$callback.redirect_uri, '[?&]code=([^&]*)').Groups[1].Value + if (-not $code) { throw 'The OAuth2 callback returned no authorization code.' } + + $token = Invoke-RestMethod -Method Post -Uri "$ServerUrl/oauth2/token" ` + -ContentType 'application/x-www-form-urlencoded' -Body @{ + grant_type = 'authorization_code' + code = $code + redirect_uri = $redirectUri + client_id = 'CONSOLE' + resource = "$ServerUrl/mcp" + code_verifier = $verifier + } + if (-not $token.access_token) { throw 'The token endpoint returned no access token.' } + return $token.access_token +} + +# ------------------------------------------------------------------------------------------- +# Provision +# ------------------------------------------------------------------------------------------- +function Invoke-Provision { + Write-Host "==> Obtaining admin token from $ServerUrl" + $adminToken = Get-AdminToken + $authHeader = @{Authorization = "Bearer $adminToken"} + + Write-Host '==> Importing the E2E application' + $configYaml = Get-Content $ConfigFile -Raw + $import = Invoke-RestMethod -Method Post -Uri "$ServerUrl/import" -Headers $authHeader ` + -ContentType 'application/json' ` + -Body (@{content = $configYaml; options = @{upsert = $true}} | ConvertTo-Json) + if ($import.summary.failed -ne 0) { + throw "Import failed: $($import | ConvertTo-Json -Depth 5)" + } + + # POST /import drops the attestation block (see the note in $ConfigFile), so without + # this the app stores attestation: null and /flow/execute rejects the sample with FES-1016. + # Re-applying it over PUT is the only way to get devMode persisted today. + Write-Host '==> Re-applying attestation devMode over PUT (import drops it)' + $app = Invoke-RestMethod -Uri "$ServerUrl/applications/$AppId" -Headers $authHeader + $app | Add-Member -NotePropertyName attestation -NotePropertyValue @{devMode = $true} -Force + $put = Invoke-RestMethod -Method Put -Uri "$ServerUrl/applications/$AppId" -Headers $authHeader ` + -ContentType 'application/json' -Body ($app | ConvertTo-Json -Depth 10) + if (-not $put.attestation.devMode) { + throw 'Attestation devMode did not persist.' + } + + Write-Host "==> Ensuring test user '$E2eUser' exists" + # The filter grammar accepts exactly one `attribute eq "value"` clause. + $filter = [uri]::EscapeDataString("username eq ""$E2eUser""") + $existing = Invoke-RestMethod -Uri "$ServerUrl/users?filter=$filter" -Headers $authHeader + if ($existing.users -and $existing.users.Count -gt 0) { + Write-Host ' already present, leaving it as is' + } else { + $types = Invoke-RestMethod -Uri "$ServerUrl/user-types" -Headers $authHeader + $ouId = ($types.types | Where-Object {$_.name -eq 'Person'}).ouId + if (-not $ouId) { throw 'Could not resolve the ouId of the Person user type.' } + + $created = Invoke-RestMethod -Method Post -Uri "$ServerUrl/users" -Headers $authHeader ` + -ContentType 'application/json' -Body (@{ + ouId = $ouId + type = 'Person' + attributes = @{ + username = $E2eUser + password = $E2ePass + email = "$E2eUser@example.com" + given_name = 'E2E' + family_name = 'Mobile' + } + } | ConvertTo-Json -Depth 5) + if (-not $created.id) { throw 'Failed to create the test user.' } + Write-Host ' created' + } +} + +# ------------------------------------------------------------------------------------------- +# Build and install the sample +# ------------------------------------------------------------------------------------------- +function Build-Sample { + $sampleDir = Join-Path $ScriptDir '..\..\samples\quickstart' | Resolve-Path + + Write-Host '==> Configuring the sample' + # 10.0.2.2 is the emulator's alias for the host loopback, where the server is listening. + # The debug build sets allowInsecureConnections, which is what lets the app accept the + # server's self-signed certificate. + @( + 'THUNDERID_BASE_URL=https://10.0.2.2:8090' + "THUNDERID_APPLICATION_ID=$AppId" + 'THUNDERID_ATTESTATION_ENABLED=false' + 'THUNDERID_CLOUD_PROJECT_NUMBER=' + ) | Set-Content -Path (Join-Path $sampleDir 'config.properties') -Encoding utf8 + + Write-Host '==> Building and installing the sample' + Push-Location $sampleDir + try { + & .\gradlew.bat installDebug + if ($LASTEXITCODE -ne 0) { throw "gradlew installDebug failed with exit code $LASTEXITCODE." } + } finally { + Pop-Location + } +} + +# ------------------------------------------------------------------------------------------- +# Run the flows +# ------------------------------------------------------------------------------------------- +function Invoke-Flows { + if (-not (Get-Command maestro -ErrorAction SilentlyContinue)) { + throw 'maestro is not installed. See https://maestro.mobile.dev/getting-started/installing-maestro' + } + # Default to the whole suite when no flow path was passed through. + $target = if ($MaestroArgs) { $MaestroArgs } else { @('flows/') } + + Write-Host '==> Running Maestro' + Push-Location $ScriptDir + try { + & maestro --platform android test @target -e "E2E_USERNAME=$E2eUser" -e "E2E_PASSWORD=$E2ePass" + if ($LASTEXITCODE -ne 0) { throw "Maestro reported failures (exit code $LASTEXITCODE)." } + } finally { + Pop-Location + } +} + +if (-not $SkipServer) { + Start-ThunderIDServer + Invoke-Provision + Write-Host '' + Write-Host " server : $ServerUrl" + Write-Host " application id : $AppId" + Write-Host " test user : $E2eUser" + Write-Host '' +} +if (-not $SkipBuild) { Build-Sample } +if (-not $SkipTest) { Invoke-Flows } diff --git a/tests/e2e/run-e2e.sh b/tests/e2e/run-e2e.sh new file mode 100755 index 0000000..08b9b54 --- /dev/null +++ b/tests/e2e/run-e2e.sh @@ -0,0 +1,366 @@ +#!/usr/bin/env bash +# +# Run the Android Quickstart E2E suite end to end: start a ThunderID server, provision the test +# application and user, build and install the sample, then drive it with Maestro. +# +# Every stage is idempotent, so re-running is safe and is the normal way to iterate. +# +# Usage: +# ./run-e2e.sh Everything +# ./run-e2e.sh --skip-server Server already running and provisioned +# ./run-e2e.sh --skip-build Sample already installed on the device +# ./run-e2e.sh flows/signin.yaml Run one flow instead of the whole suite +# +# Any argument that is not a recognised flag is passed through to Maestro, so extra Maestro +# options and a specific flow path both work. +# +# Environment: +# THUNDERID_VERSION Release to run, without the leading "v" (default: latest release) +# SERVER_URL Where the server is reachable (default https://localhost:8090) +# ADMIN_USERNAME Admin user to bootstrap (default admin) +# ADMIN_PASSWORD Admin password (default admin) +# E2E_USERNAME Test user to create (default e2e_mobile_user) +# E2E_PASSWORD Test user password (default TestPassword@123) +# INSTALL_DIR Where to unpack the distribution (default ./.thunderid-server) +# +# Why not `npx thunderid`? That wrapper renders an interactive TUI and aborts with +# "bubbletea: could not open TTY" when stdout is not a terminal, which is always the case on a CI +# runner. The distribution's own setup.sh/start.sh take the same arguments non-interactively. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +SERVER_URL="${SERVER_URL:-https://localhost:8090}" +ADMIN_USER="${ADMIN_USERNAME:-admin}" +ADMIN_PASS="${ADMIN_PASSWORD:-admin}" +E2E_USER="${E2E_USERNAME:-e2e_mobile_user}" +E2E_PASS="${E2E_PASSWORD:-TestPassword@123}" +INSTALL_DIR="${INSTALL_DIR:-$SCRIPT_DIR/.thunderid-server}" + +# The sample owns its own application config, not the test script. +CONFIG_FILE="$SCRIPT_DIR/../../samples/quickstart/thunderid-config/thunderid-config.yaml" + +# Must match the `id` in $CONFIG_FILE and the application ID the sample is built with. +APP_ID="019e5b10-1001-7a2b-9c3d-4e5f60718293" + +do_server=true +do_build=true +do_test=true +maestro_args=() +for arg in "$@"; do + case "$arg" in + --skip-server) do_server=false ;; + --skip-build) do_build=false ;; + --skip-test) do_test=false ;; + -h | --help) sed -n '2,27p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) maestro_args+=("$arg") ;; + esac +done + +for tool in curl jq openssl; do + command -v "$tool" >/dev/null 2>&1 || { echo "ERROR: $tool is required but not installed." >&2; exit 1; } +done + +# --------------------------------------------------------------------------------------------- +# Start +# --------------------------------------------------------------------------------------------- +start_server() { + # Reuse a server that is already serving. All three mobile SDK suites bind the same port, so + # failing here would mean tearing down a perfectly good server just to start an identical one. + # Provisioning runs regardless and is idempotent, so the reused server still ends up correct. + if curl -sk -o /dev/null --max-time 3 "$SERVER_URL/health/liveness" 2>/dev/null; then + echo "==> A server is already serving at $SERVER_URL, reusing it" + return 0 + fi + + command -v unzip >/dev/null 2>&1 || { echo "ERROR: unzip is required but not installed." >&2; exit 1; } + + local pkg_os pkg_arch + case "$(uname -s)" in + Darwin) pkg_os="macos" ;; + Linux) pkg_os="linux" ;; + *) echo "ERROR: unsupported OS $(uname -s)." >&2; exit 1 ;; + esac + case "$(uname -m)" in + arm64 | aarch64) pkg_arch="arm64" ;; + x86_64 | amd64) pkg_arch="x64" ;; + *) echo "ERROR: unsupported architecture $(uname -m)." >&2; exit 1 ;; + esac + + local version="${THUNDERID_VERSION:-}" + if [ -z "$version" ]; then + echo "==> Resolving the latest ThunderID release" + version=$(curl -sSL https://api.github.com/repos/thunder-id/thunderid/releases/latest | + jq -r '.tag_name // empty' | sed 's/^v//') + if [ -z "$version" ]; then + echo "ERROR: could not resolve the latest release (rate limited?). Set THUNDERID_VERSION." >&2 + exit 1 + fi + fi + + local archive="thunderid-${version}-${pkg_os}-${pkg_arch}.zip" + local url="https://github.com/thunder-id/thunderid/releases/download/v${version}/${archive}" + local dist_home="$INSTALL_DIR/thunderid-${version}-${pkg_os}-${pkg_arch}" + + if [ ! -d "$dist_home" ]; then + echo "==> Downloading $archive" + mkdir -p "$INSTALL_DIR" + curl -sSLf "$url" -o "$INSTALL_DIR/$archive" + unzip -q "$INSTALL_DIR/$archive" -d "$INSTALL_DIR" + fi + + echo "==> Running first-time setup" + (cd "$dist_home" && ./setup.sh --admin-username "$ADMIN_USER" --admin-password "$ADMIN_PASS") + + echo "==> Starting the server" + # The server has to outlive the step that starts it, which means leaving this process group: + # `nohup` alone only ignores SIGHUP, so anything that signals the group still takes the server + # down with it. setsid does that but does not exist on macOS, so fall back to Python, whose + # start_new_session flag calls setsid in the child. + if command -v setsid >/dev/null 2>&1; then + (cd "$dist_home" && setsid ./start.sh > "$INSTALL_DIR/server.log" 2>&1 < /dev/null &) + else + python3 - "$dist_home" "$INSTALL_DIR/server.log" <<'PY' +import subprocess +import sys + +dist_home, log_path = sys.argv[1], sys.argv[2] +with open(log_path, "ab") as log: + subprocess.Popen( + ["./start.sh"], + cwd=dist_home, + stdout=log, + stderr=log, + stdin=subprocess.DEVNULL, + start_new_session=True, + ) +PY + fi + + echo "==> Waiting for $SERVER_URL to accept connections" + # The server serves a self-signed certificate on localhost, hence -k. + local i + for i in $(seq 1 120); do + if curl -sk -o /dev/null --max-time 3 "$SERVER_URL/health/liveness"; then + echo " up" + return 0 + fi + sleep 2 + done + + echo "ERROR: the server did not come up within 240s. Last 50 log lines:" >&2 + tail -50 "$INSTALL_DIR/server.log" >&2 || true + exit 1 +} + +# --------------------------------------------------------------------------------------------- +# Admin token +# +# /applications and /users require a bearer token; the Direct-Auth-Secret header does not apply +# to them (it only gates /auth/, /register/passkey/ and /access/). Mirrors mint_admin_token() in +# the product's tests/e2e/run-e2e.sh: the CONSOLE client runs an authorization-code + PKCE +# exchange, whose credentials step is submitted over the Flow Execution API. +# --------------------------------------------------------------------------------------------- +mint_admin_token() { + local redirect_uri="$SERVER_URL/console" + local verifier challenge + verifier=$(openssl rand -hex 32 | cut -c1-43) + challenge=$(printf '%s' "$verifier" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=') + + local headers_file location auth_id exec_id + headers_file=$(mktemp) + curl -sk -o /dev/null -D "$headers_file" \ + -G "$SERVER_URL/oauth2/authorize" \ + --data-urlencode "client_id=CONSOLE" \ + --data-urlencode "redirect_uri=$redirect_uri" \ + --data-urlencode "scope=system" \ + --data-urlencode "resource=$SERVER_URL/mcp" \ + --data-urlencode "response_type=code" \ + --data-urlencode "code_challenge=$challenge" \ + --data-urlencode "code_challenge_method=S256" + location=$(grep -i "^location:" "$headers_file" | tr -d '\r' | sed 's/^[Ll]ocation: //' || true) + rm -f "$headers_file" + + auth_id=$(sed -n 's/.*[?&]authId=\([^&]*\).*/\1/p' <<<"$location") + exec_id=$(sed -n 's/.*[?&]executionId=\([^&]*\).*/\1/p' <<<"$location") + if [ -z "$auth_id" ] || [ -z "$exec_id" ]; then + echo "ERROR: could not parse authId/executionId from the authorize redirect." >&2 + echo "Location: $location" >&2 + exit 1 + fi + + # The console login flow runs an SSO check ahead of the credentials prompt. This is a fresh, + # cookie-less login, so the first call advances past that check and mints a challenge token; + # the second submits the admin credentials with it. + local prompt_resp challenge_token flow_resp assertion + prompt_resp=$(curl -sk -X POST "$SERVER_URL/flow/execute" \ + -H "Content-Type: application/json" \ + -d "{\"executionId\": \"$exec_id\"}") + challenge_token=$(jq -r '.challengeToken // empty' <<<"$prompt_resp") + if [ -z "$challenge_token" ]; then + echo "ERROR: no challenge token. Response: $prompt_resp" >&2 + exit 1 + fi + + flow_resp=$(curl -sk -X POST "$SERVER_URL/flow/execute" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg executionId "$exec_id" \ + --arg challengeToken "$challenge_token" \ + --arg username "$ADMIN_USER" \ + --arg password "$ADMIN_PASS" \ + '{executionId: $executionId, challengeToken: $challengeToken, action: "action_001", + inputs: {username: $username, password: $password}}')") + assertion=$(jq -r '.assertion // empty' <<<"$flow_resp") + if [ -z "$assertion" ]; then + echo "ERROR: admin login returned no assertion. Response: $flow_resp" >&2 + exit 1 + fi + + local callback_resp auth_code token_resp + callback_resp=$(curl -sk -X POST "$SERVER_URL/oauth2/auth/callback" \ + -H "Content-Type: application/json" \ + -d "{\"authId\": \"$auth_id\", \"assertion\": \"$assertion\"}") + auth_code=$(jq -r '.redirect_uri // empty' <<<"$callback_resp" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p') + if [ -z "$auth_code" ]; then + echo "ERROR: callback returned no authorization code. Response: $callback_resp" >&2 + exit 1 + fi + + token_resp=$(curl -sk -X POST "$SERVER_URL/oauth2/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "grant_type=authorization_code" \ + --data-urlencode "code=$auth_code" \ + --data-urlencode "redirect_uri=$redirect_uri" \ + --data-urlencode "client_id=CONSOLE" \ + --data-urlencode "resource=$SERVER_URL/mcp" \ + --data-urlencode "code_verifier=$verifier") + ADMIN_TOKEN=$(jq -r '.access_token // empty' <<<"$token_resp") + if [ -z "$ADMIN_TOKEN" ]; then + echo "ERROR: token endpoint returned no access token. Response: $token_resp" >&2 + exit 1 + fi +} + +# --------------------------------------------------------------------------------------------- +# Provision +# --------------------------------------------------------------------------------------------- +provision() { + echo "==> Obtaining admin token from $SERVER_URL" + mint_admin_token + + echo "==> Importing the E2E application" + local import_resp + import_resp=$(jq -n --arg content "$(cat "$CONFIG_FILE")" \ + '{content: $content, options: {upsert: true}}' | + curl -sk -X POST "$SERVER_URL/import" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d @-) + if [ "$(jq -r '.summary.failed // 1' <<<"$import_resp")" != "0" ]; then + echo "ERROR: import failed. Response: $import_resp" >&2 + exit 1 + fi + + # POST /import drops the attestation block (see the note in $CONFIG_FILE), so without + # this the app stores attestation: null and /flow/execute rejects the sample with FES-1016. + # Re-applying it over PUT is the only way to get devMode persisted today. + echo "==> Re-applying attestation devMode over PUT (import drops it)" + local app_json updated put_resp + app_json=$(curl -sk "$SERVER_URL/applications/$APP_ID" -H "Authorization: Bearer $ADMIN_TOKEN") + updated=$(jq '.attestation = {devMode: true}' <<<"$app_json") + put_resp=$(curl -sk -X PUT "$SERVER_URL/applications/$APP_ID" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$updated") + if [ "$(jq -r '.attestation.devMode // false' <<<"$put_resp")" != "true" ]; then + echo "ERROR: attestation devMode did not persist. Response: $put_resp" >&2 + exit 1 + fi + + echo "==> Ensuring test user '$E2E_USER' exists" + # The filter grammar accepts exactly one `attribute eq "value"` clause. + local filter existing ou_id create_resp + filter=$(printf 'username eq "%s"' "$E2E_USER" | jq -sRr @uri) + existing=$(curl -sk "$SERVER_URL/users?filter=$filter" -H "Authorization: Bearer $ADMIN_TOKEN") + if [ "$(jq -r '(.users // []) | length' <<<"$existing")" -gt 0 ]; then + echo " already present, leaving it as is" + else + ou_id=$(curl -sk "$SERVER_URL/user-types" -H "Authorization: Bearer $ADMIN_TOKEN" | + jq -r '.types[] | select(.name == "Person") | .ouId') + if [ -z "$ou_id" ] || [ "$ou_id" = "null" ]; then + echo "ERROR: could not resolve the ouId of the Person user type." >&2 + exit 1 + fi + create_resp=$(curl -sk -X POST "$SERVER_URL/users" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg u "$E2E_USER" --arg p "$E2E_PASS" --arg ou "$ou_id" \ + '{ouId: $ou, type: "Person", + attributes: {username: $u, password: $p, email: ($u + "@example.com"), + given_name: "E2E", family_name: "Mobile"}}')") + if [ -z "$(jq -r '.id // empty' <<<"$create_resp")" ]; then + echo "ERROR: failed to create the test user. Response: $create_resp" >&2 + exit 1 + fi + echo " created" + fi +} + +# --------------------------------------------------------------------------------------------- +# Build and install the sample +# --------------------------------------------------------------------------------------------- +build_sample() { + local sample_dir="$SCRIPT_DIR/../../samples/quickstart" + + echo "==> Configuring the sample" + # 10.0.2.2 is the emulator's alias for the host loopback, where the server is listening. + # The debug build sets allowInsecureConnections, which is what lets the app accept the + # server's self-signed certificate. + cat > "$sample_dir/config.properties" < Building and installing the sample" + (cd "$sample_dir" && ./gradlew installDebug) +} + +# --------------------------------------------------------------------------------------------- +# Run the flows +# --------------------------------------------------------------------------------------------- +run_flows() { + command -v maestro >/dev/null 2>&1 || { + echo "ERROR: maestro is not installed. See https://maestro.mobile.dev/getting-started/installing-maestro" >&2 + exit 1 + } + # Default to the whole suite when no flow path was passed through. Checking the length before + # expanding matters: under `set -u`, bash 3.2 (still the default on macOS) treats expanding an + # empty array as an unbound variable. + local target + if [ "${#maestro_args[@]}" -eq 0 ]; then + target=("flows/") + else + target=("${maestro_args[@]}") + fi + + echo "==> Running Maestro" + (cd "$SCRIPT_DIR" && maestro --platform android test "${target[@]}" \ + -e E2E_USERNAME="$E2E_USER" \ + -e E2E_PASSWORD="$E2E_PASS") +} + +if $do_server; then + start_server + provision + echo + echo " server : $SERVER_URL" + echo " application id : $APP_ID" + echo " test user : $E2E_USER" + echo +fi +$do_build && build_sample +$do_test && run_flows From 47ba8db2bbac0bbdfc09cf3b7c44d2a00187780c Mon Sep 17 00:00:00 2001 From: Brion Date: Wed, 2 Sep 2026 22:43:25 +0530 Subject: [PATCH 2/3] Wait explicitly for the landing screen in ensure-signed-out Every flow starts here, and a bare assertVisible gets Maestro's short default lookup timeout rather than the generous budget the rest of the suite uses. On a loaded CI emulator the landing screen does not always render inside it, which failed the Android sign-up flow 19 seconds in, before the flow had done anything. Wait for it explicitly instead. Signed-off-by: Brion --- tests/e2e/flows/subflows/ensure-signed-out.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/e2e/flows/subflows/ensure-signed-out.yaml b/tests/e2e/flows/subflows/ensure-signed-out.yaml index 43eaf4b..4c3d2ff 100644 --- a/tests/e2e/flows/subflows/ensure-signed-out.yaml +++ b/tests/e2e/flows/subflows/ensure-signed-out.yaml @@ -18,4 +18,8 @@ appId: dev.thunderid.Quickstart visible: "Get started" timeout: 20000 -- assertVisible: "Sign in" +# A bare assertVisible relies on Maestro's short default lookup timeout, which is not always +# enough for the landing screen to render on a loaded CI emulator/simulator - wait explicitly. +- extendedWaitUntil: + visible: "Sign in" + timeout: 20000 From 6a2aefe92edf69482114dbe459caf1099ccd9dd5 Mon Sep 17 00:00:00 2001 From: Brion Date: Wed, 2 Sep 2026 22:43:36 +0530 Subject: [PATCH 3/3] Pin the Maestro CLI version and emit a JUnit report CI installed whatever Maestro was newest at the time, so the test runner changed under the suite between runs with nothing in the repository to show it: CI has been on 2.10.0 since it shipped while a contributor following the README gets whatever is current, which makes a CI-only failure impossible to reproduce faithfully. Pin it, and bump deliberately after checking the flows. The JUnit report gives a failed run a machine-readable result instead of a console log to scrape, and CI now collects it with the other debug artifacts. Signed-off-by: Brion --- .github/actions/run-e2e-suite/action.yml | 6 ++++++ .gitignore | 3 +++ tests/e2e/run-e2e.ps1 | 6 +++++- tests/e2e/run-e2e.sh | 4 ++++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/actions/run-e2e-suite/action.yml b/.github/actions/run-e2e-suite/action.yml index 6139073..b78ab20 100644 --- a/.github/actions/run-e2e-suite/action.yml +++ b/.github/actions/run-e2e-suite/action.yml @@ -39,6 +39,11 @@ runs: - name: 🧭 Install Maestro shell: bash + # Pinned rather than latest: an unpinned install swaps the test runner out from under the + # suite between runs, so a CI failure cannot be reproduced against the version a + # contributor has locally. Bump this deliberately, after checking the flows against it. + env: + MAESTRO_VERSION: "2.9.0" run: | curl -Ls "https://get.maestro.mobile.dev" | bash echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" @@ -72,6 +77,7 @@ runs: # which is the only way to tell a genuine regression from a flake after the fact. path: | ~/.maestro/tests + tests/e2e/report.xml tests/e2e/.thunderid-server/server.log retention-days: 7 if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index c599171..cbd7d3c 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ google-services.json # ThunderID server distribution downloaded by the E2E suite .thunderid-server/ + +# Maestro JUnit report from the E2E suite. +tests/e2e/report.xml diff --git a/tests/e2e/run-e2e.ps1 b/tests/e2e/run-e2e.ps1 index 7497eb3..50af152 100644 --- a/tests/e2e/run-e2e.ps1 +++ b/tests/e2e/run-e2e.ps1 @@ -337,7 +337,11 @@ function Invoke-Flows { Write-Host '==> Running Maestro' Push-Location $ScriptDir try { - & maestro --platform android test @target -e "E2E_USERNAME=$E2eUser" -e "E2E_PASSWORD=$E2ePass" + # The JUnit report is what makes a failed run readable without scraping the console log; + # it sits alongside Maestro's own debug output and CI collects both. + & maestro --platform android test @target ` + --format=JUNIT --output=report.xml ` + -e "E2E_USERNAME=$E2eUser" -e "E2E_PASSWORD=$E2ePass" if ($LASTEXITCODE -ne 0) { throw "Maestro reported failures (exit code $LASTEXITCODE)." } } finally { Pop-Location diff --git a/tests/e2e/run-e2e.sh b/tests/e2e/run-e2e.sh index 08b9b54..fd8ce24 100755 --- a/tests/e2e/run-e2e.sh +++ b/tests/e2e/run-e2e.sh @@ -348,7 +348,11 @@ run_flows() { fi echo "==> Running Maestro" + # The JUnit report is what makes a failed run readable without scraping the console log; it + # sits alongside Maestro's own debug output and CI collects both. (cd "$SCRIPT_DIR" && maestro --platform android test "${target[@]}" \ + --format=JUNIT \ + --output=report.xml \ -e E2E_USERNAME="$E2E_USER" \ -e E2E_PASSWORD="$E2E_PASS") }