diff --git a/.github/actions/run-e2e-suite/action.yml b/.github/actions/run-e2e-suite/action.yml new file mode 100644 index 0000000..b78ab20 --- /dev/null +++ b/.github/actions/run-e2e-suite/action.yml @@ -0,0 +1,83 @@ +# 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 + # 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" + + - 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/report.xml + 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..cbd7d3c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,9 @@ google-services.json # Android Profiling *.hprof + +# ThunderID server distribution downloaded by the E2E suite +.thunderid-server/ + +# Maestro JUnit report from the E2E suite. +tests/e2e/report.xml 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..4c3d2ff --- /dev/null +++ b/tests/e2e/flows/subflows/ensure-signed-out.yaml @@ -0,0 +1,25 @@ +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 + +# 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 diff --git a/tests/e2e/run-e2e.ps1 b/tests/e2e/run-e2e.ps1 new file mode 100644 index 0000000..50af152 --- /dev/null +++ b/tests/e2e/run-e2e.ps1 @@ -0,0 +1,361 @@ +<# +.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 { + # 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 + } +} + +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..fd8ce24 --- /dev/null +++ b/tests/e2e/run-e2e.sh @@ -0,0 +1,370 @@ +#!/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" + # 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") +} + +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