diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ded2c5a..bf642ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,7 @@ jobs: run: yarn prepare build-android: + name: Released Android compatibility runs-on: ubuntu-latest env: @@ -70,6 +71,17 @@ jobs: - name: Setup uses: ./.github/actions/setup + # Public CI checks the released native ABI. Source integration is checked + # separately with the pinned checkout; released binaries lack the new steps. + - name: Select released native SDK for compatibility check + run: | + node - <<'JS' + const fs = require('fs'); + const pkg = require('./package.json'); + delete pkg.diditNativeSdkVersions.source; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + JS + - name: Cache turborepo for Android uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: @@ -117,11 +129,13 @@ jobs: yarn turbo run build:android --cache-dir="${{ env.TURBO_CACHE_DIR }}" build-ios: + name: Released iOS compatibility and bridge tests runs-on: macos-latest env: XCODE_VERSION: 26 TURBO_CACHE_DIR: .turbo/ios + DIDIT_SDK_IOS_LOCATION_ENABLED: "false" RCT_USE_RN_DEP: 1 RCT_USE_PREBUILT_RNCORE: 1 @@ -132,6 +146,17 @@ jobs: - name: Setup uses: ./.github/actions/setup + # Public CI checks the released native ABI. Source integration is checked + # separately with the pinned checkout; released binaries lack the new steps. + - name: Select released native SDK for compatibility check + run: | + node - <<'JS' + const fs = require('fs'); + const pkg = require('./package.json'); + delete pkg.diditNativeSdkVersions.source; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + JS + - name: Cache turborepo for iOS uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: @@ -149,13 +174,11 @@ jobs: fi - name: Use appropriate Xcode version - if: env.turbo_cache_hit != 1 uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 with: xcode-version: ${{ env.XCODE_VERSION }} - name: Install cocoapods - if: env.turbo_cache_hit != 1 && steps.cocoapods-cache.outputs.cache-hit != 'true' run: | cd example/ios bundle install @@ -164,3 +187,15 @@ jobs: - name: Build example for iOS run: | yarn turbo run build:ios --cache-dir="${{ env.TURBO_CACHE_DIR }}" + + - name: Test iOS bridge against released native SDK + working-directory: example/ios + run: | + set -o pipefail + SIMULATOR_ID=$(xcrun simctl list devices available | grep -E '^[[:space:]]+iPhone' | grep -oE '[0-9A-F]{8}(-[0-9A-F]{4}){3}-[0-9A-F]{12}' | tail -n 1) + xcodebuild test \ + -workspace SdkReactNativeExample.xcworkspace \ + -scheme SdkReactNativeExample \ + -configuration Debug \ + -destination "id=$SIMULATOR_ID" \ + -only-testing:SdkReactNativeExampleTests | xcbeautify diff --git a/.gitignore b/.gitignore index c1e32f9..5937069 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,6 @@ android/generated # React Native Nitro Modules nitrogen/ + +# Native integration artifacts built from the pinned source checkout +.native-sdk/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1b384f9..cb9d526 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,6 +90,16 @@ Remember to add tests for your change if possible. Run the unit tests by: yarn test ``` +The iOS bridge has XCTests in `example/ios/SdkReactNativeExampleTests`, hosted by the example app. They exercise the native result delivery callback, including `retryBlocked`, and read the built app's location purpose strings. On a Mac, run them after `bundle exec pod install` in `example/ios`: + +```sh +cd example/ios +xcodebuild test \ + -workspace SdkReactNativeExample.xcworkspace \ + -scheme SdkReactNativeExample \ + -destination 'platform=iOS Simulator,name=' +``` + ### Commit message convention @@ -128,7 +138,13 @@ The wrapper version (`version` in `package.json`) and the native Didit SDK versi This one field drives both iOS resolution paths: the CocoaPods raw-podspec URL and the SwiftPM `exactVersion` requirement (`$DiditSdkIosLinkage = 'spm'`). A bump must therefore have both a matching git tag **and** the SPM release assets (`DiditSDK*.xcframework.zip`) published on that `sdk-ios` release, or SwiftPM consumers fail to resolve. -`SdkReactNative.podspec` and `app.plugin.js` both derive the iOS pin from `diditNativeSdkVersions.ios`, so bumping it there is enough to move the podspec dependency and the sdk-ios podspec URL together. The remaining hand-maintained pins - `android/build.gradle`, the README podspec URL, and the example Podfiles - are asserted against these values by `src/__tests__/native-sdk-pins.test.ts`, so `yarn test` fails if any of them drift (see issues #19 and #28). Only bump `diditNativeSdkVersions.ios` once the matching tag exists in `didit-protocol/sdk-ios`. +`SdkReactNative.podspec` and `app.plugin.js` derive the iOS pin from `diditNativeSdkVersions.ios`; `android/build.gradle` reads `diditNativeSdkVersions.android`. The README's release URL is checked by `src/__tests__/native-sdk-pins.test.ts`. Only bump release pins once the matching native artifacts exist. + +During Bank and Location integration, `diditNativeSdkVersions.source` selects a branch at an immutable revision. Set `DIDIT_SDK_SOURCE_PATH` to that clean native checkout. Run `yarn native:android` to publish local Maven artifacts with the native project's own Gradle wrapper. Both examples consume that repository and the source CocoaPod; they reject missing, modified, or mismatched source. No native source is copied into this package. Run both Android examples sequentially, plus the native Bank and Location unit and JVM screenshot suites using that checkout's Gradle wrapper. + +For release-binary compatibility testing only, use a disposable wrapper checkout without the `source` field, set `DIDIT_SDK_IOS_LOCATION_ENABLED=false`, and run the same iOS bridge XCTests against the declared release. That checks the `retryBlocked` ABI and callback; it does not verify Bank or Location. Public CI explicitly performs this released-binary check. Integration builds and OS permission captures require the pinned source checkout and a Mac for iOS. Verify English and Spanish when-in-use and temporary precise-location prompts on a device; string-resource tests do not prove the OS dialogs. + +Before the stacked push, remove the temporary `source` pin, update the released versions, regenerate both examples, refresh CocoaPods lockfiles on a Mac, and repeat the native callback, permission, build and screenshot checks. Enable Location using CocoaPods until a released SwiftPM Location product is supported here. ### Scripts diff --git a/README.md b/README.md index 084ba19..b2ddbdc 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ A React Native wrapper for the Didit Identity Verification SDK. Supports both iO ## Permissions -The SDK uses the camera, location, and optionally NFC on both platforms. Native permissions are declared by the underlying native SDKs and merged automatically where the platform supports it. +The SDK uses the camera, optionally NFC, and location only for the Location verification step (see [Bank and Location verification steps](#bank-and-location-verification-steps)). Native permissions are declared by the underlying native SDKs and merged automatically where the platform supports it. ### iOS @@ -85,6 +85,90 @@ The SDK handles Android runtime permission requests automatically. When the user You do not need to request camera permission in your app code before calling `startVerification()` — the SDK manages this internally. +## Bank and Location verification steps + +Workflows can include a **Bank** step (the person links a bank account) and a **Location** step (the device position is read once, with the person's consent). Both screens are drawn by the native SDKs, so this package needs no new API: they run inside `startVerification()` / `startVerificationWithWorkflow()`, and the outcome comes back as the same `VerificationResult`. + +> **Development preview:** these steps currently use the native integration source pinned in `diditNativeSdkVersions.source` in `package.json`. Set `DIDIT_SDK_SOURCE_PATH` to a clean checkout at that revision before building either example. A missing or different checkout fails the build instead of substituting the 4.9.1 release, which does not contain these steps. The temporary source pin must move to the released native version before the stacked push and package release. + +For Android source builds, apply this package's `android/native-source.gradle` from the host's `settings.gradle`. First run `yarn native:android` to build the pinned native revision with its own Gradle wrapper. The settings script exclusively resolves Didit artifacts from that local Maven repository, preventing a fallback to released artifacts. Both examples already do this; Expo prebuild adds it automatically. + +### Bank + +No setup. The SDK opens the bank's approval page in the system browser (a Custom Tab on Android, Safari in a sheet on iOS) and checks the result itself when the person comes back, so there is no permission, URL scheme or deep link to register. + +### Location on iOS + +Location lives in the native SDK's optional `DiditSDK/Location` module, which is not included in the `all` variant. Use CocoaPods with `$DiditSdkIosLocationEnabled = true` before `use_native_modules!`, and declare both the selected variant and `DiditSDK/Location` with the same source path. The bare example's Podfile shows this setup. Expo users set `iosLocationEnabled: true` in this package's plugin options and run prebuild. SwiftPM linkage currently cannot opt into Location. Without the module, or without `NSLocationWhenInUseUsageDescription`, the step shows no prompt, reports that the device cannot place the person, and the workflow's own rules decide. + +Add the purpose strings to `Info.plist`. `NSLocationTemporaryUsageDescriptionDictionary` is optional: it lets the step ask once for precise location when the person granted only an approximate one (iOS 14+), and its 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. + +``` + +iOS picks these strings by the device language, not by the SDK's `languageCode`, so translate them in each `.lproj/InfoPlist.strings`. The precise-location string is translated under 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."; +``` + +With Expo, set the same keys in `app.json` and translate them with [`locales`](https://docs.expo.dev/guides/localization/#translating-app-metadata): + +```json +{ + "expo": { + "plugins": [ + ["@didit-protocol/sdk-react-native", { "iosLocationEnabled": true }] + ], + "ios": { + "infoPlist": { + "NSLocationWhenInUseUsageDescription": "Your location is used to confirm where you are for this verification.", + "NSLocationTemporaryUsageDescriptionDictionary": { + "DiditLocationVerification": "This verification needs your precise location once." + } + } + }, + "locales": { + "es": "./locales/es.json" + } + } +} +``` + +where `locales/es.json` is: + +```json +{ + "ios": { + "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." + } +} +``` + +Both example apps in this repository declare these strings in English and Spanish. + +Once your app links the module, declare **Precise Location** and **Coarse Location** in App Store Connect under **App Functionality**, linked to the user and not used for tracking. The SDK reads one position per attempt, only while the step is on screen, and never in the background. + +### Location on Android + +The native SDK declares `ACCESS_FINE_LOCATION` and `ACCESS_COARSE_LOCATION`, plus the location hardware features as optional (`android:required="false"`), and they merge into your app automatically. It asks for them at runtime only when a workflow reaches the step, after a screen that explains why, and the person can grant precise or approximate location. If your app never runs the step, you can remove them from the merged manifest (the `` element needs `xmlns:tools="http://schemas.android.com/tools"`): + +```xml + + +``` + +A session that still reaches the step then reports the location as unavailable, without a prompt. If you keep them, declare **Approximate location** and **Precise location** as collected data in your Google Play Data safety form. + ## Native SDK Variants NFC and auto-detection are enabled by default on both platforms. @@ -526,6 +610,7 @@ Both `startVerification` and `startVerificationWithWorkflow` return a `Promise + repositories.exclusiveContent { + forRepository { repositories.maven { url = uri(nativeRepository) } } + filter { includeGroup('me.didit') } + } + } + configureRepository(dependencyResolutionManagement.repositories) + gradle.beforeProject { project -> configureRepository(project.repositories) } +} diff --git a/android/src/main/java/com/sdkreactnative/SdkReactNativeModule.kt b/android/src/main/java/com/sdkreactnative/SdkReactNativeModule.kt index cad8498..b44f411 100644 --- a/android/src/main/java/com/sdkreactnative/SdkReactNativeModule.kt +++ b/android/src/main/java/com/sdkreactnative/SdkReactNativeModule.kt @@ -46,24 +46,23 @@ class SdkReactNativeModule(reactContext: ReactApplicationContext) : config: ReadableMap?, promise: Promise ) { - Log.d(TAG, "startVerification: token=${token.take(8)}..., config=$config") + logDebug("startVerification: started") val activity = reactApplicationContext.currentActivity scope.launch { try { val configuration = parseConfiguration(config) - Log.d(TAG, "startVerification: parsed configuration=$configuration") DiditSdk.startVerification( token = token, configuration = configuration ) { result -> - Log.d(TAG, "startVerification: onResult callback fired, type=${result::class.simpleName}") + logDebug("startVerification: onResult callback fired, type=${result::class.simpleName}") promise.resolve(mapVerificationResult(result)) } awaitReadyAndLaunchUI(promise, activity) } catch (e: Exception) { - Log.e(TAG, "startVerification: exception", e) + logDebugError("startVerification: exception", e) rejectWithError(promise, e) } } @@ -80,26 +79,24 @@ class SdkReactNativeModule(reactContext: ReactApplicationContext) : config: ReadableMap?, promise: Promise ) { - Log.d(TAG, "startVerificationWithWorkflow: workflowId=$workflowId, vendorData=$vendorData, metadata=$metadata") - Log.d(TAG, "startVerificationWithWorkflow: contactDetails=$contactDetails, expectedDetails=$expectedDetails, config=$config") + logDebug("startVerificationWithWorkflow: started") val activity = reactApplicationContext.currentActivity scope.launch { try { val configuration = parseConfiguration(config) - Log.d(TAG, "startVerificationWithWorkflow: parsed configuration=$configuration") DiditSdk.startVerification( workflowId = workflowId, vendorData = vendorData, configuration = configuration ) { result -> - Log.d(TAG, "startVerificationWithWorkflow: onResult callback fired, type=${result::class.simpleName}") + logDebug("startVerificationWithWorkflow: onResult callback fired, type=${result::class.simpleName}") promise.resolve(mapVerificationResult(result)) } awaitReadyAndLaunchUI(promise, activity) } catch (e: Exception) { - Log.e(TAG, "startVerificationWithWorkflow: exception", e) + logDebugError("startVerificationWithWorkflow: exception", e) rejectWithError(promise, e) } } @@ -114,7 +111,7 @@ class SdkReactNativeModule(reactContext: ReactApplicationContext) : */ private suspend fun awaitReadyAndLaunchUI(promise: Promise, activity: android.app.Activity?) { if (activity == null) { - Log.e(TAG, "awaitReadyAndLaunchUI: no active Activity at call time") + logDebugError("awaitReadyAndLaunchUI: no active Activity at call time") val errorResult = mapVerificationResult( VerificationResult.Failed( error = VerificationError.Unknown("No active Activity available to present verification UI."), @@ -129,15 +126,15 @@ class SdkReactNativeModule(reactContext: ReactApplicationContext) : val stateReached = withTimeoutOrNull(TIMEOUT_MS) { DiditSdk.state.first { state -> - Log.d(TAG, "awaitReadyAndLaunchUI: SDK state = $state") + logDebug("awaitReadyAndLaunchUI: SDK state = ${state::class.simpleName}") when (state) { is DiditSdkState.Ready -> { - Log.d(TAG, "awaitReadyAndLaunchUI: launching verification UI") + logDebug("awaitReadyAndLaunchUI: launching verification UI") DiditSdk.launchVerificationUI(activity) true } is DiditSdkState.Error -> { - Log.e(TAG, "awaitReadyAndLaunchUI: SDK entered Error state: ${state.message}") + logDebugError("awaitReadyAndLaunchUI: SDK entered Error state") val errorResult = mapVerificationResult( VerificationResult.Failed( error = VerificationError.Unknown(state.message ?: "SDK entered error state."), @@ -153,7 +150,7 @@ class SdkReactNativeModule(reactContext: ReactApplicationContext) : } if (stateReached == null) { - Log.e(TAG, "awaitReadyAndLaunchUI: timed out waiting for SDK state after ${TIMEOUT_MS}ms") + logDebugError("awaitReadyAndLaunchUI: timed out waiting for SDK state after ${TIMEOUT_MS}ms") val errorResult = mapVerificationResult( VerificationResult.Failed( error = VerificationError.Unknown("Timed out waiting for verification SDK to become ready."), @@ -275,7 +272,7 @@ class SdkReactNativeModule(reactContext: ReactApplicationContext) : ) promise.resolve(TransactionJson.resultJson(result)) } catch (e: Exception) { - Log.e(TAG, "submitTransaction: exception", e) + logDebugError("submitTransaction: exception", e) rejectTransactionError(promise, e) } } @@ -299,7 +296,7 @@ class SdkReactNativeModule(reactContext: ReactApplicationContext) : ) promise.resolve(TransactionJson.resultJson(result)) } catch (e: Exception) { - Log.e(TAG, "getTransaction: exception", e) + logDebugError("getTransaction: exception", e) rejectTransactionError(promise, e) } } @@ -325,6 +322,23 @@ class SdkReactNativeModule(reactContext: ReactApplicationContext) : } } + // ─── Diagnostics ───────────────────────────────────────────────────────── + + /** + * Debug-build-only logging. The release build type does not minify, so + * nothing strips a plain Log call: every diagnostic goes through these two + * helpers, and no call site may pass a session token, personal data or a + * native error message. src/__tests__/android-logging-privacy.test.ts fails + * if one does. + */ + private fun logDebug(message: String) { + if (BuildConfig.DEBUG) Log.d(TAG, message) + } + + private fun logDebugError(message: String, error: Throwable? = null) { + if (BuildConfig.DEBUG) Log.e(TAG, message, error) + } + companion object { const val NAME = NativeSdkReactNativeSpec.NAME private const val TAG = "DiditSdkRN" diff --git a/app.plugin.js b/app.plugin.js index fcf5853..82aadc2 100644 --- a/app.plugin.js +++ b/app.plugin.js @@ -91,6 +91,7 @@ function normalizeOptions(props = {}) { iosVariant: normalizeVariant(props.iosVariant, legacyIosVariant), iosLinkage: normalizeLinkage(props.iosLinkage), iosPodspecUrl: normalizePodspecUrl(props.iosPodspecUrl), + iosLocationEnabled: props.iosLocationEnabled === true, }; } @@ -144,8 +145,18 @@ function diditPodBlock(iosVariant, options, isRubyExpression = false) { const lines = [ variantAssignment, ` $DiditSdkIosLinkage = '${options.iosLinkage}'`, + ` $DiditSdkIosLocationEnabled = ${options.iosLocationEnabled}`, ]; + if ( + options.iosLinkage === 'spm' && + (options.iosLocationEnabled || pkg.diditNativeSdkVersions.source) + ) { + throw new Error( + 'Location and native source builds require cocoapods iosLinkage.' + ); + } + if (options.iosLinkage === 'spm') { // SdkReactNative.podspec declares DiditSDK as a SwiftPM dependency, so the // Podfile needs no pod declaration - only the globals it reads. @@ -160,10 +171,23 @@ function diditPodBlock(iosVariant, options, isRubyExpression = false) { " when 'nfc' then 'DiditSDK/NFC'", ' else', ' raise "Invalid $DiditSdkIosVariant \'#{$DiditSdkIosVariant}\'. Supported values: all, core, autodetection, nfc."', - ' end', - ` pod didit_sdk_subspec, :podspec => '${options.iosPodspecUrl}'` + ' end' ); + if (pkg.diditNativeSdkVersions.source) { + lines.push( + ` didit_source_script = Pod::Executable.execute_command('node', ['--print', "require.resolve('@didit-protocol/sdk-react-native/native-sdk-source')"]).strip`, + ` didit_source_path = Pod::Executable.execute_command('node', [didit_source_script, 'ios']).strip`, + ' pod didit_sdk_subspec, :path => didit_source_path', + " pod 'DiditSDK/Location', :path => didit_source_path if $DiditSdkIosLocationEnabled" + ); + } else { + lines.push( + ` pod didit_sdk_subspec, :podspec => '${options.iosPodspecUrl}'`, + ` pod 'DiditSDK/Location', :podspec => '${options.iosPodspecUrl}' if $DiditSdkIosLocationEnabled` + ); + } + return lines.join('\n'); } @@ -219,6 +243,18 @@ function withDiditSettingsGradle(config) { }); } +function withDiditNativeSource(config) { + return withSettingsGradle(config, (mod) => { + const line = `apply from: new File(providers.exec { commandLine('node', '--print', "require.resolve('@didit-protocol/sdk-react-native/package.json')") }.standardOutput.asText.get().trim()).toPath().resolveSibling('android/native-source.gradle').toFile()`; + + if (!mod.modResults.contents.includes('android/native-source.gradle')) { + mod.modResults.contents += `\n${line}\n`; + } + + return mod; + }); +} + /** * Adds BouncyCastle dependency exclusions and packaging rules to * android/app/build.gradle to avoid duplicate class and META-INF @@ -298,6 +334,7 @@ function withDiditAndroidVariantProperty(config, androidVariant) { function withDiditAndroidMaven(config, options) { config = withDiditBuildGradle(config); config = withDiditSettingsGradle(config); + config = withDiditNativeSource(config); config = withDiditAndroidVariantProperty(config, options.androidVariant); config = withDiditPackagingExclusion(config); return config; diff --git a/build-native-android.js b/build-native-android.js new file mode 100644 index 0000000..e09e664 --- /dev/null +++ b/build-native-android.js @@ -0,0 +1,50 @@ +const { spawnSync } = require('child_process'); +const { mkdirSync, rmSync, writeFileSync } = require('fs'); +const { join } = require('path'); +const { + resolveNativeSdkSource, + androidRepositoryPath, +} = require('./native-sdk-source'); +const { diditNativeSdkVersions } = require('./package.json'); +const nativePath = resolveNativeSdkSource('android'); + +if (!nativePath) throw new Error('No native source pin is configured.'); + +const repository = androidRepositoryPath(); +const marker = join(repository, 'source.json'); +const modules = [ + 'sdk', + 'sdk-core', + 'sdk-autodetection', + 'sdk-nfc', + 'sdk-wallet', +]; +const args = [ + ...modules.map( + (name) => `:${name}:publishReleasePublicationToLocalRepository` + ), + `-PsdkRepoDir=${repository}`, + '--no-daemon', + '--console=plain', + '--max-workers=2', +]; + +function publish() { + const result = spawnSync(join(nativePath, 'gradlew'), args, { + cwd: nativePath, + stdio: 'inherit', + }); + + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status || 1); +} + +mkdirSync(repository, { recursive: true }); +rmSync(marker, { force: true }); + +publish(); +resolveNativeSdkSource('android'); +writeFileSync( + marker, + JSON.stringify({ revision: diditNativeSdkVersions.source.revision }) + '\n' +); diff --git a/example-expo/App.tsx b/example-expo/App.tsx index 503c51b..84874a7 100644 --- a/example-expo/App.tsx +++ b/example-expo/App.tsx @@ -19,7 +19,7 @@ import { } from '@didit-protocol/sdk-react-native'; export default function App() { - const [token, setToken] = useState('tjtg3LsqyY-N'); + const [token, setToken] = useState(''); const [workflowId, setWorkflowId] = useState(''); const [loading, setLoading] = useState(false); const [result, setResult] = useState(null); diff --git a/example-expo/android/settings.gradle b/example-expo/android/settings.gradle index 92aa53c..57db5c5 100644 --- a/example-expo/android/settings.gradle +++ b/example-expo/android/settings.gradle @@ -37,3 +37,5 @@ expoAutolinking.useExpoVersionCatalog() include ':app' includeBuild(expoAutolinking.reactNativeGradlePlugin) + +apply from: new File(providers.exec { commandLine('node', '--print', "require.resolve('@didit-protocol/sdk-react-native/package.json')") }.standardOutput.asText.get().trim()).toPath().resolveSibling('android/native-source.gradle').toFile() diff --git a/example-expo/app.json b/example-expo/app.json index 5e52d84..6adbfa3 100644 --- a/example-expo/app.json +++ b/example-expo/app.json @@ -10,6 +10,7 @@ "@didit-protocol/sdk-react-native", { "iosVariant": "all", + "iosLocationEnabled": true, "androidVariant": "all" } ] @@ -22,6 +23,10 @@ "NSMicrophoneUsageDescription": "Microphone access is required to record video for liveness verification.", "NSPhotoLibraryUsageDescription": "Photo library access is required to upload document images.", "NFCReaderUsageDescription": "NFC is used to read passport chip data for identity verification.", + "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", "A0000002471001", @@ -30,6 +35,9 @@ ] } }, + "locales": { + "es": "./locales/es.json" + }, "android": { "package": "me.didit.sdk.reactnative.expoexample", "adaptiveIcon": { diff --git a/example-expo/ios/DiditExpoExample.xcodeproj/project.pbxproj b/example-expo/ios/DiditExpoExample.xcodeproj/project.pbxproj index f3b4737..5ed2ec0 100644 --- a/example-expo/ios/DiditExpoExample.xcodeproj/project.pbxproj +++ b/example-expo/ios/DiditExpoExample.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; D3B2F5EDC02B66C5935838A5 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A92FAC627B51295BC573C26A /* ExpoModulesProvider.swift */; }; F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; + 08781A61616F403BA05CE643 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 9602DDAD615B4729B9401021 /* InfoPlist.strings */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -30,6 +31,7 @@ ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = DiditExpoExample/AppDelegate.swift; sourceTree = ""; }; F11748442D0722820044C1D9 /* DiditExpoExample-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "DiditExpoExample-Bridging-Header.h"; path = "DiditExpoExample/DiditExpoExample-Bridging-Header.h"; sourceTree = ""; }; + 9602DDAD615B4729B9401021 /* InfoPlist.strings */ = {isa = PBXFileReference; name = "InfoPlist.strings"; path = "es.lproj/InfoPlist.strings"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = text.plist.strings; explicitFileType = undefined; includeInIndex = 0; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -127,11 +129,21 @@ isa = PBXGroup; children = ( BB2F792C24A3F905000567C9 /* Expo.plist */, + BBEEB4F65AD04F14A9D6BBC7 /* es.lproj */, ); name = Supporting; path = DiditExpoExample/Supporting; sourceTree = ""; }; + BBEEB4F65AD04F14A9D6BBC7 /* es.lproj */ = { + isa = "PBXGroup"; + children = ( + 9602DDAD615B4729B9401021 /* InfoPlist.strings */, + ); + name = es.lproj; + sourceTree = ""; + path = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -197,6 +209,7 @@ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, 36DE53FAFBF2A3780CD20F58 /* PrivacyInfo.xcprivacy in Resources */, + 08781A61616F403BA05CE643 /* InfoPlist.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/example-expo/ios/DiditExpoExample/Info.plist b/example-expo/ios/DiditExpoExample/Info.plist index d56a660..c42c63e 100644 --- a/example-expo/ios/DiditExpoExample/Info.plist +++ b/example-expo/ios/DiditExpoExample/Info.plist @@ -48,6 +48,13 @@ NSCameraUsageDescription Camera access is required for identity verification to capture documents and facial photos. + NSLocationTemporaryUsageDescriptionDictionary + + DiditLocationVerification + This verification needs your precise location once. + + NSLocationWhenInUseUsageDescription + Your location is used to confirm where you are for this verification. NSMicrophoneUsageDescription Microphone access is required to record video for liveness verification. NSPhotoLibraryUsageDescription diff --git a/example-expo/ios/DiditExpoExample/Supporting/es.lproj/InfoPlist.strings b/example-expo/ios/DiditExpoExample/Supporting/es.lproj/InfoPlist.strings new file mode 100644 index 0000000..2c27f7e --- /dev/null +++ b/example-expo/ios/DiditExpoExample/Supporting/es.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +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."; diff --git a/example-expo/ios/Podfile b/example-expo/ios/Podfile index 511fa4c..e5b2cba 100644 --- a/example-expo/ios/Podfile +++ b/example-expo/ios/Podfile @@ -21,7 +21,10 @@ platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1' prepare_react_native_project! target 'DiditExpoExample' do - $DiditSdkIosVariant = 'all' +# @generated begin didit-sdk-react-native-pod - expo prebuild (DO NOT MODIFY) sync-d257bf333a2351b6f6bdf0abd31a5390f878696b + $DiditSdkIosVariant = podfile_properties.fetch('didit.iosVariant', 'all') + $DiditSdkIosLinkage = 'cocoapods' + $DiditSdkIosLocationEnabled = true didit_sdk_subspec = case $DiditSdkIosVariant when 'all' then 'DiditSDK/All' when 'core' then 'DiditSDK/Core' @@ -30,8 +33,11 @@ target 'DiditExpoExample' do else raise "Invalid $DiditSdkIosVariant '#{$DiditSdkIosVariant}'. Supported values: all, core, autodetection, nfc." end - pod didit_sdk_subspec, :podspec => 'https://raw.githubusercontent.com/didit-protocol/sdk-ios/4.9.1/DiditSDK.podspec' - + didit_source_script = Pod::Executable.execute_command('node', ['--print', "require.resolve('@didit-protocol/sdk-react-native/native-sdk-source')"]).strip + didit_source_path = Pod::Executable.execute_command('node', [didit_source_script, 'ios']).strip + pod didit_sdk_subspec, :path => didit_source_path + pod 'DiditSDK/Location', :path => didit_source_path if $DiditSdkIosLocationEnabled +# @generated end didit-sdk-react-native-pod use_expo_modules! if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1' diff --git a/example-expo/ios/Podfile.properties.json b/example-expo/ios/Podfile.properties.json index de9f7b7..924456a 100644 --- a/example-expo/ios/Podfile.properties.json +++ b/example-expo/ios/Podfile.properties.json @@ -1,4 +1,5 @@ { "expo.jsEngine": "hermes", - "EX_DEV_CLIENT_NETWORK_INSPECTOR": "true" + "EX_DEV_CLIENT_NETWORK_INSPECTOR": "true", + "didit.iosVariant": "all" } diff --git a/example-expo/locales/es.json b/example-expo/locales/es.json new file mode 100644 index 0000000..a0d3e3e --- /dev/null +++ b/example-expo/locales/es.json @@ -0,0 +1,6 @@ +{ + "ios": { + "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." + } +} diff --git a/example/android/settings.gradle b/example/android/settings.gradle index 2e80aef..6d3b86f 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -17,3 +17,5 @@ dependencyResolutionManagement { rootProject.name = 'sdkreactnative.example' include ':app' includeBuild('../node_modules/@react-native/gradle-plugin') + +apply from: file("../../android/native-source.gradle") diff --git a/example/ios/Podfile b/example/ios/Podfile index 8d784cf..2f30252 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -24,9 +24,9 @@ if linkage != nil end target 'SdkReactNativeExample' do - config = use_native_modules! - - didit_sdk_ios_path = ENV['DIDIT_SDK_IOS_PATH'] + didit_sdk_ios_path = Pod::Executable.execute_command('node', [File.expand_path('../../native-sdk-source.js', __dir__), 'ios']).strip + didit_sdk_ios_path = ENV['DIDIT_SDK_IOS_PATH'] if didit_sdk_ios_path.empty? + $DiditSdkIosLocationEnabled = ENV.fetch('DIDIT_SDK_IOS_LOCATION_ENABLED', 'true').downcase == 'true' if didit_sdk_ios_path.nil? || didit_sdk_ios_path.empty? # DiditSDK is resolved from the sdk-ios git tag through SwiftPM by # SdkReactNative.podspec, so no pod declaration is needed here. @@ -43,14 +43,22 @@ target 'SdkReactNativeExample' do raise "Invalid $DiditSdkIosVariant '#{$DiditSdkIosVariant}'. Supported values: all, core, autodetection, nfc." end pod didit_sdk_subspec, :path => didit_sdk_ios_path + pod 'DiditSDK/Location', :path => didit_sdk_ios_path if $DiditSdkIosLocationEnabled end + config = use_native_modules! + use_react_native!( :path => config[:reactNativePath], # An absolute path to your application root. :app_path => "#{Pod::Config.instance.installation_root}/.." ) + # The bridge's XCTests run inside the example app, which already links the pods. + target 'SdkReactNativeExampleTests' do + inherit! :search_paths + end + post_install do |installer| react_native_post_install( installer, diff --git a/example/ios/SdkReactNativeExample.xcodeproj/project.pbxproj b/example/ios/SdkReactNativeExample.xcodeproj/project.pbxproj index 87b72cc..25a0ec0 100644 --- a/example/ios/SdkReactNativeExample.xcodeproj/project.pbxproj +++ b/example/ios/SdkReactNativeExample.xcodeproj/project.pbxproj @@ -7,14 +7,29 @@ objects = { /* Begin PBXBuildFile section */ + 00E356F31AD99517003FC87E /* DiditSdkBridgeResultTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E356F21AD99517003FC87E /* DiditSdkBridgeResultTests.swift */; }; 0C80B921A6F3F58F76C31292 /* libPods-SdkReactNativeExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-SdkReactNativeExample.a */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 22ABB861F5A1DCF99DA64262 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; + 2C0F1C6B0FF2962C9D0B282D /* libPods-SdkReactNativeExample-SdkReactNativeExampleTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 65CD951716839465CA77588F /* libPods-SdkReactNativeExample-SdkReactNativeExampleTests.a */; }; 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; + A1C3E5F72E9B4D6100D1D1A1 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = A1C3E5F52E9B4D6100D1D1A1 /* InfoPlist.strings */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 00E356F41AD99517003FC87E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 13B07F861A680F5B00A75B9A; + remoteInfo = SdkReactNativeExample; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ + 00E356EE1AD99517003FC87E /* SdkReactNativeExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SdkReactNativeExampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 00E356F21AD99517003FC87E /* DiditSdkBridgeResultTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiditSdkBridgeResultTests.swift; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* SdkReactNativeExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SdkReactNativeExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = SdkReactNativeExample/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = SdkReactNativeExample/Info.plist; sourceTree = ""; }; @@ -22,12 +37,24 @@ 3B4392A12AC88292D35C810B /* Pods-SdkReactNativeExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SdkReactNativeExample.debug.xcconfig"; path = "Target Support Files/Pods-SdkReactNativeExample/Pods-SdkReactNativeExample.debug.xcconfig"; sourceTree = ""; }; 5709B34CF0A7D63546082F79 /* Pods-SdkReactNativeExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SdkReactNativeExample.release.xcconfig"; path = "Target Support Files/Pods-SdkReactNativeExample/Pods-SdkReactNativeExample.release.xcconfig"; sourceTree = ""; }; 5DCACB8F33CDC322A6C60F78 /* libPods-SdkReactNativeExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-SdkReactNativeExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 65CD951716839465CA77588F /* libPods-SdkReactNativeExample-SdkReactNativeExampleTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-SdkReactNativeExample-SdkReactNativeExampleTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 6CF1C5F710D90A466B8CE570 /* Pods-SdkReactNativeExample-SdkReactNativeExampleTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SdkReactNativeExample-SdkReactNativeExampleTests.debug.xcconfig"; path = "Target Support Files/Pods-SdkReactNativeExample-SdkReactNativeExampleTests/Pods-SdkReactNativeExample-SdkReactNativeExampleTests.debug.xcconfig"; sourceTree = ""; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = SdkReactNativeExample/AppDelegate.swift; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = SdkReactNativeExample/LaunchScreen.storyboard; sourceTree = ""; }; + A1C3E5F62E9B4D6100D1D1A1 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; + D0E8BFFA6CE874103AF34566 /* Pods-SdkReactNativeExample-SdkReactNativeExampleTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SdkReactNativeExample-SdkReactNativeExampleTests.release.xcconfig"; path = "Target Support Files/Pods-SdkReactNativeExample-SdkReactNativeExampleTests/Pods-SdkReactNativeExample-SdkReactNativeExampleTests.release.xcconfig"; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 00E356EB1AD99517003FC87E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2C0F1C6B0FF2962C9D0B282D /* libPods-SdkReactNativeExample-SdkReactNativeExampleTests.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -39,12 +66,21 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 00E356EF1AD99517003FC87E /* SdkReactNativeExampleTests */ = { + isa = PBXGroup; + children = ( + 00E356F21AD99517003FC87E /* DiditSdkBridgeResultTests.swift */, + ); + path = SdkReactNativeExampleTests; + sourceTree = ""; + }; 13B07FAE1A68108700A75B9A /* SdkReactNativeExample */ = { isa = PBXGroup; children = ( 13B07FB51A68108700A75B9A /* Images.xcassets */, 761780EC2CA45674006654EE /* AppDelegate.swift */, 13B07FB61A68108700A75B9A /* Info.plist */, + A1C3E5F52E9B4D6100D1D1A1 /* InfoPlist.strings */, 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */, 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */, ); @@ -56,6 +92,7 @@ children = ( ED297162215061F000B7C4FE /* JavaScriptCore.framework */, 5DCACB8F33CDC322A6C60F78 /* libPods-SdkReactNativeExample.a */, + 65CD951716839465CA77588F /* libPods-SdkReactNativeExample-SdkReactNativeExampleTests.a */, ); name = Frameworks; sourceTree = ""; @@ -71,6 +108,7 @@ isa = PBXGroup; children = ( 13B07FAE1A68108700A75B9A /* SdkReactNativeExample */, + 00E356EF1AD99517003FC87E /* SdkReactNativeExampleTests */, 832341AE1AAA6A7D00B99B32 /* Libraries */, 83CBBA001A601CBA00E9B192 /* Products */, 2D16E6871FA4F8E400B85C8A /* Frameworks */, @@ -85,6 +123,7 @@ isa = PBXGroup; children = ( 13B07F961A680F5B00A75B9A /* SdkReactNativeExample.app */, + 00E356EE1AD99517003FC87E /* SdkReactNativeExampleTests.xctest */, ); name = Products; sourceTree = ""; @@ -94,6 +133,8 @@ children = ( 3B4392A12AC88292D35C810B /* Pods-SdkReactNativeExample.debug.xcconfig */, 5709B34CF0A7D63546082F79 /* Pods-SdkReactNativeExample.release.xcconfig */, + 6CF1C5F710D90A466B8CE570 /* Pods-SdkReactNativeExample-SdkReactNativeExampleTests.debug.xcconfig */, + D0E8BFFA6CE874103AF34566 /* Pods-SdkReactNativeExample-SdkReactNativeExampleTests.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -101,6 +142,25 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 00E356ED1AD99517003FC87E /* SdkReactNativeExampleTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "SdkReactNativeExampleTests" */; + buildPhases = ( + 09B86318EBAF7EE8C45A9A6A /* [CP] Check Pods Manifest.lock */, + 00E356EA1AD99517003FC87E /* Sources */, + 00E356EB1AD99517003FC87E /* Frameworks */, + 00E356EC1AD99517003FC87E /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 00E356F51AD99517003FC87E /* PBXTargetDependency */, + ); + name = SdkReactNativeExampleTests; + productName = SdkReactNativeExampleTests; + productReference = 00E356EE1AD99517003FC87E /* SdkReactNativeExampleTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 13B07F861A680F5B00A75B9A /* SdkReactNativeExample */ = { isa = PBXNativeTarget; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "SdkReactNativeExample" */; @@ -130,6 +190,10 @@ attributes = { LastUpgradeCheck = 1210; TargetAttributes = { + 00E356ED1AD99517003FC87E = { + CreatedOnToolsVersion = 6.2; + TestTargetID = 13B07F861A680F5B00A75B9A; + }; 13B07F861A680F5B00A75B9A = { LastSwiftMigration = 1120; }; @@ -142,6 +206,7 @@ knownRegions = ( en, Base, + es, ); mainGroup = 83CBB9F61A601CBA00E9B192; productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; @@ -149,11 +214,19 @@ projectRoot = ""; targets = ( 13B07F861A680F5B00A75B9A /* SdkReactNativeExample */, + 00E356ED1AD99517003FC87E /* SdkReactNativeExampleTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 00E356EC1AD99517003FC87E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 13B07F8E1A680F5B00A75B9A /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -161,6 +234,7 @@ 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 22ABB861F5A1DCF99DA64262 /* PrivacyInfo.xcprivacy in Resources */, + A1C3E5F72E9B4D6100D1D1A1 /* InfoPlist.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -200,6 +274,28 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SdkReactNativeExample/Pods-SdkReactNativeExample-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; + 09B86318EBAF7EE8C45A9A6A /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-SdkReactNativeExample-SdkReactNativeExampleTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -242,6 +338,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 00E356EA1AD99517003FC87E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 00E356F31AD99517003FC87E /* DiditSdkBridgeResultTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 13B07F871A680F5B00A75B9A /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -252,7 +356,66 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 00E356F51AD99517003FC87E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 13B07F861A680F5B00A75B9A /* SdkReactNativeExample */; + targetProxy = 00E356F41AD99517003FC87E /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + A1C3E5F52E9B4D6100D1D1A1 /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + A1C3E5F62E9B4D6100D1D1A1 /* es */, + ); + name = InfoPlist.strings; + path = SdkReactNativeExample; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + /* Begin XCBuildConfiguration section */ + 00E356F61AD99517003FC87E /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6CF1C5F710D90A466B8CE570 /* Pods-SdkReactNativeExample-SdkReactNativeExampleTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = me.didit.sdk.reactnative.example.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SdkReactNativeExample.app/SdkReactNativeExample"; + }; + name = Debug; + }; + 00E356F71AD99517003FC87E /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D0E8BFFA6CE874103AF34566 /* Pods-SdkReactNativeExample-SdkReactNativeExampleTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = me.didit.sdk.reactnative.example.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SdkReactNativeExample.app/SdkReactNativeExample"; + }; + name = Release; + }; 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 3B4392A12AC88292D35C810B /* Pods-SdkReactNativeExample.debug.xcconfig */; @@ -458,6 +621,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "SdkReactNativeExampleTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 00E356F61AD99517003FC87E /* Debug */, + 00E356F71AD99517003FC87E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "SdkReactNativeExample" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/example/ios/SdkReactNativeExample/Info.plist b/example/ios/SdkReactNativeExample/Info.plist index c3e3858..2c2387d 100644 --- a/example/ios/SdkReactNativeExample/Info.plist +++ b/example/ios/SdkReactNativeExample/Info.plist @@ -28,6 +28,13 @@ NFCReaderUsageDescription NFC is used to read passport chip data for identity verification. + NSLocationTemporaryUsageDescriptionDictionary + + DiditLocationVerification + This verification needs your precise location once. + + NSLocationWhenInUseUsageDescription + Your location is used to confirm where you are for this verification. NSMicrophoneUsageDescription Microphone access is required to record video for liveness verification. NSPhotoLibraryUsageDescription diff --git a/example/ios/SdkReactNativeExample/es.lproj/InfoPlist.strings b/example/ios/SdkReactNativeExample/es.lproj/InfoPlist.strings new file mode 100644 index 0000000..19d2ee4 --- /dev/null +++ b/example/ios/SdkReactNativeExample/es.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +"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."; diff --git a/example/ios/SdkReactNativeExampleTests/DiditSdkBridgeResultTests.swift b/example/ios/SdkReactNativeExampleTests/DiditSdkBridgeResultTests.swift new file mode 100644 index 0000000..d469bc3 --- /dev/null +++ b/example/ios/SdkReactNativeExampleTests/DiditSdkBridgeResultTests.swift @@ -0,0 +1,112 @@ +import XCTest +import DiditSDK +@testable import SdkReactNative + +/// Hands results built from the pinned DiditSDK to the bridge's delivery path +/// (the one its verification view calls) and reads the dictionary the +/// TurboModule resolves to JS. Every error type must match the string the +/// Android bridge sends for the same native error. +final class DiditSdkBridgeResultTests: XCTestCase { + + func testHostBundlesEnglishAndSpanishLocationPurposes() throws { + let english = Bundle.main.infoDictionary + let temporary = english?["NSLocationTemporaryUsageDescriptionDictionary"] as? [String: String] + let spanishPath = try XCTUnwrap(Bundle.main.path(forResource: "es", ofType: "lproj")) + let spanish = try XCTUnwrap(Bundle(path: spanishPath)) + + XCTAssertEqual(english?["NSLocationWhenInUseUsageDescription"] as? String, + "Your location is used to confirm where you are for this verification.") + XCTAssertEqual(temporary?["DiditLocationVerification"], + "This verification needs your precise location once.") + XCTAssertEqual(spanish.localizedString(forKey: "NSLocationWhenInUseUsageDescription", value: nil, table: "InfoPlist"), + "Tu ubicación se usa para confirmar dónde estás en esta verificación.") + XCTAssertEqual(spanish.localizedString(forKey: "DiditLocationVerification", value: nil, table: "InfoPlist"), + "Esta verificación necesita tu ubicación precisa una vez.") + } + + func testRetryBlockedReachesJavaScriptAsRetryBlocked() { + let result = deliver( + .failed( + error: .retryBlocked, + session: SessionData(sessionId: "test-session", status: .declined) + ) + ) + + XCTAssertEqual(result["type"] as? String, "failed") + XCTAssertEqual(result["errorType"] as? String, "retryBlocked") + XCTAssertEqual(result["sessionId"] as? String, "test-session") + XCTAssertEqual(result["status"] as? String, "Declined") + } + + func testEveryNativeErrorKeepsTheAndroidErrorType() { + let expected: [(VerificationError, String)] = [ + (.sessionExpired, "sessionExpired"), + (.retryBlocked, "retryBlocked"), + (.networkError, "networkError"), + (.cameraAccessDenied, "cameraAccessDenied"), + (.unknown("native message"), "unknown"), + ] + + for (error, errorType) in expected { + let result = deliver(.failed(error: error, session: nil)) + + XCTAssertEqual(result["errorType"] as? String, errorType, "\(error)") + XCTAssertEqual(result["errorMessage"] as? String, error.localizedDescription, "\(error)") + } + } + + func testCompletedAndCancelledResultsKeepTheSession() { + let session = SessionData(sessionId: "test-session", status: .pending) + let completed = deliver(.completed(session: session)) + let cancelled = deliver(.cancelled(session: session)) + + XCTAssertEqual(completed["type"] as? String, "completed") + XCTAssertEqual(cancelled["type"] as? String, "cancelled") + for result in [completed, cancelled] { + XCTAssertEqual(result["sessionId"] as? String, "test-session") + XCTAssertEqual(result["status"] as? String, "Pending") + XCTAssertNil(result["errorType"]) + } + } + + func testResultIsDeliveredOnlyOnceForTheCurrentPresentation() { + let bridge = DiditSdkBridge() + let stale = bridge.beginPresentation() + let current = bridge.beginPresentation() + let delivered = expectation(description: "delivered once") + delivered.assertForOverFulfill = true + + bridge.deliverResult(.cancelled(session: nil), generation: stale) { _ in + XCTFail("A stale presentation must not resolve the current callback") + } + bridge.deliverResult(.failed(error: .retryBlocked, session: nil), generation: current) { result in + XCTAssertEqual(result["errorType"] as? String, "retryBlocked") + XCTAssertNil(result["sessionId"]) + delivered.fulfill() + } + bridge.deliverResult(.cancelled(session: nil), generation: current) { _ in + XCTFail("A duplicate result must not resolve twice") + } + wait(for: [delivered], timeout: 5) + } + + /// Starts a presentation, hands it one native result and waits for what + /// the bridge resolves. + private func deliver(_ result: VerificationResult) -> NSDictionary { + let bridge = DiditSdkBridge() + let resolved = Resolved() + let delivered = expectation(description: "result delivered") + + bridge.deliverResult(result, generation: bridge.beginPresentation()) { dictionary in + resolved.dictionary = dictionary + delivered.fulfill() + } + + wait(for: [delivered], timeout: 5) + return resolved.dictionary + } +} + +private final class Resolved: @unchecked Sendable { + var dictionary: NSDictionary = [:] +} diff --git a/example/src/App.tsx b/example/src/App.tsx index fc26b8e..2c639cb 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -24,7 +24,7 @@ import { const DEMO_WALLET_ADDRESS = '0x71C7656EC7ab88b098defB751B7401B5f6d8976F'; export default function App() { - const [token, setToken] = useState('e5xD6RVXV19Q'); + const [token, setToken] = useState(''); const [workflowId, setWorkflowId] = useState(''); const [transactionToken, setTransactionToken] = useState(''); const [transactionResult, setTransactionResult] = useState( diff --git a/ios/DiditSdkBridge.swift b/ios/DiditSdkBridge.swift index 8a09dac..c7fdc37 100644 --- a/ios/DiditSdkBridge.swift +++ b/ios/DiditSdkBridge.swift @@ -97,12 +97,7 @@ public class DiditSdkBridge: NSObject, @unchecked Sendable { let bridgeView = DiditBridgeView( onResult: { [weak self] result in - guard let self = self, self.claimResultDelivery(for: generation) else { return } - self.tearDownThenResolve( - generation: generation, - result: Self.mapVerificationResult(result), - completion: completion - ) + self?.deliverResult(result, generation: generation, completion: completion) } ) @@ -120,12 +115,26 @@ public class DiditSdkBridge: NSObject, @unchecked Sendable { hostingController = nil } - private func beginPresentation() -> Int { + func beginPresentation() -> Int { presentationGeneration += 1 hasDeliveredResult = false return presentationGeneration } + /// Sends the native result of one presentation to JS, once, after its host is dismissed. + func deliverResult( + _ result: VerificationResult, + generation: Int, + completion: @escaping @Sendable (NSDictionary) -> Void + ) { + guard claimResultDelivery(for: generation) else { return } + tearDownThenResolve( + generation: generation, + result: Self.mapVerificationResult(result), + completion: completion + ) + } + private func claimResultDelivery(for generation: Int) -> Bool { guard !hasDeliveredResult, presentationGeneration == generation else { return false } hasDeliveredResult = true @@ -323,6 +332,8 @@ public class DiditSdkBridge: NSObject, @unchecked Sendable { return "networkError" case .cameraAccessDenied: return "cameraAccessDenied" + case .retryBlocked: + return "retryBlocked" case .unknown: return "unknown" @unknown default: diff --git a/native-sdk-source.js b/native-sdk-source.js new file mode 100644 index 0000000..1244b8e --- /dev/null +++ b/native-sdk-source.js @@ -0,0 +1,88 @@ +const { execFileSync } = require('child_process'); +const { readFileSync, realpathSync } = require('fs'); +const { join } = require('path'); +const { diditNativeSdkVersions } = require('./package.json'); + +function git(sourcePath, args) { + return execFileSync('git', ['-C', sourcePath, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +function verifySource(sourcePath, pin) { + if (git(sourcePath, ['rev-parse', 'HEAD']) !== pin.revision) { + throw new Error( + `Didit native source must match ${pin.branch} at ${pin.revision}.` + ); + } + if (git(sourcePath, ['status', '--porcelain'])) { + throw new Error( + 'Didit native source has local changes; use the pinned checkout.' + ); + } +} + +function resolveNativeSdkSource(platform) { + const pin = diditNativeSdkVersions.source; + const sourcePath = process.env.DIDIT_SDK_SOURCE_PATH; + + if (!pin) return ''; + if (!sourcePath) { + throw new Error( + 'Set DIDIT_SDK_SOURCE_PATH to the pinned native source checkout. Released binaries cannot provide these steps.' + ); + } + verifySource(sourcePath, pin); + + return realpathSync( + join(sourcePath, platform === 'ios' ? 'ios/sdk' : 'android') + ); +} + +function androidRepositoryPath() { + return join( + __dirname, + '.native-sdk', + diditNativeSdkVersions.source.revision, + 'maven' + ); +} + +function verifyAndroidArtifacts(repository) { + const marker = JSON.parse( + readFileSync(join(repository, 'source.json'), 'utf8') + ); + + if (marker.revision !== diditNativeSdkVersions.source.revision) { + throw new Error( + 'Native Android artifacts do not match the pin. Run yarn native:android.' + ); + } +} + +function resolveAndroidRepository() { + const repository = diditNativeSdkVersions.source + ? androidRepositoryPath() + : ''; + + if (!repository) return ''; + resolveNativeSdkSource('android'); + verifyAndroidArtifacts(repository); + + return repository; +} + +module.exports = { + resolveNativeSdkSource, + resolveAndroidRepository, + androidRepositoryPath, +}; + +if (require.main === module) { + process.stdout.write( + process.argv[2] === 'android-repository' + ? resolveAndroidRepository() + : resolveNativeSdkSource(process.argv[2]) + ); +} diff --git a/package.json b/package.json index 3fd4f26..8d9c9cb 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,11 @@ "version": "4.9.1", "diditNativeSdkVersions": { "ios": "4.9.1", - "android": "4.9.1" + "android": "4.9.1", + "source": { + "branch": "integration/console-v2", + "revision": "88259c3b2ac57520ead46579ee7c1fb56686cdb1" + } }, "description": "React Native wrapper for Didit Identity Verification SDK", "main": "./lib/commonjs/index.js", @@ -22,7 +26,8 @@ } }, "./package.json": "./package.json", - "./app.plugin.js": "./app.plugin.js" + "./app.plugin.js": "./app.plugin.js", + "./native-sdk-source": "./native-sdk-source.js" }, "files": [ "src", @@ -42,7 +47,9 @@ "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", - "!**/.*" + "!**/.*", + "native-sdk-source.js", + "build-native-android.js" ], "scripts": { "example": "yarn workspace sdk-react-native-example", @@ -53,7 +60,8 @@ "typecheck": "tsc", "release": "release-it --only-version", "lint": "eslint \"**/*.{js,ts,tsx}\"", - "test": "jest" + "test": "jest", + "native:android": "node build-native-android.js" }, "keywords": [ "react-native", diff --git a/src/__tests__/android-logging-privacy.test.ts b/src/__tests__/android-logging-privacy.test.ts new file mode 100644 index 0000000..58c2976 --- /dev/null +++ b/src/__tests__/android-logging-privacy.test.ts @@ -0,0 +1,211 @@ +import { readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; + +/** + * The Android bridge used to log the first characters of the session token, the + * workflow's vendorData and metadata, the contact and expected details and the + * parsed configuration, all through plain Log.d. The library's release build + * type does not minify, so nothing stripped them and a release build wrote + * credentials and personal data to logcat. Every diagnostic now goes through a + * helper gated on BuildConfig.DEBUG and carries no token, personal data or + * native error message. These tests read the bridge sources and fail when a log + * call leaks one of them again or drops the debug-only gate. + */ + +const repoRoot = join(__dirname, '..', '..'); + +const read = (relativePath: string) => + readFileSync(join(repoRoot, relativePath), 'utf8'); + +/** A diagnostic log call, in Kotlin or in an Apple source file. */ +const LOG_CALL = + /\b(?:Log\.[deiwv]|logDebug(?:Error)?|NSLog|os_log|print(?:ln)?)\s*\(/g; + +/** A log call that reaches the platform logger directly, so it needs a gate. */ +const RAW_LOG = /\b(?:Log\.[deiwv]|NSLog|os_log|print(?:ln)?)\s*\(/; + +/** A credential, personal data, or a native error message. */ +const SENSITIVE = + /\b(?:transactionToken|token|vendorData|metadata|contactDetails|expectedDetails|config(?:uration)?|session(?:Id)?|state\.message)\b/i; + +type Gate = (index: number) => boolean; + +/** Every file under `directory` whose name ends with one of `extensions`. */ +function sources(directory: string, extensions: string[]): string[] { + const entries = readdirSync(join(repoRoot, directory), { + recursive: true, + encoding: 'utf8', + }); + + return entries + .filter((name) => extensions.some((extension) => name.endsWith(extension))) + .map((name) => join(directory, name)); +} + +/** The index of the character that closes the opener at `open`. */ +function closingIndex( + source: string, + open: number, + opener: string, + closer: string +): number { + let depth = 0; + + for (let index = open; index < source.length; index += 1) { + if (source[index] === opener) depth += 1; + if (source[index] === closer) depth -= 1; + if (depth === 0) return index; + } + + return source.length; +} + +/** The argument list of the call whose opening parenthesis is at `open`. */ +const callText = (source: string, open: number) => + source.slice(open, closingIndex(source, open, '(', ')') + 1); + +/** The body of the Kotlin function that contains `index`. */ +function functionBody(source: string, index: number): string { + const start = source.lastIndexOf('fun ', index); + const brace = source.indexOf('{', start); + + return start < 0 + ? '' + : source.slice(brace, closingIndex(source, brace, '{', '}') + 1); +} + +/** True when `index` sits inside one of the file's `#if DEBUG` regions. */ +const inDebugRegion = (source: string, index: number) => + Array.from(source.matchAll(/#if DEBUG/g), (match) => match.index ?? 0).some( + (start) => index >= start && index <= source.indexOf('#endif', start) + ); + +/** Kotlin logs are allowed inside a function gated on BuildConfig.DEBUG. */ +const kotlinGate = + (source: string): Gate => + (index) => + functionBody(source, index).includes('BuildConfig.DEBUG'); + +/** Apple logs are allowed inside a `#if DEBUG` region. */ +const appleGate = + (source: string): Gate => + (index) => + inDebugRegion(source, index); + +const lineOf = (source: string, index: number) => + source.slice(0, index).split('\n').length; + +/** + * Why one log call is not allowed, or null when it is. A call that goes through + * a gated helper is gated by construction, so only its text matters. + */ +function violation(source: string, match: RegExpMatchArray, gated: Gate) { + const index = match.index ?? 0; + const text = callText(source, index + match[0].length - 1); + const leaked = text.match(SENSITIVE)?.[0]; + + if (leaked) return `line ${lineOf(source, index)}: logs ${leaked}`; + if (!RAW_LOG.test(match[0]) || gated(index)) return null; + + return `line ${lineOf(source, index)}: is not behind a debug-only gate`; +} + +/** Every log call in `source` that leaks data or is not debug-only. */ +function violations(source: string, gate: (source: string) => Gate): string[] { + const gated = gate(source); + + return Array.from(source.matchAll(LOG_CALL), (match) => + violation(source, match, gated) + ).filter((reason): reason is string => reason !== null); +} + +const KOTLIN_SOURCES = sources('android/src', ['.kt', '.java']); +const APPLE_SOURCES = sources('ios', ['.swift', '.mm', '.m', '.h']); + +describe('wrapper diagnostic logging', () => { + it('reads both bridges', () => { + expect(KOTLIN_SOURCES).toContain( + 'android/src/main/java/com/sdkreactnative/SdkReactNativeModule.kt' + ); + expect(APPLE_SOURCES).toContain('ios/DiditSdkBridge.swift'); + }); + + it.each(KOTLIN_SOURCES)( + '%s keeps every log call debug-only and free of personal data', + (path) => { + expect(violations(read(path), kotlinGate)).toEqual([]); + } + ); + + it.each(APPLE_SOURCES)( + '%s keeps every log call debug-only and free of personal data', + (path) => { + expect(violations(read(path), appleGate)).toEqual([]); + } + ); +}); + +describe('the logging guard itself', () => { + const lines = (...body: string[]) => body.join('\n'); + + it('rejects the token log this change removed', () => { + const fixture = lines( + 'fun startVerification(token: String, config: ReadableMap?) {', + ' Log.d(TAG, "startVerification: token=${token.take(8)}..., config=$config")', + '}' + ); + + expect(violations(fixture, kotlinGate)).toEqual(['line 2: logs token']); + }); + + it('rejects personal data even behind the debug gate', () => { + const fixture = lines( + 'private fun logDebug(message: String) {', + ' if (BuildConfig.DEBUG) Log.d(TAG, "vendorData=$vendorData")', + '}' + ); + + expect(violations(fixture, kotlinGate)).toEqual([ + 'line 2: logs vendorData', + ]); + }); + + it('rejects a structural log with no debug gate', () => { + const fixture = lines('fun start() {', ' Log.d(TAG, "started")', '}'); + + expect(violations(fixture, kotlinGate)).toEqual([ + 'line 2: is not behind a debug-only gate', + ]); + }); + + it('accepts the shipped helper', () => { + const fixture = lines( + 'private fun logDebug(message: String) {', + ' if (BuildConfig.DEBUG) Log.d(TAG, message)', + '}' + ); + + expect(violations(fixture, kotlinGate)).toEqual([]); + }); + + it('rejects a helper that reaches the logger without a gate', () => { + const fixture = lines( + 'private fun logDebug(message: String) {', + ' Log.d(TAG, message)', + '}' + ); + + expect(violations(fixture, kotlinGate)).toEqual([ + 'line 2: is not behind a debug-only gate', + ]); + }); + + it('accepts an Apple log inside #if DEBUG and rejects one outside it', () => { + expect( + violations(lines('#if DEBUG', 'NSLog("started")', '#endif'), appleGate) + ).toEqual([]); + expect(violations('NSLog("started")', appleGate)).toEqual([ + 'line 1: is not behind a debug-only gate', + ]); + }); +}); diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index 6bdf882..a1746bb 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -4,6 +4,7 @@ jest.mock('../NativeSdkReactNative', () => ({ __esModule: true, default: { startVerification: jest.fn(), + startVerificationWithWorkflow: jest.fn(), submitTransaction: jest.fn(), getTransaction: jest.fn(), onTransactionUpdated: jest.fn(() => ({ remove: jest.fn() })), @@ -11,10 +12,18 @@ jest.mock('../NativeSdkReactNative', () => ({ })); import NativeSdkReactNative from '../NativeSdkReactNative'; -import { startVerification, submitTransaction, getTransaction } from '../index'; +import { + startVerification, + startVerificationWithWorkflow, + submitTransaction, + getTransaction, + VerificationStatus, +} from '../index'; const mockStartVerification = NativeSdkReactNative.startVerification as jest.Mock; +const mockStartWithWorkflow = + NativeSdkReactNative.startVerificationWithWorkflow as jest.Mock; const mockSubmit = NativeSdkReactNative.submitTransaction as jest.Mock; const mockGet = NativeSdkReactNative.getTransaction as jest.Mock; const mockOnUpdated = NativeSdkReactNative.onTransactionUpdated as jest.Mock; @@ -82,6 +91,61 @@ describe('verification configuration marshalling', () => { }); }); +/** + * Bank and Location are drawn by the native SDKs, so the wrapper adds no API + * for them: their screens follow the configured language, and a native SDK + * that cannot show a step ends the flow with a failure the host app can act on. + */ +describe('native-only verification steps', () => { + beforeEach(() => { + mockStartVerification.mockReset(); + mockStartWithWorkflow.mockReset(); + }); + + it('forwards the configured language to native from both entry points', async () => { + const completed = { + type: 'completed', + sessionId: 'test-session', + status: 'Pending', + }; + mockStartVerification.mockResolvedValue(completed); + mockStartWithWorkflow.mockResolvedValue(completed); + + await startVerification('test-token', { languageCode: 'es' }); + await startVerificationWithWorkflow('test-workflow', { + config: { languageCode: 'es' }, + }); + + expect(mockStartVerification).toHaveBeenCalledWith( + 'test-token', + expect.objectContaining({ languageCode: 'es' }) + ); + const [, , , , , workflowConfig] = mockStartWithWorkflow.mock.calls[0]; + expect(workflowConfig).toMatchObject({ languageCode: 'es' }); + }); + + it('returns an unsupported-step exit as an unknown failure that keeps the session', async () => { + mockStartVerification.mockResolvedValue({ + type: 'failed', + errorType: 'unknown', + errorMessage: 'Unsupported verification step', + sessionId: 'test-session', + status: 'Pending', + }); + + const result = await startVerification('test-token'); + + expect(result).toEqual({ + type: 'failed', + error: { type: 'unknown', message: 'Unsupported verification step' }, + session: { + sessionId: 'test-session', + status: VerificationStatus.Pending, + }, + }); + }); +}); + describe('transaction result marshalling', () => { beforeEach(() => { mockSubmit.mockReset(); diff --git a/src/__tests__/location-permission-entries.test.ts b/src/__tests__/location-permission-entries.test.ts new file mode 100644 index 0000000..9389006 --- /dev/null +++ b/src/__tests__/location-permission-entries.test.ts @@ -0,0 +1,155 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +/** + * The native Location step only prompts when the host app declares its iOS + * purpose string, and only asks once for precise location when the temporary + * purpose key is exactly `DiditLocationVerification`. A missing or misspelled + * entry fails silently: the step reports that the device cannot place the + * person. iOS reads the translations from `.lproj/InfoPlist.strings`, + * with the precise-once string under its purpose key. These tests keep the + * example apps and the README on the design copy, in English and Spanish. + */ + +const repoRoot = join(__dirname, '..', '..'); + +const read = (relativePath: string) => + readFileSync(join(repoRoot, relativePath), 'utf8'); + +const WHEN_IN_USE = + 'Your location is used to confirm where you are for this verification.'; +const WHEN_IN_USE_ES = + 'Tu ubicación se usa para confirmar dónde estás en esta verificación.'; +const PRECISE_ONCE = 'This verification needs your precise location once.'; +const PRECISE_ONCE_ES = + 'Esta verificación necesita tu ubicación precisa una vez.'; +const PURPOSE_KEY = 'DiditLocationVerification'; + +const IOS_EXAMPLE_PLISTS = [ + 'example/ios/SdkReactNativeExample/Info.plist', + 'example-expo/ios/DiditExpoExample/Info.plist', +]; + +const IOS_EXAMPLE_SPANISH_STRINGS = [ + { + stringsFile: 'example/ios/SdkReactNativeExample/es.lproj/InfoPlist.strings', + pbxproj: 'example/ios/SdkReactNativeExample.xcodeproj/project.pbxproj', + }, + { + stringsFile: + 'example-expo/ios/DiditExpoExample/Supporting/es.lproj/InfoPlist.strings', + pbxproj: 'example-expo/ios/DiditExpoExample.xcodeproj/project.pbxproj', + }, +]; + +/** The `` value that follows `key`. */ +const plistString = (contents: string, key: string) => + contents.match( + new RegExp(`${key}\\s*([^<]*)`) + )?.[1]; + +/** The value of `key` in an `InfoPlist.strings` file (the key may be unquoted). */ +const stringsValue = (contents: string, key: string) => + contents.match(new RegExp(`^"?${key}"?\\s*=\\s*"([^"]*)";`, 'm'))?.[1]; + +/** The body of the `` that follows `key`. */ +const plistDict = (contents: string, key: string) => + contents.match( + new RegExp(`${key}\\s*([\\s\\S]*?)`) + )?.[1]; + +describe.each(IOS_EXAMPLE_PLISTS)( + 'iOS location purpose strings in %s', + (path) => { + const plist = read(path); + + it('declares the when-in-use purpose string', () => { + expect(plistString(plist, 'NSLocationWhenInUseUsageDescription')).toBe( + WHEN_IN_USE + ); + }); + + it('declares the precise-once purpose under the key the native step asks for', () => { + const temporary = + plistDict(plist, 'NSLocationTemporaryUsageDescriptionDictionary') ?? ''; + + expect(plistString(temporary, PURPOSE_KEY)).toBe(PRECISE_ONCE); + }); + } +); + +describe.each(IOS_EXAMPLE_SPANISH_STRINGS)( + 'iOS Spanish purpose strings in $stringsFile', + ({ stringsFile, pbxproj }) => { + it('translates both purpose strings', () => { + const strings = read(stringsFile); + + expect(stringsValue(strings, 'NSLocationWhenInUseUsageDescription')).toBe( + WHEN_IN_USE_ES + ); + expect(stringsValue(strings, PURPOSE_KEY)).toBe(PRECISE_ONCE_ES); + }); + + it('bundles the translation with the app', () => { + const project = read(pbxproj); + + expect(project).toMatch(/path = "?es\.lproj\/InfoPlist\.strings"?;/); + expect(project).toMatch( + /files = \([^)]*\/\* InfoPlist\.strings in Resources \*\// + ); + }); + } +); + +describe('Expo example app config', () => { + it('declares the same location purpose strings as its prebuilt Info.plist', () => { + const infoPlist = JSON.parse(read('example-expo/app.json')).expo.ios + .infoPlist; + + expect(infoPlist.NSLocationWhenInUseUsageDescription).toBe(WHEN_IN_USE); + expect(infoPlist.NSLocationTemporaryUsageDescriptionDictionary).toEqual({ + [PURPOSE_KEY]: PRECISE_ONCE, + }); + }); + + it('translates them to Spanish with the same locale file it was prebuilt from', () => { + const { locales } = JSON.parse(read('example-expo/app.json')).expo; + const spanish = JSON.parse(read(join('example-expo', locales.es))).ios; + const prebuilt = read( + 'example-expo/ios/DiditExpoExample/Supporting/es.lproj/InfoPlist.strings' + ); + + expect(spanish).toEqual({ + NSLocationWhenInUseUsageDescription: WHEN_IN_USE_ES, + [PURPOSE_KEY]: PRECISE_ONCE_ES, + }); + Object.entries(spanish).forEach(([key, value]) => + expect(stringsValue(prebuilt, key)).toBe(value) + ); + }); +}); + +describe('README location setup', () => { + const readme = read('README.md'); + + it('documents the same purpose strings and key as the example apps', () => { + const temporary = + plistDict(readme, 'NSLocationTemporaryUsageDescriptionDictionary') ?? ''; + + expect(plistString(readme, 'NSLocationWhenInUseUsageDescription')).toBe( + WHEN_IN_USE + ); + expect(plistString(temporary, PURPOSE_KEY)).toBe(PRECISE_ONCE); + }); + + it('documents both Spanish purpose strings for InfoPlist.strings', () => { + expect(stringsValue(readme, 'NSLocationWhenInUseUsageDescription')).toBe( + WHEN_IN_USE_ES + ); + expect(stringsValue(readme, PURPOSE_KEY)).toBe(PRECISE_ONCE_ES); + }); + + it('documents the same Spanish locale file as the Expo example', () => { + expect(readme).toContain(read('example-expo/locales/es.json').trim()); + }); +}); diff --git a/src/__tests__/location-plugin.test.ts b/src/__tests__/location-plugin.test.ts new file mode 100644 index 0000000..5d99c8c --- /dev/null +++ b/src/__tests__/location-plugin.test.ts @@ -0,0 +1,65 @@ +export {}; + +const withDiditSdk = require('../../app.plugin.js'); + +async function podfile( + props: object = {}, + contents = "target 'App' do\n use_expo_modules!\nend" +) { + const config = withDiditSdk({ name: 'test', slug: 'test' }, props); + const result = await config.mods.ios.podfile({ + ...config, + modResults: { contents }, + modRequest: { introspect: true }, + }); + + return result.modResults.contents; +} + +it('keeps Location opt-in even with the all variant', async () => { + expect(await podfile()).toContain('$DiditSdkIosLocationEnabled = false'); +}); + +it('uses the verified source for both the selected variant and Location', async () => { + const contents = await podfile({ iosLocationEnabled: true }); + + expect(contents).toContain('$DiditSdkIosLocationEnabled = true'); + expect(contents).toContain('native-sdk-source'); + expect(contents).toContain( + 'pod didit_sdk_subspec, :path => didit_source_path' + ); + expect(contents).toContain( + "pod 'DiditSDK/Location', :path => didit_source_path" + ); + expect(contents).not.toContain(':podspec'); +}); + +it('updates the Location opt-in without duplicating the generated block', async () => { + const enabled = await podfile({ iosLocationEnabled: true }); + const disabled = await podfile({ iosLocationEnabled: false }, enabled); + + expect(disabled).toBe(await podfile({ iosLocationEnabled: false }, disabled)); + expect(disabled).not.toContain('$DiditSdkIosLocationEnabled = true'); + expect(disabled.match(/pod 'DiditSDK\/Location'/g)).toHaveLength(1); +}); + +it('rejects SPM instead of omitting the native Location module', async () => { + await expect( + podfile({ iosLocationEnabled: true, iosLinkage: 'spm' }) + ).rejects.toThrow('cocoapods'); +}); + +it('adds the Android source settings once across prebuilds', async () => { + const config = withDiditSdk({ name: 'test', slug: 'test' }); + const request = { + ...config, + modResults: { contents: '' }, + modRequest: { introspect: true }, + }; + const first = await config.mods.android.settingsGradle(request); + const second = await config.mods.android.settingsGradle(first); + + expect( + second.modResults.contents.match(/android\/native-source.gradle/g) + ).toHaveLength(1); +}); diff --git a/src/__tests__/native-build-environment.test.ts b/src/__tests__/native-build-environment.test.ts new file mode 100644 index 0000000..d3990bc --- /dev/null +++ b/src/__tests__/native-build-environment.test.ts @@ -0,0 +1,98 @@ +import { execFileSync } from 'child_process'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const root = join(__dirname, '..', '..'); +const fixture = mkdtempSync(join(tmpdir(), 'native-build-environment-')); +const turbo = require.resolve('turbo/bin/turbo'); +const sourcePath = '/native-source-fixture'; +const probe = `console.log('NATIVE_BUILD_ENV=' + JSON.stringify({ + source: process.env.DIDIT_SDK_SOURCE_PATH, + location: process.env.DIDIT_SDK_IOS_LOCATION_ENABLED, + unrelated: process.env.UNRELATED_BUILD_SETTING, +}));`; + +function writeJson(path: string, value: object) { + writeFileSync(join(fixture, path), JSON.stringify(value)); +} + +beforeAll(() => { + mkdirSync(join(fixture, 'app')); + writeJson('package.json', { + name: 'native-build-fixture', + private: true, + packageManager: 'npm@10.0.0', + workspaces: ['app'], + }); + writeJson('package-lock.json', { lockfileVersion: 3, packages: {} }); + writeJson('app/package.json', { + name: 'native-build-app', + scripts: { + 'build:ios': 'node check.cjs', + 'build:android': 'node check.cjs', + }, + }); + writeFileSync(join(fixture, 'app/check.cjs'), probe); + writeFileSync( + join(fixture, 'turbo.json'), + readFileSync(join(root, 'turbo.json')) + ); + writeFileSync(join(fixture, '.gitignore'), '.turbo/\n'); +}); + +afterAll(() => rmSync(fixture, { recursive: true, force: true })); + +function runBuild(task: string, location: string, dry = false) { + const args = [turbo, 'run', task, '--env-mode=strict', '--force']; + + return execFileSync(process.execPath, dry ? [...args, '--dry=json'] : args, { + cwd: fixture, + encoding: 'utf8', + env: { + ...process.env, + DIDIT_SDK_SOURCE_PATH: sourcePath, + DIDIT_SDK_IOS_LOCATION_ENABLED: location, + UNRELATED_BUILD_SETTING: 'must-not-leak', + TURBO_TELEMETRY_DISABLED: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +it.each(['false', 'true'])( + 'preserves the iOS Location flag (%s) and source checkout in strict mode', + (location) => { + const output = runBuild('build:ios', location); + + expect(output).toContain( + `NATIVE_BUILD_ENV=${JSON.stringify({ + source: sourcePath, + location, + })}` + ); + } +); + +it('preserves the Android source checkout in strict mode', () => { + const output = runBuild('build:android', 'false'); + + expect(output).toContain( + `NATIVE_BUILD_ENV=${JSON.stringify({ + source: sourcePath, + })}` + ); +}); + +it('invalidates the iOS build cache when Location is toggled', () => { + const disabled = JSON.parse(runBuild('build:ios', 'false', true)); + const enabled = JSON.parse(runBuild('build:ios', 'true', true)); + + expect(disabled.tasks[0].hash).not.toBe(enabled.tasks[0].hash); +}); diff --git a/src/__tests__/native-error-types.test.ts b/src/__tests__/native-error-types.test.ts new file mode 100644 index 0000000..420ecc0 --- /dev/null +++ b/src/__tests__/native-error-types.test.ts @@ -0,0 +1,120 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +jest.mock('../NativeSdkReactNative', () => ({ + __esModule: true, + default: { + startVerification: jest.fn(), + onTransactionUpdated: jest.fn(() => ({ remove: jest.fn() })), + }, +})); + +import NativeSdkReactNative from '../NativeSdkReactNative'; +import { startVerification } from '../index'; + +/** + * Each native bridge turns the native SDK's VerificationError into the + * `errorType` string the JS layer reads, and a case a bridge leaves out falls + * through to "unknown". The iOS bridge once had no retryBlocked case, so the + * same native error reached JS as "retryBlocked" on Android and "unknown" on + * iOS. Both switches are read here so the bridges keep sending the same string + * for every error both native SDKs share, and JS keeps every string they send. + */ + +const repoRoot = join(__dirname, '..', '..'); + +const read = (relativePath: string) => + readFileSync(join(repoRoot, relativePath), 'utf8'); + +/** The body of the bridge's mapErrorType, up to its closing brace. */ +function mapErrorTypeBody(source: string, signature: RegExp): string { + const start = source.search(signature); + const end = source.indexOf('\n }\n', start); + + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + + return source.slice(start, end); +} + +/** Native case name in lower camel case -> errorType string sent to JS. */ +function iosErrorTypes(): Map { + const body = mapErrorTypeBody( + read('ios/DiditSdkBridge.swift'), + /private static func mapErrorType\(/ + ); + + return new Map( + Array.from( + body.matchAll(/case \.(\w+)[^:]*:\s*return "(\w+)"/g), + (match) => [String(match[1]), String(match[2])] + ) + ); +} + +function androidErrorTypes(): Map { + const body = mapErrorTypeBody( + read('android/src/main/java/com/sdkreactnative/SdkReactNativeModule.kt'), + /private fun mapErrorType\(/ + ); + + return new Map( + Array.from( + body.matchAll(/is VerificationError\.(\w+) -> "(\w+)"/g), + (match) => { + const name = String(match[1]); + + return [name[0]!.toLowerCase() + name.slice(1), String(match[2])]; + } + ) + ); +} + +describe('native error type mapping', () => { + const ios = iosErrorTypes(); + const android = androidErrorTypes(); + + it('reads both bridge switches', () => { + const shared = ['sessionExpired', 'networkError', 'cameraAccessDenied']; + + expect([...ios.keys()]).toEqual( + expect.arrayContaining([...shared, 'unknown']) + ); + expect([...android.keys()]).toEqual( + expect.arrayContaining([...shared, 'notInitialized', 'apiError']) + ); + }); + + it('sends retryBlocked from both bridges', () => { + expect(ios.get('retryBlocked')).toBe('retryBlocked'); + expect(android.get('retryBlocked')).toBe('retryBlocked'); + }); + + it('sends the same string from Android for every error the iOS bridge maps', () => { + Array.from(ios).forEach(([nativeCase, errorType]) => { + expect([nativeCase, android.get(nativeCase)]).toEqual([ + nativeCase, + errorType, + ]); + }); + }); + + it.each([...new Set([...ios.values(), ...android.values()])])( + 'keeps the %s error type in the JS result', + async (errorType) => { + (NativeSdkReactNative.startVerification as jest.Mock).mockResolvedValue({ + type: 'failed', + errorType, + errorMessage: 'native message', + sessionId: 'test-session', + status: 'Declined', + }); + + expect(await startVerification('test-token')).toMatchObject({ + type: 'failed', + error: { type: errorType, message: 'native message' }, + session: { sessionId: 'test-session' }, + }); + } + ); +}); diff --git a/src/__tests__/native-sdk-pins.test.ts b/src/__tests__/native-sdk-pins.test.ts index 0bfb756..36d0f43 100644 --- a/src/__tests__/native-sdk-pins.test.ts +++ b/src/__tests__/native-sdk-pins.test.ts @@ -40,6 +40,14 @@ const BARE_PODFILE = ["target 'App' do", ' use_native_modules!', 'end'].join( ); async function runPodfileMod(props: object, podfile: string) { + jest.resetModules(); + jest.doMock('../../package.json', () => ({ + ...pkg, + diditNativeSdkVersions: { + ...pkg.diditNativeSdkVersions, + source: undefined, + }, + })); const withDiditSdk = require('../../app.plugin.js'); const config = withDiditSdk({ name: 'test', slug: 'test' }, props); const result = await config.mods.ios.podfile({ @@ -67,13 +75,17 @@ describe('expo config plugin podspec pin', () => { it('injects the declared iOS version into an Expo Podfile', async () => { const contents = await runPodfileMod({}, EXPO_PODFILE); - expect(podspecUrlRefs(contents)).toEqual([declaredIosVersion]); + expect(new Set(podspecUrlRefs(contents))).toEqual( + new Set([declaredIosVersion]) + ); }); it('injects the declared iOS version into a bare React Native Podfile', async () => { const contents = await runPodfileMod({}, BARE_PODFILE); - expect(podspecUrlRefs(contents)).toEqual([declaredIosVersion]); + expect(new Set(podspecUrlRefs(contents))).toEqual( + new Set([declaredIosVersion]) + ); }); it('lets a consumer override the podspec URL as an escape hatch', async () => { @@ -156,7 +168,7 @@ describe('swift package manager linkage', () => { }); describe('hand-maintained pins stay in lockstep', () => { - it.each(['README.md', 'example-expo/ios/Podfile'])( + it.each(['README.md'])( '%s references only the declared iOS version', (relativePath) => { const refs = podspecUrlRefs(read(relativePath)); @@ -174,10 +186,11 @@ describe('hand-maintained pins stay in lockstep', () => { }); it('android/build.gradle pins the declared Android version', () => { - const match = read('android/build.gradle').match( - /me\.didit:\$diditSdkAndroidArtifact:([\d.]+)/ - ); + const gradle = read('android/build.gradle'); - expect(match?.[1]).toBe(declaredAndroidVersion); + expect(gradle).toContain('diditNativeSdkVersions'); + expect(gradle).toContain( + 'me.didit:$diditSdkAndroidArtifact:${nativePins.android}' + ); }); }); diff --git a/src/__tests__/native-source.test.ts b/src/__tests__/native-source.test.ts new file mode 100644 index 0000000..dbfb782 --- /dev/null +++ b/src/__tests__/native-source.test.ts @@ -0,0 +1,96 @@ +import { execFileSync } from 'child_process'; +import { readFileSync, realpathSync } from 'fs'; + +jest.mock('child_process', () => ({ execFileSync: jest.fn() })); +jest.mock('fs', () => ({ + realpathSync: jest.fn((path) => path), + readFileSync: jest.fn(), +})); + +const { diditNativeSdkVersions } = require('../../package.json'); +const { + resolveNativeSdkSource, + resolveAndroidRepository, +} = require('../../native-sdk-source'); +const git = execFileSync as jest.Mock; +const originalPath = process.env.DIDIT_SDK_SOURCE_PATH; + +afterEach(() => { + if (originalPath === undefined) delete process.env.DIDIT_SDK_SOURCE_PATH; + else process.env.DIDIT_SDK_SOURCE_PATH = originalPath; + jest.clearAllMocks(); +}); + +it('requires an immutable revision for the integration branch', () => { + expect(diditNativeSdkVersions.source.branch).toBe('integration/console-v2'); + expect(diditNativeSdkVersions.source.revision).toMatch(/^[a-f0-9]{40}$/); +}); + +it('fails instead of falling back to released binaries without a checkout', () => { + delete process.env.DIDIT_SDK_SOURCE_PATH; + + expect(() => resolveNativeSdkSource('ios')).toThrow('DIDIT_SDK_SOURCE_PATH'); + expect(git).not.toHaveBeenCalled(); +}); + +it('rejects a checkout at a different revision', () => { + process.env.DIDIT_SDK_SOURCE_PATH = '/native'; + git.mockReturnValueOnce('different-revision'); + + expect(() => resolveNativeSdkSource('android')).toThrow('must match'); + expect(realpathSync).not.toHaveBeenCalled(); +}); + +it('rejects tracked edits to the pinned source', () => { + process.env.DIDIT_SDK_SOURCE_PATH = '/native'; + git.mockReturnValueOnce(diditNativeSdkVersions.source.revision); + git.mockReturnValueOnce(' M sdk-source'); + + expect(() => resolveNativeSdkSource('ios')).toThrow('local changes'); +}); + +it.each([ + ['ios', '/native/ios/sdk'], + ['android', '/native/android'], +])( + 'resolves the %s project from the same verified checkout', + (platform, path) => { + process.env.DIDIT_SDK_SOURCE_PATH = '/native'; + git.mockReturnValueOnce(diditNativeSdkVersions.source.revision); + git.mockReturnValueOnce(''); + + expect(resolveNativeSdkSource(platform)).toBe(path); + expect(git.mock.calls[0][1]).toEqual([ + '-C', + '/native', + 'rev-parse', + 'HEAD', + ]); + } +); + +it('rejects local artifacts built from another revision', () => { + process.env.DIDIT_SDK_SOURCE_PATH = '/native'; + git + .mockReturnValueOnce(diditNativeSdkVersions.source.revision) + .mockReturnValueOnce(''); + (readFileSync as jest.Mock).mockReturnValueOnce( + JSON.stringify({ revision: 'different' }) + ); + + expect(() => resolveAndroidRepository()).toThrow('yarn native:android'); +}); + +it('selects only the local repository built from the pin', () => { + process.env.DIDIT_SDK_SOURCE_PATH = '/native'; + git + .mockReturnValueOnce(diditNativeSdkVersions.source.revision) + .mockReturnValueOnce(''); + (readFileSync as jest.Mock).mockReturnValueOnce( + JSON.stringify(diditNativeSdkVersions.source) + ); + + expect(resolveAndroidRepository()).toContain( + `.native-sdk/${diditNativeSdkVersions.source.revision}/maven` + ); +}); diff --git a/src/__tests__/verification-pass-through.test.ts b/src/__tests__/verification-pass-through.test.ts new file mode 100644 index 0000000..5531f9e --- /dev/null +++ b/src/__tests__/verification-pass-through.test.ts @@ -0,0 +1,66 @@ +jest.mock('../NativeSdkReactNative', () => ({ + __esModule: true, + default: { + startVerification: jest.fn(), + startVerificationWithWorkflow: jest.fn(), + onTransactionUpdated: jest.fn(() => ({ remove: jest.fn() })), + }, +})); + +import NativeSdkReactNative from '../NativeSdkReactNative'; +import { startVerification, startVerificationWithWorkflow } from '../index'; + +const entries = [ + { + name: 'session', + native: NativeSdkReactNative.startVerification, + start: () => startVerification('test-token', { languageCode: 'es' }), + }, + { + name: 'workflow', + native: NativeSdkReactNative.startVerificationWithWorkflow, + start: () => + startVerificationWithWorkflow('test-workflow', { + config: { languageCode: 'es' }, + }), + }, +]; +const session = { sessionId: 'test-session', status: 'Pending' }; + +describe.each(entries)( + '$name verification entry point', + ({ native, start }) => { + it.each(['completed', 'cancelled'])( + 'preserves the native %s outcome', + async (type) => { + (native as jest.Mock).mockResolvedValue({ type, ...session }); + + expect(await start()).toEqual({ type, session }); + } + ); + + it.each(['retryBlocked', 'unknown', 'networkError'])( + 'preserves %s failures without losing the session', + async (errorType) => { + (native as jest.Mock).mockResolvedValue({ + type: 'failed', + errorType, + errorMessage: 'Native failure', + ...session, + }); + + expect(await start()).toEqual({ + type: 'failed', + error: { type: errorType, message: 'Native failure' }, + session, + }); + } + ); + + it('does not invent a session when cancellation has none', async () => { + (native as jest.Mock).mockResolvedValue({ type: 'cancelled' }); + + expect(await start()).toEqual({ type: 'cancelled', session: undefined }); + }); + } +); diff --git a/turbo.json b/turbo.json index 5c49487..39289cf 100644 --- a/turbo.json +++ b/turbo.json @@ -11,6 +11,7 @@ "build:android": { "env": [ "ANDROID_HOME", + "DIDIT_SDK_SOURCE_PATH", "ORG_GRADLE_PROJECT_newArchEnabled" ], "inputs": [ @@ -20,13 +21,17 @@ "$TURBO_ROOT$/cpp/**", "$TURBO_ROOT$/app.plugin.js", "$TURBO_ROOT$/react-native.config.js", + "$TURBO_ROOT$/native-sdk-source.js", "$TURBO_ROOT$/android/build.gradle", + "$TURBO_ROOT$/android/native-source.gradle", "$TURBO_ROOT$/android/src/**" ], "outputs": [] }, "build:ios": { "env": [ + "DIDIT_SDK_SOURCE_PATH", + "DIDIT_SDK_IOS_LOCATION_ENABLED", "RCT_NEW_ARCH_ENABLED", "RCT_REMOVE_LEGACY_ARCH", "RCT_USE_RN_DEP", @@ -39,6 +44,7 @@ "$TURBO_ROOT$/cpp/**", "$TURBO_ROOT$/app.plugin.js", "$TURBO_ROOT$/react-native.config.js", + "$TURBO_ROOT$/native-sdk-source.js", "$TURBO_ROOT$/*.podspec", "$TURBO_ROOT$/ios/**", "!$TURBO_ROOT$/ios/build/**"