Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# 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: macos-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
uses: ./.github/workflows/run-e2e-suite.yml
with:
thunderid-version: ${{ inputs.thunderid-version }}
artifact-suffix: -nightly
11 changes: 11 additions & 0 deletions .github/workflows/pr-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,14 @@ jobs:
- name: 🔨 Build Quickstart Sample
working-directory: samples/quickstart
run: flutter build apk --debug

e2e:
name: 🎭 E2E Tests
if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }}
uses: ./.github/workflows/run-e2e-suite.yml
with:
# Fork PRs run without repository secrets, so the unauthenticated GitHub API call that
# resolves "latest" hits its rate limit easily. The nightly workflow already exists to
# catch breakage from new server releases, so pin the PR builder to a known-good
# version instead of resolving it live; bump this alongside the server's own releases.
thunderid-version: "1.0.1"
73 changes: 73 additions & 0 deletions .github/workflows/run-e2e-suite.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Runs the Quickstart E2E suite: a ThunderID server, the provisioned test application and user,
# the sample built onto a simulator, 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.
#
# These flows run on iOS: the sample's iOS target carries an NSAllowsArbitraryLoads exemption so
# it accepts the server's self-signed certificate, while its Android target has no equivalent and
# the plugin exposes no allowInsecureConnections option of its own.

name: Run E2E Suite

on:
workflow_call:
inputs:
thunderid-version:
description: ThunderID release to test against, without the leading "v". Defaults to the latest release.
required: false
type: string
default: ""
artifact-suffix:
description: Appended to the debug artifact name, so concurrent callers do not collide.
required: false
type: string
default: ""

jobs:
e2e:
name: 🎭 E2E Tests
runs-on: macos-latest
timeout-minutes: 45
steps:
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: 🐦 Set up Flutter
uses: subosito/flutter-action@f2c4f6686ca8e8d6e6d0f28410eeef506ed66aff # v2
with:
channel: stable
cache: true

- 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: 🔬 Run E2E Suite
shell: bash
working-directory: tests/e2e
env:
THUNDERID_VERSION: ${{ inputs.thunderid-version }}
run: ./run-e2e.sh

- name: 📤 Upload Debug Artifacts
if: ${{ failure() }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: e2e-debug-flutter${{ 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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ unlinked_spec.ds
**/ios/**/Icon?
**/ios/**/Pods/
**/ios/**/.symlinks/
**/ios/**/.build/
**/ios/**/.swiftpm/
**/ios/**/profile
**/ios/**/xcuserdata
**/ios/.generated/
Expand Down Expand Up @@ -126,3 +128,9 @@ app.*.symbols
.env
.env.local
.env.*.local

# ThunderID server distribution downloaded by the E2E suite
.thunderid-server/

# Maestro JUnit report from the E2E suite.
tests/e2e/report.xml
Original file line number Diff line number Diff line change
Expand Up @@ -180,14 +180,17 @@ class ThunderIDMethodHandler(private val context: Context) {
afterSignInUrl = args["afterSignInUrl"] as? String,
afterSignOutUrl = args["afterSignOutUrl"] as? String,
applicationId = args["applicationId"] as? String,
// Lets a development build reach a ThunderID server using the self-signed certificate
// it generates for localhost. iOS achieves the same at the app level through an
// NSAppTransportSecurity exemption, so the flag is only meaningful here.
allowInsecureConnections = args["allowInsecureConnections"] as? Boolean ?: false,
attestationEnabled = attestationEnabled,
attestationTokenProvider = if (attestationEnabled) {
PlayIntegrityTokenProvider(context, cloudProjectNumber!!)::requestToken
} else {
null
},
tokenValidation = validation,
allowInsecureConnections = args["allowInsecureConnections"] as? Boolean ?: false,
vendor = args["vendor"] as? String ?: ThunderIDConfig.DEFAULT_VENDOR
)
}
Expand Down
2 changes: 1 addition & 1 deletion ios/thunderid_flutter.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Pod::Spec.new do |s|
s.license = { :type => 'Apache License 2.0', :file => '../LICENSE' }
s.author = { 'ThunderID' => 'dev@thunderid.dev' }
s.source = { :path => '.' }
s.source_files = 'Classes/**/*'
s.source_files = 'thunderid_flutter/Sources/thunderid_flutter/**/*'
s.dependency 'Flutter'
s.dependency 'ThunderID', '>= 1.1.0'
s.platform = :ios, '16.0'
Expand Down
14 changes: 14 additions & 0 deletions ios/thunderid_flutter/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions ios/thunderid_flutter/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ let package = Package(
name: "thunderid_flutter",
dependencies: [
.product(name: "ThunderID", package: "ios-sdks")
],
path: "../Classes"
]
)
]
)
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Flutter
import UIKit
import ThunderID

