diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a75ca6..6e4481b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,12 @@ jobs: flutter pub get flutter build apk --debug + # The plugin's JVM unit tests (android/src/test) against the released + # native AAR, inside the Gradle project the build above generated. + - name: Plugin unit tests (Android) + working-directory: example/android + run: ./gradlew :didit_sdk:testDebugUnitTest + build-ios-example: name: Example app builds (iOS) runs-on: macos-latest @@ -116,3 +122,17 @@ jobs: flutter config --no-enable-swift-package-manager flutter pub get flutter build ios --debug --no-codesign + + # The plugin's XCTests (example/ios/RunnerTests) on a simulator, against + # the same released native binaries. + - name: Plugin unit tests (iOS) + working-directory: example/ios + run: | + udid=$(xcrun simctl list devices available --json | python3 -c 'import json, sys; print(next(d["udid"] for runtime, devices in json.load(sys.stdin)["devices"].items() if "iOS" in runtime for d in devices if d["name"].startswith("iPhone")))') + xcodebuild test -workspace Runner.xcworkspace -scheme Runner -configuration Debug -destination "id=$udid" + + # Dart to the released native SDK and back on a simulator: the native SDK + # ends the flow through its own callback with retryBlocked, against a + # stand-in for the verification API, and Dart checks the result. + - name: Bridge test (iOS) + run: bash tool/ios_bridge_test.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 92b1719..7f07994 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,3 +12,8 @@ context with maintainers only through an approved private channel. ## Verification Run formatting, static analysis, and tests before opening a pull request. + +- Dart: `flutter analyze && flutter test` at the repository root. +- Android plugin: `flutter build apk --config-only` in `example`, then `./gradlew :didit_sdk:testDebugUnitTest` in `example/android`. +- iOS plugin (macOS): `flutter build ios --debug --no-codesign` in `example`, then run Product > Test on the Runner scheme of `example/ios/Runner.xcworkspace` (or `xcodebuild test -workspace Runner.xcworkspace -scheme Runner -destination 'platform=iOS Simulator,name='` in `example/ios`). +- iOS bridge (macOS, sudo): `tool/ios_bridge_test.sh [simulator udid]` runs a native `retryBlocked` result from Dart to the native SDK and back on a simulator, against a local stand-in for the verification API. diff --git a/README.md b/README.md index 1db9fc7..2acc2e6 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ Add the following keys to your app's `Info.plist`: | Microphone | `NSMicrophoneUsageDescription` | Video recording for liveness checks | Yes | | Photo Library | `NSPhotoLibraryUsageDescription` | Upload documents from device gallery | Yes | | NFC | `NFCReaderUsageDescription` | Read NFC chips in passports/ID cards | If using NFC | +| Location | `NSLocationWhenInUseUsageDescription` | Location verification step | If your workflows include Location (see [Location Configuration](#location-configuration-for-the-location-verification-step)) | ```xml NSCameraUsageDescription @@ -197,6 +198,30 @@ Make sure the app's provisioning profile includes the NFC Tag Reading capability This NFC configuration is not needed when `$DiditSdkIosVariant` is `'core'` or `'autodetection'`. +#### Location Configuration (for the Location verification step) + +Only needed when your workflows include a Location step (see [Bank and Location Steps](#bank-and-location-steps)). + +1. **Native Location module.** The step runs on Core Location, which the native SDK ships in its optional `DiditSDK/Location` module, outside every variant, so apps that never run the step do not link location APIs. The released native binaries do not include that module yet. + +2. **Usage strings** in `Info.plist`. `NSLocationTemporaryUsageDescriptionDictionary` is optional: with it, a step that needs a precise position can ask once for precise location when the person granted only an approximate one (iOS 14+). Its purpose key must be exactly `DiditLocationVerification`. + ```xml + NSLocationWhenInUseUsageDescription + Your location is used to confirm where you are for this verification. + NSLocationTemporaryUsageDescriptionDictionary + + DiditLocationVerification + This verification needs your precise location once. + + ``` + Translate both in your `InfoPlist.strings` files (iOS looks the precise-location string up by its purpose key), for example `es.lproj/InfoPlist.strings`: + ``` + "NSLocationWhenInUseUsageDescription" = "Tu ubicación se usa para confirmar dónde estás en esta verificación."; + "DiditLocationVerification" = "Esta verificación necesita tu ubicación precisa una vez."; + ``` + +3. **App Store privacy label.** Declare Precise Location and Coarse Location (iOS shares the coarse one when the person turns precise location off) for App Functionality, linked to the user and not used for tracking. + ### Android The following permissions are declared in the native SDK's `AndroidManifest.xml` and merged automatically: @@ -207,9 +232,18 @@ The following permissions are declared in the native SDK's `AndroidManifest.xml` | `ACCESS_NETWORK_STATE` | Detect network availability | Yes | | `CAMERA` | Document scanning and face verification | Yes | | `NFC` | Read NFC chips in passports/ID cards | If using NFC | +| `ACCESS_FINE_LOCATION` | Precise position for the Location verification step | Native releases with the Location step | +| `ACCESS_COARSE_LOCATION` | Approximate position for the Location verification step | Native releases with the Location step | Camera and NFC hardware features are declared as optional (`android:required="false"`), so your app can be installed on devices without these features. When `diditSdkAndroidNfcEnabled=false`, the Android NFC permission and feature are not added by the SDK. +The location hardware features (`android.hardware.location`, `android.hardware.location.gps` and `android.hardware.location.network`) are optional too, and on Android 12+ the person can grant precise or approximate location. If your merged manifest has these permissions, declare Approximate location and Precise location as collected data in your app's Google Play Data safety form. If your app never runs Location verification, remove them from your merged manifest (`xmlns:tools="http://schemas.android.com/tools"` on the `` element): + +```xml + + +``` + #### Runtime Permissions The SDK handles Android runtime permission requests automatically. When the user reaches a step that requires camera access: @@ -423,6 +457,15 @@ final sessionToken = await yourBackend.createSession(userId); final result = await DiditSdk.startVerification(sessionToken); ``` +## Bank and Location Steps + +Workflows can include a Bank verification step and a Location verification step. The native SDKs draw both screens, so there is no Dart API to call: start the verification as usual and handle the same [`VerificationResult`](#verification-results), in the language set by [`languageCode`](#languagecode). + +> **Availability:** these steps need a native SDK release that includes them and a Flutter release that pins it. The native SDK this release pins does not include them, so keep Bank and Location out of the workflows you start from the app until the [CHANGELOG](CHANGELOG.md) announces support. + +- **Location:** the SDK explains why it needs the position, asks for location permission, reads the position once while the step is on screen and never tracks it in the background. It needs the iOS setup in [Location Configuration](#location-configuration-for-the-location-verification-step); on Android the permissions are merged from the native SDK (see [Android](#android)). Without the iOS module or usage string, or with the Android permissions removed, the step shows no prompt: it reports the location as unavailable and the workflow's own settings decide what happens next. +- **Bank:** the person picks their bank and approves in the system browser (`SFSafariViewController` on iOS, Custom Tabs on Android), then returns to the SDK, which reads the outcome from Didit. Your app needs no permission, URL scheme or intent filter for it. + ## Verification Results Both `startVerification` and `startVerificationWithWorkflow` return a `Future`. The result is a sealed class — use pattern matching to determine the outcome. @@ -458,6 +501,7 @@ Both `startVerification` and `startVerificationWithWorkflow` return a `Future() + private val result = mockk(relaxed = true) + private val sdkState = MutableStateFlow(DiditSdkState.Idle) + private val configuration = slot() + private val onResult = slot<(VerificationResult) -> Unit>() + private val delivered = mutableListOf() + private lateinit var plugin: SdkFlutterPlugin + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + mockkObject(DiditSdk) + every { DiditSdk.state } returns sdkState + every { DiditSdk.launchVerificationUI(any()) } just Runs + every { + DiditSdk.startVerification( + token = any(), + configuration = capture(configuration), + onResult = capture(onResult) + ) + } answers { sdkState.value = DiditSdkState.Ready } + every { + DiditSdk.startVerification( + workflowId = any(), + vendorData = any(), + configuration = capture(configuration), + onResult = capture(onResult) + ) + } answers { sdkState.value = DiditSdkState.Ready } + every { result.success(any()) } answers { delivered += firstArg() } + + plugin = SdkFlutterPlugin() + plugin.onAttachedToActivity(mockk { + every { activity } returns hostActivity + }) + } + + @AfterTest + fun tearDown() { + unmockkObject(DiditSdk) + Dispatchers.resetMain() + } + + private fun startVerification(config: Map = mapOf("languageCode" to "es")) { + plugin.onMethodCall( + MethodCall("startVerification", mapOf("token" to "session-token", "config" to config)), + result + ) + } + + // Starts a verification, lets the native SDK finish it with [nativeResult] and + // returns what the plugin sent back to Dart. + private fun deliver(nativeResult: VerificationResult): Any? { + startVerification() + onResult.captured(nativeResult) + return delivered.last() + } + @Test - fun onMethodCall_getPlatformVersion_returnsExpectedValue() { - val plugin = SdkFlutterPlugin() + fun startVerification_passesTokenAndConfigToNative_thenOpensItsUi() { + startVerification(mapOf("languageCode" to "es", "showCloseButton" to false)) - val call = MethodCall("getPlatformVersion", null) - val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) - plugin.onMethodCall(call, mockResult) + verify { DiditSdk.startVerification(token = "session-token", configuration = any(), onResult = any()) } + assertEquals(SupportedLanguage.SPANISH, configuration.captured.languageLocale) + assertEquals(false, configuration.captured.showCloseButton) + verify(exactly = 1) { DiditSdk.launchVerificationUI(hostActivity) } + assertEquals>(emptyList(), delivered) + } + + @Test + fun startVerificationWithWorkflow_passesWorkflowAndVendorDataToNative_thenOpensItsUi() { + plugin.onMethodCall( + MethodCall( + "startVerificationWithWorkflow", + mapOf( + "workflowId" to "workflow-id", + "vendorData" to "user-1", + "config" to mapOf("languageCode" to "es") + ) + ), + result + ) + + verify { + DiditSdk.startVerification( + workflowId = "workflow-id", + vendorData = "user-1", + configuration = any(), + onResult = any() + ) + } + assertEquals(SupportedLanguage.SPANISH, configuration.captured.languageLocale) + verify(exactly = 1) { DiditSdk.launchVerificationUI(hostActivity) } + } + + @Test + fun completedSession_returnsItsIdAndFinalStatus() { + for (status in VerificationStatus.entries) { + assertEquals( + mapOf("type" to "completed", "sessionId" to "session-1", "status" to status.rawValue), + deliver(VerificationResult.Completed(SessionData("session-1", status))) + ) + } + } + + @Test + fun cancelledSession_returnsTheSessionWhenOneExists() { + assertEquals( + mapOf("type" to "cancelled", "sessionId" to "session-1", "status" to "Pending"), + deliver(VerificationResult.Cancelled(SessionData("session-1", VerificationStatus.PENDING))) + ) + assertEquals(mapOf("type" to "cancelled"), deliver(VerificationResult.Cancelled(null))) + } + + @Test + fun failedSession_returnsTheErrorTypeAndMessage() { + val errorTypes = mapOf( + VerificationError.SessionExpired to "sessionExpired", + VerificationError.NetworkError to "networkError", + VerificationError.CameraAccessDenied to "cameraAccessDenied", + VerificationError.NotInitialized to "notInitialized", + VerificationError.RetryBlocked to "retryBlocked", + VerificationError.ApiError(503, "Service unavailable") to "apiError", + VerificationError.Unknown("Unsupported verification step") to "unknown" + ) + for ((error, errorType) in errorTypes) { + assertEquals( + mapOf( + "type" to "failed", + "errorType" to errorType, + "errorMessage" to error.message, + "sessionId" to "session-1", + "status" to "Pending" + ), + deliver( + VerificationResult.Failed(error, SessionData("session-1", VerificationStatus.PENDING)) + ) + ) + } + } + + @Test + fun nativeStartError_returnsTheNativeFailureWithoutOpeningTheUi() { + every { + DiditSdk.startVerification(token = any(), configuration = any(), onResult = any()) + } answers { + sdkState.value = DiditSdkState.Error("Session not found") + arg<(VerificationResult) -> Unit>(2)( + VerificationResult.Failed(VerificationError.Unknown("Session not found"), null) + ) + } + + startVerification() + + verify(exactly = 0) { DiditSdk.launchVerificationUI(any()) } + assertEquals>( + listOf(mapOf("type" to "failed", "errorType" to "unknown", "errorMessage" to "Session not found")), + delivered + ) + } + + @Test + fun startVerification_withoutAnActivity_failsWithoutOpeningTheUi() { + plugin.onDetachedFromActivity() + + startVerification() + + verify(exactly = 0) { DiditSdk.launchVerificationUI(any()) } + assertEquals>( + listOf( + mapOf( + "type" to "failed", + "errorType" to "unknown", + "errorMessage" to "No active Activity available to present verification UI." + ) + ), + delivered + ) + } + + @Test + fun startVerification_withoutAToken_isRejectedBeforeReachingNative() { + plugin.onMethodCall(MethodCall("startVerification", mapOf()), result) + + verify { result.error("INVALID_ARGUMENT", "Token is required", null) } + verify(exactly = 0) { DiditSdk.startVerification(token = any(), configuration = any(), onResult = any()) } + } + + @Test + fun unknownMethod_isNotImplemented() { + plugin.onMethodCall(MethodCall("getPlatformVersion", null), result) - Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) + verify { result.notImplemented() } } } diff --git a/example/integration_test/retry_blocked_test.dart b/example/integration_test/retry_blocked_test.dart new file mode 100644 index 0000000..918cb09 --- /dev/null +++ b/example/integration_test/retry_blocked_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'package:didit_sdk/sdk_flutter.dart'; + +// tool/ios_bridge_test.sh runs this on an iOS simulator against tool/stand_in_api.py, +// which answers the native SDK's session request the way the verification API answers +// a person with no retries left. Without the stand-in the request would reach the real +// API, so the test is skipped. +const _standInApi = bool.fromEnvironment('DIDIT_STAND_IN_API'); + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets( + 'the native retryBlocked callback reaches Dart as retryBlocked', + (WidgetTester tester) async { + final result = await DiditSdk.startVerificationWithWorkflow( + 'retry-blocked-workflow', + ); + + expect(result, isA()); + final failed = result as VerificationFailed; + expect(failed.error.type, VerificationErrorType.retryBlocked); + expect(failed.session?.sessionId, 'retry-blocked-session'); + expect(failed.session?.status, VerificationStatus.declined); + }, + skip: !_standInApi, + timeout: const Timeout(Duration(minutes: 2)), + ); +} diff --git a/example/ios/Podfile b/example/ios/Podfile index a0cca42..bf75dab 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -50,12 +50,22 @@ didit_sdk_ios_podspec = ENV.fetch( 'DIDIT_SDK_IOS_PODSPEC', 'https://raw.githubusercontent.com/didit-protocol/sdk-ios/4.9.1/DiditSDK.podspec' ) +# Optional: the path to a DiditSDK source tree (the directory holding DiditSDK.podspec) +# to build the native SDK from source instead. It also adds the DiditSDK/Location module, +# which the Location verification step needs and the released binaries do not include. +# The tree's DiditSDK version must match the plugin's pin in ios/didit_sdk.podspec. +didit_sdk_ios_source = ENV.fetch('DIDIT_SDK_IOS_SOURCE', '').strip target 'Runner' do use_frameworks! - # DiditSDK is not on CocoaPods trunk; pull it from the GitHub-hosted podspec. - pod didit_sdk_ios_pod, :podspec => didit_sdk_ios_podspec + unless didit_sdk_ios_source.empty? + pod didit_sdk_ios_pod, :path => didit_sdk_ios_source + pod 'DiditSDK/Location', :path => didit_sdk_ios_source + else + # DiditSDK is not on CocoaPods trunk; pull it from the GitHub-hosted podspec. + pod didit_sdk_ios_pod, :podspec => didit_sdk_ios_podspec + end flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) target 'RunnerTests' do diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 5b662a9..4d6c5cf 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -29,6 +29,6 @@ SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e -PODFILE CHECKSUM: ed31d62c92f6244acb75fd05044c648c8b8de30f +PODFILE CHECKSUM: a1ad4ffffd9b230cd409bdeae40aadb4c08d1b7b COCOAPODS: 1.16.2 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index d26a7b2..4460b7d 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 4C0C5A012E9F000100D1D1D1 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 4C0C5A022E9F000100D1D1D1 /* InfoPlist.strings */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 76D6558419DB405620096097 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5EABE474C84BC4A4E8987018 /* Pods_Runner.framework */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; @@ -52,6 +53,7 @@ 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 3B55FCA806B57C76F9CBBF4D /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 4C0C5A032E9F000100D1D1D1 /* es */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; 5EABE474C84BC4A4E8987018 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -150,6 +152,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, + 4C0C5A022E9F000100D1D1D1 /* InfoPlist.strings */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, @@ -240,6 +243,7 @@ knownRegions = ( en, Base, + es, ); mainGroup = 97C146E51CF9000F007C117D; productRefGroup = 97C146EF1CF9000F007C117D /* Products */; @@ -268,6 +272,7 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + 4C0C5A012E9F000100D1D1D1 /* InfoPlist.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -415,6 +420,14 @@ /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ + 4C0C5A022E9F000100D1D1D1 /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + 4C0C5A032E9F000100D1D1D1 /* es */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; children = ( @@ -516,6 +529,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = me.didit.sdk.sdkFlutterExample.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -534,6 +548,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = me.didit.sdk.sdkFlutterExample.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -550,6 +565,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = me.didit.sdk.sdkFlutterExample.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index d8bb89f..72b3c1a 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -34,6 +34,13 @@ NFC access is required to read the chip in your identity document. NSPhotoLibraryUsageDescription Photo library access is required to upload document images. + NSLocationWhenInUseUsageDescription + Your location is used to confirm where you are for this verification. + NSLocationTemporaryUsageDescriptionDictionary + + DiditLocationVerification + This verification needs your precise location once. + com.apple.developer.nfc.readersession.iso7816.select-identifiers D23300000045737445494420763335 diff --git a/example/ios/Runner/es.lproj/InfoPlist.strings b/example/ios/Runner/es.lproj/InfoPlist.strings new file mode 100644 index 0000000..772dca8 --- /dev/null +++ b/example/ios/Runner/es.lproj/InfoPlist.strings @@ -0,0 +1,4 @@ +/* Spanish usage descriptions for the location keys in Info.plist. */ +"NSLocationWhenInUseUsageDescription" = "Tu ubicación se usa para confirmar dónde estás en esta verificación."; +/* Purpose key of NSLocationTemporaryUsageDescriptionDictionary, looked up here by Core Location. */ +"DiditLocationVerification" = "Esta verificación necesita tu ubicación precisa una vez."; diff --git a/example/ios/RunnerTests/RunnerTests.swift b/example/ios/RunnerTests/RunnerTests.swift index 8f41a95..48f2f00 100644 --- a/example/ios/RunnerTests/RunnerTests.swift +++ b/example/ios/RunnerTests/RunnerTests.swift @@ -1,27 +1,113 @@ +import DiditSDK import Flutter import UIKit import XCTest +@testable import didit_sdk -@testable import sdk_flutter - -// This demonstrates a simple unit test of the Swift portion of this plugin's implementation. +// The native SDK runs every step of the flow, Bank and Location included, and reports a +// single VerificationResult when the flow ends. These tests cover the plugin's side of that +// hand-off: the Dart configuration it passes to the native SDK, the calls it rejects before +// presenting anything, and the map each result shape reaches Dart as. The whole round trip, +// from Dart through the native SDK's own callback and back, is checked by +// example/integration_test/retry_blocked_test.dart (tool/ios_bridge_test.sh). // -// See https://developer.apple.com/documentation/xctest for more information about using XCTest. +// CI runs them in the iOS example job; CONTRIBUTING.md shows how to run them on a Mac. class RunnerTests: XCTestCase { - func testGetPlatformVersion() { - let plugin = SdkFlutterPlugin() - - let call = FlutterMethodCall(methodName: "getPlatformVersion", arguments: []) + private let plugin = SdkFlutterPlugin() - let resultExpectation = expectation(description: "result block must be called.") - plugin.handle(call) { result in - XCTAssertEqual(result as! String, "iOS " + UIDevice.current.systemVersion) - resultExpectation.fulfill() + private func reply(to method: String, arguments: Any?) -> Any? { + var reply: Any? + let replied = expectation(description: "\(method) replies") + plugin.handle(FlutterMethodCall(methodName: method, arguments: arguments)) { result in + reply = result + replied.fulfill() } waitForExpectations(timeout: 1) + return reply + } + + private func strings(_ map: [String: Any?]) -> [String: String] { + map.compactMapValues { $0 as? String } + } + + func testStartVerificationWithoutATokenIsRejected() { + let error = reply(to: "startVerification", arguments: [String: Any]()) as? FlutterError + + XCTAssertEqual(error?.code, "INVALID_ARGUMENT") + } + + func testStartVerificationWithWorkflowWithoutAWorkflowIdIsRejected() { + let error = reply(to: "startVerificationWithWorkflow", arguments: [String: Any]()) as? FlutterError + + XCTAssertEqual(error?.code, "INVALID_ARGUMENT") + } + + func testUnknownMethodIsNotImplemented() { + let result = reply(to: "getPlatformVersion", arguments: nil) as? NSObject + + XCTAssertTrue(result === FlutterMethodNotImplemented) } + func testConfigurationKeepsTheDartLanguageAndOptions() { + let configuration = plugin.parseConfiguration([ + "languageCode": "es", + "showCloseButton": false, + "defaultDocumentCamera": "front", + ]) + + XCTAssertEqual(configuration?.languageLocale, .spanish) + XCTAssertEqual(configuration?.showCloseButton, false) + XCTAssertEqual(configuration?.defaultDocumentCamera, .front) + } + + func testCompletedSessionReturnsItsIdAndFinalStatus() { + let statuses: [(VerificationStatus, String)] = [ + (.approved, "Approved"), + (.pending, "Pending"), + (.declined, "Declined"), + ] + for (status, rawStatus) in statuses { + let mapped = SdkFlutterPlugin.mapVerificationResult( + .completed(session: SessionData(sessionId: "session-1", status: status)) + ) + + XCTAssertEqual(strings(mapped), ["type": "completed", "sessionId": "session-1", "status": rawStatus]) + } + } + + func testCancelledSessionReturnsTheSessionWhenOneExists() { + let withSession = SdkFlutterPlugin.mapVerificationResult( + .cancelled(session: SessionData(sessionId: "session-1", status: .pending)) + ) + let withoutSession = SdkFlutterPlugin.mapVerificationResult(.cancelled(session: nil)) + + XCTAssertEqual(strings(withSession), ["type": "cancelled", "sessionId": "session-1", "status": "Pending"]) + XCTAssertEqual(strings(withoutSession), ["type": "cancelled"]) + } + + func testFailedSessionReturnsTheErrorTypeAndMessage() { + let errors: [(VerificationError, String)] = [ + (.sessionExpired, "sessionExpired"), + (.retryBlocked, "retryBlocked"), + (.networkError, "networkError"), + (.cameraAccessDenied, "cameraAccessDenied"), + (.unknown("Unsupported verification step"), "unknown"), + ] + for (error, errorType) in errors { + let mapped = SdkFlutterPlugin.mapVerificationResult( + .failed(error: error, session: SessionData(sessionId: "session-1", status: .pending)) + ) + + XCTAssertEqual(strings(mapped), [ + "type": "failed", + "errorType": errorType, + "errorMessage": error.localizedDescription, + "sessionId": "session-1", + "status": "Pending", + ]) + } + } } diff --git a/example/lib/main.dart b/example/lib/main.dart index 7a19865..92e4573 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -31,8 +31,9 @@ class VerificationScreen extends StatefulWidget { class _VerificationScreenState extends State { static const _demoWalletAddress = '0x71C7656EC7ab88b098defB751B7401B5f6d8976F'; + // Override with `flutter run --dart-define=DIDIT_LANGUAGE=es`. static const _verificationConfig = DiditConfig( - languageCode: 'he', + languageCode: String.fromEnvironment('DIDIT_LANGUAGE', defaultValue: 'he'), loggingEnabled: true, ); diff --git a/ios/didit_sdk/Sources/didit_sdk/SdkFlutterPlugin.swift b/ios/didit_sdk/Sources/didit_sdk/SdkFlutterPlugin.swift index dca6a66..9037d98 100644 --- a/ios/didit_sdk/Sources/didit_sdk/SdkFlutterPlugin.swift +++ b/ios/didit_sdk/Sources/didit_sdk/SdkFlutterPlugin.swift @@ -185,7 +185,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { // MARK: - Configuration Parsing - private func parseConfiguration(_ dict: [String: Any]?) -> DiditSdk.Configuration? { + func parseConfiguration(_ dict: [String: Any]?) -> DiditSdk.Configuration? { guard let dict = dict else { return nil } var language: SupportedLanguage? @@ -230,7 +230,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { } } - private static func mapVerificationResult(_ result: VerificationResult) -> [String: Any?] { + static func mapVerificationResult(_ result: VerificationResult) -> [String: Any?] { switch result { case .completed(let session): return [ @@ -271,6 +271,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { private static func mapErrorType(_ error: VerificationError) -> String { switch error { case .sessionExpired: return "sessionExpired" + case .retryBlocked: return "retryBlocked" case .networkError: return "networkError" case .cameraAccessDenied: return "cameraAccessDenied" case .unknown: return "unknown" diff --git a/packages/didit_sdk_autodetection/android/build.gradle.kts b/packages/didit_sdk_autodetection/android/build.gradle.kts index 8a3e735..e964894 100644 --- a/packages/didit_sdk_autodetection/android/build.gradle.kts +++ b/packages/didit_sdk_autodetection/android/build.gradle.kts @@ -112,5 +112,6 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") testImplementation("org.jetbrains.kotlin:kotlin-test") - testImplementation("org.mockito:mockito-core:5.0.0") + testImplementation("io.mockk:mockk:1.14.11") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") } diff --git a/packages/didit_sdk_autodetection/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt b/packages/didit_sdk_autodetection/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt index 07a2432..13e6f25 100644 --- a/packages/didit_sdk_autodetection/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt +++ b/packages/didit_sdk_autodetection/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt @@ -1,27 +1,239 @@ package me.didit.sdk.sdk_flutter +import android.app.Activity +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel -import org.mockito.Mockito +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import me.didit.sdk.Configuration +import me.didit.sdk.DiditSdk +import me.didit.sdk.DiditSdkState +import me.didit.sdk.SessionData +import me.didit.sdk.VerificationError +import me.didit.sdk.VerificationResult +import me.didit.sdk.VerificationStatus +import me.didit.sdk.core.localization.SupportedLanguage +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals /* - * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. + * The native SDK runs every step of the flow, Bank and Location included, inside its own + * activity, and reports a single VerificationResult when the flow ends. These tests drive + * the plugin through the method channel against a stubbed DiditSdk: the call reaches the + * native SDK with the Dart configuration, the native UI opens once the session is ready, + * and every result shape reaches Dart as the map VerificationResult.fromMap reads. * - * Once you have built the plugin's example app, you can run these tests from the command - * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or - * you can run them directly from IDEs that support JUnit such as Android Studio. + * Run them with `./gradlew :didit_sdk:testDebugUnitTest` from example/android. */ - +@OptIn(ExperimentalCoroutinesApi::class) internal class SdkFlutterPluginTest { + private val hostActivity = mockk() + private val result = mockk(relaxed = true) + private val sdkState = MutableStateFlow(DiditSdkState.Idle) + private val configuration = slot() + private val onResult = slot<(VerificationResult) -> Unit>() + private val delivered = mutableListOf() + private lateinit var plugin: SdkFlutterPlugin + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + mockkObject(DiditSdk) + every { DiditSdk.state } returns sdkState + every { DiditSdk.launchVerificationUI(any()) } just Runs + every { + DiditSdk.startVerification( + token = any(), + configuration = capture(configuration), + onResult = capture(onResult) + ) + } answers { sdkState.value = DiditSdkState.Ready } + every { + DiditSdk.startVerification( + workflowId = any(), + vendorData = any(), + configuration = capture(configuration), + onResult = capture(onResult) + ) + } answers { sdkState.value = DiditSdkState.Ready } + every { result.success(any()) } answers { delivered += firstArg() } + + plugin = SdkFlutterPlugin() + plugin.onAttachedToActivity(mockk { + every { activity } returns hostActivity + }) + } + + @AfterTest + fun tearDown() { + unmockkObject(DiditSdk) + Dispatchers.resetMain() + } + + private fun startVerification(config: Map = mapOf("languageCode" to "es")) { + plugin.onMethodCall( + MethodCall("startVerification", mapOf("token" to "session-token", "config" to config)), + result + ) + } + + // Starts a verification, lets the native SDK finish it with [nativeResult] and + // returns what the plugin sent back to Dart. + private fun deliver(nativeResult: VerificationResult): Any? { + startVerification() + onResult.captured(nativeResult) + return delivered.last() + } + @Test - fun onMethodCall_getPlatformVersion_returnsExpectedValue() { - val plugin = SdkFlutterPlugin() + fun startVerification_passesTokenAndConfigToNative_thenOpensItsUi() { + startVerification(mapOf("languageCode" to "es", "showCloseButton" to false)) - val call = MethodCall("getPlatformVersion", null) - val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) - plugin.onMethodCall(call, mockResult) + verify { DiditSdk.startVerification(token = "session-token", configuration = any(), onResult = any()) } + assertEquals(SupportedLanguage.SPANISH, configuration.captured.languageLocale) + assertEquals(false, configuration.captured.showCloseButton) + verify(exactly = 1) { DiditSdk.launchVerificationUI(hostActivity) } + assertEquals>(emptyList(), delivered) + } + + @Test + fun startVerificationWithWorkflow_passesWorkflowAndVendorDataToNative_thenOpensItsUi() { + plugin.onMethodCall( + MethodCall( + "startVerificationWithWorkflow", + mapOf( + "workflowId" to "workflow-id", + "vendorData" to "user-1", + "config" to mapOf("languageCode" to "es") + ) + ), + result + ) + + verify { + DiditSdk.startVerification( + workflowId = "workflow-id", + vendorData = "user-1", + configuration = any(), + onResult = any() + ) + } + assertEquals(SupportedLanguage.SPANISH, configuration.captured.languageLocale) + verify(exactly = 1) { DiditSdk.launchVerificationUI(hostActivity) } + } + + @Test + fun completedSession_returnsItsIdAndFinalStatus() { + for (status in VerificationStatus.entries) { + assertEquals( + mapOf("type" to "completed", "sessionId" to "session-1", "status" to status.rawValue), + deliver(VerificationResult.Completed(SessionData("session-1", status))) + ) + } + } + + @Test + fun cancelledSession_returnsTheSessionWhenOneExists() { + assertEquals( + mapOf("type" to "cancelled", "sessionId" to "session-1", "status" to "Pending"), + deliver(VerificationResult.Cancelled(SessionData("session-1", VerificationStatus.PENDING))) + ) + assertEquals(mapOf("type" to "cancelled"), deliver(VerificationResult.Cancelled(null))) + } + + @Test + fun failedSession_returnsTheErrorTypeAndMessage() { + val errorTypes = mapOf( + VerificationError.SessionExpired to "sessionExpired", + VerificationError.NetworkError to "networkError", + VerificationError.CameraAccessDenied to "cameraAccessDenied", + VerificationError.NotInitialized to "notInitialized", + VerificationError.RetryBlocked to "retryBlocked", + VerificationError.ApiError(503, "Service unavailable") to "apiError", + VerificationError.Unknown("Unsupported verification step") to "unknown" + ) + for ((error, errorType) in errorTypes) { + assertEquals( + mapOf( + "type" to "failed", + "errorType" to errorType, + "errorMessage" to error.message, + "sessionId" to "session-1", + "status" to "Pending" + ), + deliver( + VerificationResult.Failed(error, SessionData("session-1", VerificationStatus.PENDING)) + ) + ) + } + } + + @Test + fun nativeStartError_returnsTheNativeFailureWithoutOpeningTheUi() { + every { + DiditSdk.startVerification(token = any(), configuration = any(), onResult = any()) + } answers { + sdkState.value = DiditSdkState.Error("Session not found") + arg<(VerificationResult) -> Unit>(2)( + VerificationResult.Failed(VerificationError.Unknown("Session not found"), null) + ) + } + + startVerification() + + verify(exactly = 0) { DiditSdk.launchVerificationUI(any()) } + assertEquals>( + listOf(mapOf("type" to "failed", "errorType" to "unknown", "errorMessage" to "Session not found")), + delivered + ) + } + + @Test + fun startVerification_withoutAnActivity_failsWithoutOpeningTheUi() { + plugin.onDetachedFromActivity() + + startVerification() + + verify(exactly = 0) { DiditSdk.launchVerificationUI(any()) } + assertEquals>( + listOf( + mapOf( + "type" to "failed", + "errorType" to "unknown", + "errorMessage" to "No active Activity available to present verification UI." + ) + ), + delivered + ) + } + + @Test + fun startVerification_withoutAToken_isRejectedBeforeReachingNative() { + plugin.onMethodCall(MethodCall("startVerification", mapOf()), result) + + verify { result.error("INVALID_ARGUMENT", "Token is required", null) } + verify(exactly = 0) { DiditSdk.startVerification(token = any(), configuration = any(), onResult = any()) } + } + + @Test + fun unknownMethod_isNotImplemented() { + plugin.onMethodCall(MethodCall("getPlatformVersion", null), result) - Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) + verify { result.notImplemented() } } } diff --git a/packages/didit_sdk_autodetection/ios/didit_sdk_autodetection/Sources/didit_sdk_autodetection/SdkFlutterPlugin.swift b/packages/didit_sdk_autodetection/ios/didit_sdk_autodetection/Sources/didit_sdk_autodetection/SdkFlutterPlugin.swift index dca6a66..9037d98 100644 --- a/packages/didit_sdk_autodetection/ios/didit_sdk_autodetection/Sources/didit_sdk_autodetection/SdkFlutterPlugin.swift +++ b/packages/didit_sdk_autodetection/ios/didit_sdk_autodetection/Sources/didit_sdk_autodetection/SdkFlutterPlugin.swift @@ -185,7 +185,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { // MARK: - Configuration Parsing - private func parseConfiguration(_ dict: [String: Any]?) -> DiditSdk.Configuration? { + func parseConfiguration(_ dict: [String: Any]?) -> DiditSdk.Configuration? { guard let dict = dict else { return nil } var language: SupportedLanguage? @@ -230,7 +230,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { } } - private static func mapVerificationResult(_ result: VerificationResult) -> [String: Any?] { + static func mapVerificationResult(_ result: VerificationResult) -> [String: Any?] { switch result { case .completed(let session): return [ @@ -271,6 +271,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { private static func mapErrorType(_ error: VerificationError) -> String { switch error { case .sessionExpired: return "sessionExpired" + case .retryBlocked: return "retryBlocked" case .networkError: return "networkError" case .cameraAccessDenied: return "cameraAccessDenied" case .unknown: return "unknown" diff --git a/packages/didit_sdk_core/android/build.gradle.kts b/packages/didit_sdk_core/android/build.gradle.kts index b63e87b..084047a 100644 --- a/packages/didit_sdk_core/android/build.gradle.kts +++ b/packages/didit_sdk_core/android/build.gradle.kts @@ -112,5 +112,6 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") testImplementation("org.jetbrains.kotlin:kotlin-test") - testImplementation("org.mockito:mockito-core:5.0.0") + testImplementation("io.mockk:mockk:1.14.11") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") } diff --git a/packages/didit_sdk_core/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt b/packages/didit_sdk_core/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt index 07a2432..13e6f25 100644 --- a/packages/didit_sdk_core/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt +++ b/packages/didit_sdk_core/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt @@ -1,27 +1,239 @@ package me.didit.sdk.sdk_flutter +import android.app.Activity +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel -import org.mockito.Mockito +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import me.didit.sdk.Configuration +import me.didit.sdk.DiditSdk +import me.didit.sdk.DiditSdkState +import me.didit.sdk.SessionData +import me.didit.sdk.VerificationError +import me.didit.sdk.VerificationResult +import me.didit.sdk.VerificationStatus +import me.didit.sdk.core.localization.SupportedLanguage +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals /* - * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. + * The native SDK runs every step of the flow, Bank and Location included, inside its own + * activity, and reports a single VerificationResult when the flow ends. These tests drive + * the plugin through the method channel against a stubbed DiditSdk: the call reaches the + * native SDK with the Dart configuration, the native UI opens once the session is ready, + * and every result shape reaches Dart as the map VerificationResult.fromMap reads. * - * Once you have built the plugin's example app, you can run these tests from the command - * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or - * you can run them directly from IDEs that support JUnit such as Android Studio. + * Run them with `./gradlew :didit_sdk:testDebugUnitTest` from example/android. */ - +@OptIn(ExperimentalCoroutinesApi::class) internal class SdkFlutterPluginTest { + private val hostActivity = mockk() + private val result = mockk(relaxed = true) + private val sdkState = MutableStateFlow(DiditSdkState.Idle) + private val configuration = slot() + private val onResult = slot<(VerificationResult) -> Unit>() + private val delivered = mutableListOf() + private lateinit var plugin: SdkFlutterPlugin + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + mockkObject(DiditSdk) + every { DiditSdk.state } returns sdkState + every { DiditSdk.launchVerificationUI(any()) } just Runs + every { + DiditSdk.startVerification( + token = any(), + configuration = capture(configuration), + onResult = capture(onResult) + ) + } answers { sdkState.value = DiditSdkState.Ready } + every { + DiditSdk.startVerification( + workflowId = any(), + vendorData = any(), + configuration = capture(configuration), + onResult = capture(onResult) + ) + } answers { sdkState.value = DiditSdkState.Ready } + every { result.success(any()) } answers { delivered += firstArg() } + + plugin = SdkFlutterPlugin() + plugin.onAttachedToActivity(mockk { + every { activity } returns hostActivity + }) + } + + @AfterTest + fun tearDown() { + unmockkObject(DiditSdk) + Dispatchers.resetMain() + } + + private fun startVerification(config: Map = mapOf("languageCode" to "es")) { + plugin.onMethodCall( + MethodCall("startVerification", mapOf("token" to "session-token", "config" to config)), + result + ) + } + + // Starts a verification, lets the native SDK finish it with [nativeResult] and + // returns what the plugin sent back to Dart. + private fun deliver(nativeResult: VerificationResult): Any? { + startVerification() + onResult.captured(nativeResult) + return delivered.last() + } + @Test - fun onMethodCall_getPlatformVersion_returnsExpectedValue() { - val plugin = SdkFlutterPlugin() + fun startVerification_passesTokenAndConfigToNative_thenOpensItsUi() { + startVerification(mapOf("languageCode" to "es", "showCloseButton" to false)) - val call = MethodCall("getPlatformVersion", null) - val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) - plugin.onMethodCall(call, mockResult) + verify { DiditSdk.startVerification(token = "session-token", configuration = any(), onResult = any()) } + assertEquals(SupportedLanguage.SPANISH, configuration.captured.languageLocale) + assertEquals(false, configuration.captured.showCloseButton) + verify(exactly = 1) { DiditSdk.launchVerificationUI(hostActivity) } + assertEquals>(emptyList(), delivered) + } + + @Test + fun startVerificationWithWorkflow_passesWorkflowAndVendorDataToNative_thenOpensItsUi() { + plugin.onMethodCall( + MethodCall( + "startVerificationWithWorkflow", + mapOf( + "workflowId" to "workflow-id", + "vendorData" to "user-1", + "config" to mapOf("languageCode" to "es") + ) + ), + result + ) + + verify { + DiditSdk.startVerification( + workflowId = "workflow-id", + vendorData = "user-1", + configuration = any(), + onResult = any() + ) + } + assertEquals(SupportedLanguage.SPANISH, configuration.captured.languageLocale) + verify(exactly = 1) { DiditSdk.launchVerificationUI(hostActivity) } + } + + @Test + fun completedSession_returnsItsIdAndFinalStatus() { + for (status in VerificationStatus.entries) { + assertEquals( + mapOf("type" to "completed", "sessionId" to "session-1", "status" to status.rawValue), + deliver(VerificationResult.Completed(SessionData("session-1", status))) + ) + } + } + + @Test + fun cancelledSession_returnsTheSessionWhenOneExists() { + assertEquals( + mapOf("type" to "cancelled", "sessionId" to "session-1", "status" to "Pending"), + deliver(VerificationResult.Cancelled(SessionData("session-1", VerificationStatus.PENDING))) + ) + assertEquals(mapOf("type" to "cancelled"), deliver(VerificationResult.Cancelled(null))) + } + + @Test + fun failedSession_returnsTheErrorTypeAndMessage() { + val errorTypes = mapOf( + VerificationError.SessionExpired to "sessionExpired", + VerificationError.NetworkError to "networkError", + VerificationError.CameraAccessDenied to "cameraAccessDenied", + VerificationError.NotInitialized to "notInitialized", + VerificationError.RetryBlocked to "retryBlocked", + VerificationError.ApiError(503, "Service unavailable") to "apiError", + VerificationError.Unknown("Unsupported verification step") to "unknown" + ) + for ((error, errorType) in errorTypes) { + assertEquals( + mapOf( + "type" to "failed", + "errorType" to errorType, + "errorMessage" to error.message, + "sessionId" to "session-1", + "status" to "Pending" + ), + deliver( + VerificationResult.Failed(error, SessionData("session-1", VerificationStatus.PENDING)) + ) + ) + } + } + + @Test + fun nativeStartError_returnsTheNativeFailureWithoutOpeningTheUi() { + every { + DiditSdk.startVerification(token = any(), configuration = any(), onResult = any()) + } answers { + sdkState.value = DiditSdkState.Error("Session not found") + arg<(VerificationResult) -> Unit>(2)( + VerificationResult.Failed(VerificationError.Unknown("Session not found"), null) + ) + } + + startVerification() + + verify(exactly = 0) { DiditSdk.launchVerificationUI(any()) } + assertEquals>( + listOf(mapOf("type" to "failed", "errorType" to "unknown", "errorMessage" to "Session not found")), + delivered + ) + } + + @Test + fun startVerification_withoutAnActivity_failsWithoutOpeningTheUi() { + plugin.onDetachedFromActivity() + + startVerification() + + verify(exactly = 0) { DiditSdk.launchVerificationUI(any()) } + assertEquals>( + listOf( + mapOf( + "type" to "failed", + "errorType" to "unknown", + "errorMessage" to "No active Activity available to present verification UI." + ) + ), + delivered + ) + } + + @Test + fun startVerification_withoutAToken_isRejectedBeforeReachingNative() { + plugin.onMethodCall(MethodCall("startVerification", mapOf()), result) + + verify { result.error("INVALID_ARGUMENT", "Token is required", null) } + verify(exactly = 0) { DiditSdk.startVerification(token = any(), configuration = any(), onResult = any()) } + } + + @Test + fun unknownMethod_isNotImplemented() { + plugin.onMethodCall(MethodCall("getPlatformVersion", null), result) - Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) + verify { result.notImplemented() } } } diff --git a/packages/didit_sdk_core/ios/didit_sdk_core/Sources/didit_sdk_core/SdkFlutterPlugin.swift b/packages/didit_sdk_core/ios/didit_sdk_core/Sources/didit_sdk_core/SdkFlutterPlugin.swift index dca6a66..9037d98 100644 --- a/packages/didit_sdk_core/ios/didit_sdk_core/Sources/didit_sdk_core/SdkFlutterPlugin.swift +++ b/packages/didit_sdk_core/ios/didit_sdk_core/Sources/didit_sdk_core/SdkFlutterPlugin.swift @@ -185,7 +185,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { // MARK: - Configuration Parsing - private func parseConfiguration(_ dict: [String: Any]?) -> DiditSdk.Configuration? { + func parseConfiguration(_ dict: [String: Any]?) -> DiditSdk.Configuration? { guard let dict = dict else { return nil } var language: SupportedLanguage? @@ -230,7 +230,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { } } - private static func mapVerificationResult(_ result: VerificationResult) -> [String: Any?] { + static func mapVerificationResult(_ result: VerificationResult) -> [String: Any?] { switch result { case .completed(let session): return [ @@ -271,6 +271,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { private static func mapErrorType(_ error: VerificationError) -> String { switch error { case .sessionExpired: return "sessionExpired" + case .retryBlocked: return "retryBlocked" case .networkError: return "networkError" case .cameraAccessDenied: return "cameraAccessDenied" case .unknown: return "unknown" diff --git a/packages/didit_sdk_nfc/android/build.gradle.kts b/packages/didit_sdk_nfc/android/build.gradle.kts index 84b64e9..aeddf10 100644 --- a/packages/didit_sdk_nfc/android/build.gradle.kts +++ b/packages/didit_sdk_nfc/android/build.gradle.kts @@ -112,5 +112,6 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") testImplementation("org.jetbrains.kotlin:kotlin-test") - testImplementation("org.mockito:mockito-core:5.0.0") + testImplementation("io.mockk:mockk:1.14.11") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") } diff --git a/packages/didit_sdk_nfc/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt b/packages/didit_sdk_nfc/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt index 07a2432..13e6f25 100644 --- a/packages/didit_sdk_nfc/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt +++ b/packages/didit_sdk_nfc/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt @@ -1,27 +1,239 @@ package me.didit.sdk.sdk_flutter +import android.app.Activity +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel -import org.mockito.Mockito +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import me.didit.sdk.Configuration +import me.didit.sdk.DiditSdk +import me.didit.sdk.DiditSdkState +import me.didit.sdk.SessionData +import me.didit.sdk.VerificationError +import me.didit.sdk.VerificationResult +import me.didit.sdk.VerificationStatus +import me.didit.sdk.core.localization.SupportedLanguage +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals /* - * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. + * The native SDK runs every step of the flow, Bank and Location included, inside its own + * activity, and reports a single VerificationResult when the flow ends. These tests drive + * the plugin through the method channel against a stubbed DiditSdk: the call reaches the + * native SDK with the Dart configuration, the native UI opens once the session is ready, + * and every result shape reaches Dart as the map VerificationResult.fromMap reads. * - * Once you have built the plugin's example app, you can run these tests from the command - * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or - * you can run them directly from IDEs that support JUnit such as Android Studio. + * Run them with `./gradlew :didit_sdk:testDebugUnitTest` from example/android. */ - +@OptIn(ExperimentalCoroutinesApi::class) internal class SdkFlutterPluginTest { + private val hostActivity = mockk() + private val result = mockk(relaxed = true) + private val sdkState = MutableStateFlow(DiditSdkState.Idle) + private val configuration = slot() + private val onResult = slot<(VerificationResult) -> Unit>() + private val delivered = mutableListOf() + private lateinit var plugin: SdkFlutterPlugin + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + mockkObject(DiditSdk) + every { DiditSdk.state } returns sdkState + every { DiditSdk.launchVerificationUI(any()) } just Runs + every { + DiditSdk.startVerification( + token = any(), + configuration = capture(configuration), + onResult = capture(onResult) + ) + } answers { sdkState.value = DiditSdkState.Ready } + every { + DiditSdk.startVerification( + workflowId = any(), + vendorData = any(), + configuration = capture(configuration), + onResult = capture(onResult) + ) + } answers { sdkState.value = DiditSdkState.Ready } + every { result.success(any()) } answers { delivered += firstArg() } + + plugin = SdkFlutterPlugin() + plugin.onAttachedToActivity(mockk { + every { activity } returns hostActivity + }) + } + + @AfterTest + fun tearDown() { + unmockkObject(DiditSdk) + Dispatchers.resetMain() + } + + private fun startVerification(config: Map = mapOf("languageCode" to "es")) { + plugin.onMethodCall( + MethodCall("startVerification", mapOf("token" to "session-token", "config" to config)), + result + ) + } + + // Starts a verification, lets the native SDK finish it with [nativeResult] and + // returns what the plugin sent back to Dart. + private fun deliver(nativeResult: VerificationResult): Any? { + startVerification() + onResult.captured(nativeResult) + return delivered.last() + } + @Test - fun onMethodCall_getPlatformVersion_returnsExpectedValue() { - val plugin = SdkFlutterPlugin() + fun startVerification_passesTokenAndConfigToNative_thenOpensItsUi() { + startVerification(mapOf("languageCode" to "es", "showCloseButton" to false)) - val call = MethodCall("getPlatformVersion", null) - val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) - plugin.onMethodCall(call, mockResult) + verify { DiditSdk.startVerification(token = "session-token", configuration = any(), onResult = any()) } + assertEquals(SupportedLanguage.SPANISH, configuration.captured.languageLocale) + assertEquals(false, configuration.captured.showCloseButton) + verify(exactly = 1) { DiditSdk.launchVerificationUI(hostActivity) } + assertEquals>(emptyList(), delivered) + } + + @Test + fun startVerificationWithWorkflow_passesWorkflowAndVendorDataToNative_thenOpensItsUi() { + plugin.onMethodCall( + MethodCall( + "startVerificationWithWorkflow", + mapOf( + "workflowId" to "workflow-id", + "vendorData" to "user-1", + "config" to mapOf("languageCode" to "es") + ) + ), + result + ) + + verify { + DiditSdk.startVerification( + workflowId = "workflow-id", + vendorData = "user-1", + configuration = any(), + onResult = any() + ) + } + assertEquals(SupportedLanguage.SPANISH, configuration.captured.languageLocale) + verify(exactly = 1) { DiditSdk.launchVerificationUI(hostActivity) } + } + + @Test + fun completedSession_returnsItsIdAndFinalStatus() { + for (status in VerificationStatus.entries) { + assertEquals( + mapOf("type" to "completed", "sessionId" to "session-1", "status" to status.rawValue), + deliver(VerificationResult.Completed(SessionData("session-1", status))) + ) + } + } + + @Test + fun cancelledSession_returnsTheSessionWhenOneExists() { + assertEquals( + mapOf("type" to "cancelled", "sessionId" to "session-1", "status" to "Pending"), + deliver(VerificationResult.Cancelled(SessionData("session-1", VerificationStatus.PENDING))) + ) + assertEquals(mapOf("type" to "cancelled"), deliver(VerificationResult.Cancelled(null))) + } + + @Test + fun failedSession_returnsTheErrorTypeAndMessage() { + val errorTypes = mapOf( + VerificationError.SessionExpired to "sessionExpired", + VerificationError.NetworkError to "networkError", + VerificationError.CameraAccessDenied to "cameraAccessDenied", + VerificationError.NotInitialized to "notInitialized", + VerificationError.RetryBlocked to "retryBlocked", + VerificationError.ApiError(503, "Service unavailable") to "apiError", + VerificationError.Unknown("Unsupported verification step") to "unknown" + ) + for ((error, errorType) in errorTypes) { + assertEquals( + mapOf( + "type" to "failed", + "errorType" to errorType, + "errorMessage" to error.message, + "sessionId" to "session-1", + "status" to "Pending" + ), + deliver( + VerificationResult.Failed(error, SessionData("session-1", VerificationStatus.PENDING)) + ) + ) + } + } + + @Test + fun nativeStartError_returnsTheNativeFailureWithoutOpeningTheUi() { + every { + DiditSdk.startVerification(token = any(), configuration = any(), onResult = any()) + } answers { + sdkState.value = DiditSdkState.Error("Session not found") + arg<(VerificationResult) -> Unit>(2)( + VerificationResult.Failed(VerificationError.Unknown("Session not found"), null) + ) + } + + startVerification() + + verify(exactly = 0) { DiditSdk.launchVerificationUI(any()) } + assertEquals>( + listOf(mapOf("type" to "failed", "errorType" to "unknown", "errorMessage" to "Session not found")), + delivered + ) + } + + @Test + fun startVerification_withoutAnActivity_failsWithoutOpeningTheUi() { + plugin.onDetachedFromActivity() + + startVerification() + + verify(exactly = 0) { DiditSdk.launchVerificationUI(any()) } + assertEquals>( + listOf( + mapOf( + "type" to "failed", + "errorType" to "unknown", + "errorMessage" to "No active Activity available to present verification UI." + ) + ), + delivered + ) + } + + @Test + fun startVerification_withoutAToken_isRejectedBeforeReachingNative() { + plugin.onMethodCall(MethodCall("startVerification", mapOf()), result) + + verify { result.error("INVALID_ARGUMENT", "Token is required", null) } + verify(exactly = 0) { DiditSdk.startVerification(token = any(), configuration = any(), onResult = any()) } + } + + @Test + fun unknownMethod_isNotImplemented() { + plugin.onMethodCall(MethodCall("getPlatformVersion", null), result) - Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) + verify { result.notImplemented() } } } diff --git a/packages/didit_sdk_nfc/ios/didit_sdk_nfc/Sources/didit_sdk_nfc/SdkFlutterPlugin.swift b/packages/didit_sdk_nfc/ios/didit_sdk_nfc/Sources/didit_sdk_nfc/SdkFlutterPlugin.swift index dca6a66..9037d98 100644 --- a/packages/didit_sdk_nfc/ios/didit_sdk_nfc/Sources/didit_sdk_nfc/SdkFlutterPlugin.swift +++ b/packages/didit_sdk_nfc/ios/didit_sdk_nfc/Sources/didit_sdk_nfc/SdkFlutterPlugin.swift @@ -185,7 +185,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { // MARK: - Configuration Parsing - private func parseConfiguration(_ dict: [String: Any]?) -> DiditSdk.Configuration? { + func parseConfiguration(_ dict: [String: Any]?) -> DiditSdk.Configuration? { guard let dict = dict else { return nil } var language: SupportedLanguage? @@ -230,7 +230,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { } } - private static func mapVerificationResult(_ result: VerificationResult) -> [String: Any?] { + static func mapVerificationResult(_ result: VerificationResult) -> [String: Any?] { switch result { case .completed(let session): return [ @@ -271,6 +271,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { private static func mapErrorType(_ error: VerificationError) -> String { switch error { case .sessionExpired: return "sessionExpired" + case .retryBlocked: return "retryBlocked" case .networkError: return "networkError" case .cameraAccessDenied: return "cameraAccessDenied" case .unknown: return "unknown" diff --git a/test/example_app_config_test.dart b/test/example_app_config_test.dart new file mode 100644 index 0000000..7a00faa --- /dev/null +++ b/test/example_app_config_test.dart @@ -0,0 +1,48 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +// The example app's iOS location setup. The native Location step reads these +// keys, and iOS looks the precise-location string up by its purpose key. +void main() { + const whenInUseKey = 'NSLocationWhenInUseUsageDescription'; + const purposeKey = 'DiditLocationVerification'; + final plist = File('example/ios/Runner/Info.plist').readAsStringSync(); + + String? plistString(String source, String key) => RegExp( + '${RegExp.escape(key)}\\s*([^<]+)', + ).firstMatch(source)?.group(1); + + test('Info.plist declares the When In Use location string', () { + expect(plistString(plist, whenInUseKey), isNotNull); + }); + + test('Info.plist declares the precise-location purpose key', () { + final dictionary = RegExp( + 'NSLocationTemporaryUsageDescriptionDictionary\\s*(.*?)', + dotAll: true, + ).firstMatch(plist)?.group(1); + + expect(dictionary, isNotNull); + expect(plistString(dictionary!, purposeKey), isNotNull); + }); + + test('es.lproj/InfoPlist.strings translates both location strings', () { + final spanish = File( + 'example/ios/Runner/es.lproj/InfoPlist.strings', + ).readAsStringSync(); + + for (final key in [whenInUseKey, purposeKey]) { + expect(spanish, matches(RegExp('^"$key" = "[^"]+";\$', multiLine: true))); + } + }); + + test('the Xcode project bundles the Spanish InfoPlist.strings', () { + final project = File( + 'example/ios/Runner.xcodeproj/project.pbxproj', + ).readAsStringSync(); + + expect(project, contains('path = es.lproj/InfoPlist.strings;')); + expect(project, contains('InfoPlist.strings in Resources')); + }); +} diff --git a/test/sdk_flutter_method_channel_test.dart b/test/sdk_flutter_method_channel_test.dart index a9f37bf..8860c5e 100644 --- a/test/sdk_flutter_method_channel_test.dart +++ b/test/sdk_flutter_method_channel_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:didit_sdk/sdk_flutter.dart'; import 'package:didit_sdk/sdk_flutter_method_channel.dart'; void main() { @@ -57,6 +58,37 @@ void main() { expect(result['sessionId'], 'test-workflow-session-id'); }); + test( + 'a retryBlocked failure from the platform reaches Dart as retryBlocked', + () async { + const message = + 'You have reached the maximum number of verification attempts.'; + // The map the iOS plugin sends when the native SDK fails with retryBlocked. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + expect(methodCall.method, 'startVerificationWithWorkflow'); + return { + 'type': 'failed', + 'errorType': 'retryBlocked', + 'errorMessage': message, + 'sessionId': 'session-1', + 'status': 'Declined', + }; + }); + + final result = await DiditSdk.startVerificationWithWorkflow( + 'test-workflow', + ); + + expect(result, isA()); + final failed = result as VerificationFailed; + expect(failed.error.type, VerificationErrorType.retryBlocked); + expect(failed.error.message, message); + expect(failed.session?.sessionId, 'session-1'); + expect(failed.session?.status, VerificationStatus.declined); + }, + ); + test('submitTransaction calls platform channel with payload', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall methodCall) async { diff --git a/test/sdk_flutter_test.dart b/test/sdk_flutter_test.dart index 9127358..0915913 100644 --- a/test/sdk_flutter_test.dart +++ b/test/sdk_flutter_test.dart @@ -153,6 +153,22 @@ void main() { expect(platform.lastVerificationConfig?['languageCode'], 'he'); }); + testWidgets('both entry points propagate a Spanish device locale', ( + tester, + ) async { + tester.binding.platformDispatcher.localeTestValue = const Locale( + 'es', + 'ES', + ); + addTearDown(tester.binding.platformDispatcher.clearLocaleTestValue); + + await DiditSdk.startVerification('test-token'); + await DiditSdk.startVerificationWithWorkflow('test-workflow'); + + expect(platform.lastVerificationConfig?['languageCode'], 'es'); + expect(platform.lastWorkflowConfig?['languageCode'], 'es'); + }); + test('startVerificationWithWorkflow returns completed result', () async { final result = await DiditSdk.startVerificationWithWorkflow( 'test-workflow', @@ -180,6 +196,32 @@ void main() { expect(failed.error.message, 'The session has expired.'); }); + test('VerificationResult.fromMap keeps an unsupported step message', () { + final result = VerificationResult.fromMap({ + 'type': 'failed', + 'errorType': 'unknown', + 'errorMessage': 'Unsupported verification step', + }); + + expect(result, isA()); + final failed = result as VerificationFailed; + expect(failed.error.type, VerificationErrorType.unknown); + expect(failed.error.message, 'Unsupported verification step'); + }); + + test('VerificationResult.fromMap handles declined result', () { + final result = VerificationResult.fromMap({ + 'type': 'completed', + 'sessionId': 'declined-session', + 'status': 'Declined', + }); + + expect(result, isA()); + final completed = result as VerificationCompleted; + expect(completed.session.sessionId, 'declined-session'); + expect(completed.session.status, VerificationStatus.declined); + }); + test('VerificationResult.fromMap handles cancelled result', () { final result = VerificationResult.fromMap({ 'type': 'cancelled', diff --git a/tool/ios_bridge_test.sh b/tool/ios_bridge_test.sh new file mode 100755 index 0000000..9d6111e --- /dev/null +++ b/tool/ios_bridge_test.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Checks the iOS bridge end to end on a simulator. Dart calls +# DiditSdk.startVerificationWithWorkflow (example/integration_test/ +# retry_blocked_test.dart), the plugin starts the pinned native SDK, and the +# native SDK sends its session request to tool/stand_in_api.py, which answers +# the way the verification API answers a person with no retries left. The +# native SDK then ends the flow through its own callback, and the test checks +# the result Dart receives. +# +# macOS with Xcode and Flutter, Swift Package Manager disabled +# (`flutter config --no-enable-swift-package-manager`, as in the iOS CI job). +# It uses sudo to point the API host at this machine in /etc/hosts and restores +# the file on exit. The simulator keeps trusting the throwaway certificate +# authority, whose private key is deleted on exit. +# +# Usage: tool/ios_bridge_test.sh [simulator udid] (default: the first available iPhone) +set -euo pipefail + +cd "$(dirname "$0")/.." + +api_host=verification.didit.me +udid=${1:-$(xcrun simctl list devices available --json | python3 -c 'import json, sys; print(next(d["udid"] for runtime, devices in json.load(sys.stdin)["devices"].items() if "iOS" in runtime for d in devices if d["name"].startswith("iPhone")))')} +work=$(mktemp -d) + +flush_dns() { + sudo dscacheutil -flushcache + sudo killall -HUP mDNSResponder || true +} + +cleanup() { + if [[ -n "${server_pid:-}" ]]; then kill "$server_pid" 2>/dev/null || true; fi + if [[ -f "$work/hosts" ]]; then + sudo cp "$work/hosts" /etc/hosts + flush_dns + fi + if [[ -f "$work/server.log" ]]; then + echo "Requests the stand-in API received:" + cat "$work/server.log" + fi + rm -rf "$work" +} +trap cleanup EXIT + +# A certificate authority the simulator trusts, and a certificate for the API +# host that meets iOS's rules for TLS server certificates. +cat > "$work/ca.cnf" < "$work/server.ext" </dev/null +openssl req -new -newkey rsa:2048 -nodes -sha256 -subj "/CN=$api_host" \ + -keyout "$work/server.key" -out "$work/server.csr" 2>/dev/null +openssl x509 -req -sha256 -days 2 -in "$work/server.csr" -CA "$work/ca.pem" -CAkey "$work/ca.key" \ + -CAcreateserial -extfile "$work/server.ext" -out "$work/server.pem" 2>/dev/null + +cp /etc/hosts "$work/hosts" +printf '127.0.0.1 %s\n::1 %s\n' "$api_host" "$api_host" | sudo tee -a /etc/hosts > /dev/null +flush_dns + +python3 tool/stand_in_api.py --cert "$work/server.pem" --key "$work/server.key" 2> "$work/server.log" & +server_pid=$! + +# The host itself must reach the stand-in through the API host name before the +# simulator tries. +for _ in $(seq 50); do + if curl --silent --fail --cacert "$work/ca.pem" --request POST --data '{}' \ + "https://$api_host/v1/session/unilink/bridge-check/" > "$work/answer.json"; then + break + fi + sleep 0.2 +done +grep -q '"can_retry": false' "$work/answer.json" || { + echo "The stand-in API does not answer on https://$api_host" >&2 + exit 1 +} + +# Boot after the hosts change, then trust the certificate authority. +xcrun simctl shutdown "$udid" 2> /dev/null || true +xcrun simctl bootstatus "$udid" -b +xcrun simctl keychain "$udid" add-root-cert "$work/ca.pem" + +(cd example && flutter test integration_test/retry_blocked_test.dart -d "$udid" \ + --dart-define=DIDIT_STAND_IN_API=true) diff --git a/tool/stand_in_api.py b/tool/stand_in_api.py new file mode 100755 index 0000000..0010238 --- /dev/null +++ b/tool/stand_in_api.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""A stand-in for the verification API, for tool/ios_bridge_test.sh. + +It answers the workflow session request (POST /v1/session/unilink//) the +way the API answers a person whose earlier session was declined and who has no retries +left, and answers 404 to anything else. It logs each request's method, path and status +to stderr, never its query or body. +""" + +import argparse +import json +import socket +import ssl +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlsplit + +UNILINK_PATH = "/v1/session/unilink/" + + +def retry_blocked_session(workflow_id): + return { + "session_id": "retry-blocked-session", + "session_token": "retry-blocked-token", + "url": "https://verify.didit.me/session/retry-blocked-token", + "status": "Declined", + "workflow_id": workflow_id, + "workflow_label": "Bridge test", + "requires_email": False, + "is_existing_session": True, + "can_retry": False, + "retry_attempts_used": 3, + "max_retry_attempts": 2, + "retry_window_days": None, + "retry_resets_at": None, + } + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + self.rfile.read(int(self.headers.get("Content-Length") or 0)) + path = urlsplit(self.path).path + if path.startswith(UNILINK_PATH): + self.reply(200, retry_blocked_session(path[len(UNILINK_PATH):].strip("/"))) + else: + self.reply(404, {"detail": "Not found."}) + + def do_GET(self): + self.reply(404, {"detail": "Not found."}) + + def reply(self, status, body): + data = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_request(self, code="-", size="-"): + sys.stderr.write(f"{self.command} {urlsplit(self.path).path} {code}\n") + + +class DualStackServer(ThreadingHTTPServer): + # The API host points at both 127.0.0.1 and ::1, so listen on both. + address_family = socket.AF_INET6 + + def server_bind(self): + self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) + super().server_bind() + + def get_request(self): + try: + return super().get_request() + except ssl.SSLError as error: + sys.stderr.write(f"TLS handshake failed: {error}\n") + raise + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", type=int, default=443) + parser.add_argument("--cert", required=True, help="server certificate (PEM)") + parser.add_argument("--key", required=True, help="server private key (PEM)") + args = parser.parse_args() + + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(args.cert, args.key) + server = DualStackServer(("::", args.port), Handler) + server.socket = context.wrap_socket(server.socket, server_side=True) + server.serve_forever() + + +if __name__ == "__main__": + main()