@objc public class ThunderIDFlutterPlugin: NSObject, FlutterPlugin {
@objc @MainActor public class ThunderIDFlutterPlugin: NSObject, FlutterPlugin {
private let handler = ThunderIDMethodHandler()

public static func register(with registrar: FlutterPluginRegistrar) {
Expand Down
21 changes: 13 additions & 8 deletions lib/src/models/thunderid_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,6 @@ class ThunderIDConfig {
/// validate against and no endpoint to save to.
final bool fetchUserProfile;

// Transport
/// Disables TLS certificate and hostname verification on Android.
///
/// Intended only for local development against a self-signed ThunderID instance;
/// gate it on a debug flag and never ship it enabled. Ignored on iOS, where the
/// native SDK already trusts a locally-served certificate on its own.
final bool allowInsecureConnections;

// Platform Attestation
/// When true, the native SDK sends a platform attestation token (Apple App Attest /
/// Google Play Integrity) on native flow-initiate requests.
Expand All @@ -52,6 +44,19 @@ class ThunderIDConfig {
/// Google Cloud project number, required by Play Integrity on Android.
final int? cloudProjectNumber;

// Transport
/// When true, the native SDK accepts TLS certificates it cannot verify.
///
/// This exists so a development build can talk to a ThunderID server using the self-signed
/// certificate it generates for `localhost`. On iOS the same thing is achieved at the app
/// level with an `NSAppTransportSecurity` exemption, so this flag only takes effect on
/// Android, where it is forwarded to the native SDK's own `allowInsecureConnections`.
///
/// Never enable it in a release build: it disables certificate validation entirely, which
/// removes the guarantee that the server on the other end is the one you think it is. Gate it
/// on a debug check, as the Quickstart sample does.
final bool allowInsecureConnections;

// Token Validation
final TokenValidationConfig tokenValidation;

Expand Down
80 changes: 52 additions & 28 deletions lib/src/widgets/flow_form.dart
Original file line number Diff line number Diff line change
Expand Up @@ -387,20 +387,28 @@ class _FlowFormState extends State<FlowForm> {
final label = _resolve(comp['label'], fallback: _capitalize(ref));
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: TextField(
key: Key('thunderid-field-$ref'),
controller: _controllers[ref],
decoration: InputDecoration(
labelText: label,
hintText: _resolve(comp['placeholder'], fallback: _capitalize(ref)),
floatingLabelBehavior: FloatingLabelBehavior.always,
border: const OutlineInputBorder(),
// A widget Key is internal to the Flutter tree and never reaches the platform
// accessibility tree, so it cannot be targeted by anything driving the app from outside
// (UI Automator, XCUITest, and black-box runners such as Maestro). Semantics.identifier
// is what maps to resource-id on Android and accessibilityIdentifier on iOS. The Key is
// kept as well so widget tests can keep finding these fields by key.
child: Semantics(
identifier: 'thunderid-field-${_fieldTestId(comp)}',
child: TextField(
key: Key('thunderid-field-$ref'),
controller: _controllers[ref],
decoration: InputDecoration(
labelText: label,
hintText: _resolve(comp['placeholder'], fallback: _capitalize(ref)),
floatingLabelBehavior: FloatingLabelBehavior.always,
border: const OutlineInputBorder(),
),
obscureText: isPassword,
keyboardType: isPassword
? TextInputType.visiblePassword
: TextInputType.emailAddress,
autocorrect: false,
),
obscureText: isPassword,
keyboardType: isPassword
? TextInputType.visiblePassword
: TextInputType.emailAddress,
autocorrect: false,
),
);
}
Expand Down Expand Up @@ -491,21 +499,26 @@ class _FlowFormState extends State<FlowForm> {

return Padding(
padding: const EdgeInsets.only(top: 8),
child: FilledButton(
key: Key('thunderid-action-$actionId'),
onPressed: widget.isLoading
? null
: () => widget.submit(
actionId,
_controllers.map((k, v) => MapEntry(k, v.text)),
),
child: isSpinning
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(label),
// See the note on the field above: the Key alone is invisible outside the Flutter tree,
// so the identifier is what an external driver can actually target.
child: Semantics(
identifier: 'thunderid-action-$actionId',
child: FilledButton(
key: Key('thunderid-action-$actionId'),
onPressed: widget.isLoading
? null
: () => widget.submit(
actionId,
_controllers.map((k, v) => MapEntry(k, v.text)),
),
child: isSpinning
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(label),
),
),
);
}
Expand Down Expand Up @@ -613,6 +626,17 @@ class _FlowFormState extends State<FlowForm> {
),
);

/// The value used to build a field's accessibility identifier.
///
/// Deliberately different from [_fieldRef], which prefers `ref` because that is the key the
/// flow submission is built from. The iOS and Android SDKs tag their fields with the server's
/// `identifier` instead (`thunderid-field-username`, not `thunderid-field-input_001`), so
/// preferring `identifier` here keeps one set of selectors working across all three platforms.
String _fieldTestId(Map<String, dynamic> comp) => _str(
comp['identifier'],
fallback: _str(comp['name'], fallback: _fieldRef(comp)),
);

String _inputRef(Map<String, dynamic> input) => _str(
input['name'],
fallback: _str(
Expand Down
6 changes: 4 additions & 2 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ repository: https://github.com/thunder-id/flutter-sdks
issue_tracker: https://github.com/thunder-id/flutter-sdks/issues

environment:
sdk: ">=3.2.0 <4.0.0"
flutter: ">=3.16.0"
# Semantics.identifier, used to expose flow field/action identifiers to the platform
# accessibility tree, was added in Flutter 3.19 (Dart 3.3).
sdk: ">=3.3.0 <4.0.0"
flutter: ">=3.19.0"

dependencies:
flutter:
Expand Down
Loading
Loading