@dynamic-labs-sdk/client 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/android/build.gradle +231 -0
  2. package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  3. package/android/gradle/wrapper/gradle-wrapper.properties +7 -0
  4. package/android/gradle.properties +8 -0
  5. package/android/gradlew +251 -0
  6. package/android/gradlew.bat +94 -0
  7. package/android/settings.gradle +30 -0
  8. package/android/src/main/java/xyz/dynamic/client/DynamicClientPackage.kt +52 -0
  9. package/android/src/main/java/xyz/dynamic/client/keychain/KeyStoreKeyManager.kt +147 -0
  10. package/android/src/main/java/xyz/dynamic/client/keychain/KeychainModule.kt +85 -0
  11. package/android/src/main/java/xyz/dynamic/client/manifest/ReactNativeManifestModule.kt +25 -0
  12. package/android/src/main/java/xyz/dynamic/client/webview/DynamicWebViewClient.kt +83 -0
  13. package/android/src/main/java/xyz/dynamic/client/webview/DynamicWebViewHost.kt +518 -0
  14. package/android/src/main/java/xyz/dynamic/client/webview/DynamicWebViewModule.kt +147 -0
  15. package/android/src/test/java/xyz/dynamic/client/keychain/KeyStoreKeyManagerTest.kt +288 -0
  16. package/android/src/test/java/xyz/dynamic/client/webview/DynamicWebViewClientTest.kt +196 -0
  17. package/android/src/test/java/xyz/dynamic/client/webview/DynamicWebViewHostOpenTest.kt +347 -0
  18. package/android/src/test/java/xyz/dynamic/client/webview/DynamicWebViewHostTest.kt +171 -0
  19. package/android/src/test/java/xyz/dynamic/client/webview/DynamicWebViewModuleTest.kt +191 -0
  20. package/dist/{InvalidParamError-BIptAjbC.native.esm.js → InvalidParamError-Bc6OZBog.native.esm.js} +91 -829
  21. package/dist/InvalidParamError-Bc6OZBog.native.esm.js.map +1 -0
  22. package/dist/{InvalidParamError-BZ0LHaml.esm.js → InvalidParamError-IHYLdzFd.esm.js} +108 -381
  23. package/dist/InvalidParamError-IHYLdzFd.esm.js.map +1 -0
  24. package/dist/{InvalidParamError-DyXEKMWn.cjs → InvalidParamError-S56rFFDP.cjs} +108 -450
  25. package/dist/InvalidParamError-S56rFFDP.cjs.map +1 -0
  26. package/dist/{NotWaasWalletAccountError-nT4jrLbp.esm.js → NotWaasWalletAccountError-BP-bODbJ.native.esm.js} +4 -4
  27. package/dist/NotWaasWalletAccountError-BP-bODbJ.native.esm.js.map +1 -0
  28. package/dist/{NotWaasWalletAccountError-BbOA5rPd.native.esm.js → NotWaasWalletAccountError-BYEL2l-W.esm.js} +4 -4
  29. package/dist/NotWaasWalletAccountError-BYEL2l-W.esm.js.map +1 -0
  30. package/dist/{NotWaasWalletAccountError-CElqwdQO.cjs → NotWaasWalletAccountError-CbhIKUDM.cjs} +4 -4
  31. package/dist/NotWaasWalletAccountError-CbhIKUDM.cjs.map +1 -0
  32. package/dist/core.cjs +11 -11
  33. package/dist/core.cjs.map +1 -1
  34. package/dist/core.esm.js +5 -5
  35. package/dist/core.native.esm.js +7 -7
  36. package/dist/core.native.esm.js.map +1 -1
  37. package/dist/errors/WebViewLoadFailedError.d.ts +1 -1
  38. package/dist/errors/WebViewLoadFailedError.d.ts.map +1 -1
  39. package/dist/exports/index.d.ts +2 -0
  40. package/dist/exports/index.d.ts.map +1 -1
  41. package/dist/{getNetworkProviderFromNetworkId-DbI5MZhq.cjs → getNetworkProviderFromNetworkId-BDdcpGHH.cjs} +56 -55
  42. package/dist/{getNetworkProviderFromNetworkId-DbI5MZhq.cjs.map → getNetworkProviderFromNetworkId-BDdcpGHH.cjs.map} +1 -1
  43. package/dist/{getNetworkProviderFromNetworkId-BkAPIdRC.esm.js → getNetworkProviderFromNetworkId-D8rWYXlT.esm.js} +54 -54
  44. package/dist/{getNetworkProviderFromNetworkId-BkAPIdRC.esm.js.map → getNetworkProviderFromNetworkId-D8rWYXlT.esm.js.map} +1 -1
  45. package/dist/{getNetworkProviderFromNetworkId-B7DIqT2T.native.esm.js → getNetworkProviderFromNetworkId-DVmF7pH2.native.esm.js} +312 -41
  46. package/dist/getNetworkProviderFromNetworkId-DVmF7pH2.native.esm.js.map +1 -0
  47. package/dist/{getSignedSessionId-BF6n8gve.cjs → getSignedSessionId-BBaBCi9S.cjs} +3 -3
  48. package/dist/{getSignedSessionId-BF6n8gve.cjs.map → getSignedSessionId-BBaBCi9S.cjs.map} +1 -1
  49. package/dist/{getSignedSessionId-y3YtxFHZ.esm.js → getSignedSessionId-CND07Eca.esm.js} +3 -3
  50. package/dist/{getSignedSessionId-y3YtxFHZ.esm.js.map → getSignedSessionId-CND07Eca.esm.js.map} +1 -1
  51. package/dist/{getSignedSessionId-1TV-x__C.native.esm.js → getSignedSessionId-RwV7JPn8.native.esm.js} +3 -3
  52. package/dist/{getSignedSessionId-1TV-x__C.native.esm.js.map → getSignedSessionId-RwV7JPn8.native.esm.js.map} +1 -1
  53. package/dist/getVerifiedCredentialForWalletAccount-BVrGV8qf.native.esm.js +701 -0
  54. package/dist/getVerifiedCredentialForWalletAccount-BVrGV8qf.native.esm.js.map +1 -0
  55. package/dist/{getVerifiedCredentialForWalletAccount-etV50QRH.esm.js → getVerifiedCredentialForWalletAccount-BiOS65Tk.esm.js} +2 -2
  56. package/dist/{getVerifiedCredentialForWalletAccount-etV50QRH.esm.js.map → getVerifiedCredentialForWalletAccount-BiOS65Tk.esm.js.map} +1 -1
  57. package/dist/{getVerifiedCredentialForWalletAccount-BfJ29erz.cjs → getVerifiedCredentialForWalletAccount-FrxYWSuD.cjs} +3 -2
  58. package/dist/{getVerifiedCredentialForWalletAccount-BfJ29erz.cjs.map → getVerifiedCredentialForWalletAccount-FrxYWSuD.cjs.map} +1 -1
  59. package/dist/index.cjs +35 -35
  60. package/dist/index.cjs.map +1 -1
  61. package/dist/index.esm.js +14 -14
  62. package/dist/index.esm.js.map +1 -1
  63. package/dist/index.native.esm.js +14 -14
  64. package/dist/index.native.esm.js.map +1 -1
  65. package/dist/isMfaRequiredForAction-BPL_dgIM.esm.js +318 -0
  66. package/dist/isMfaRequiredForAction-BPL_dgIM.esm.js.map +1 -0
  67. package/dist/isMfaRequiredForAction-BolTrtCO.cjs +405 -0
  68. package/dist/isMfaRequiredForAction-BolTrtCO.cjs.map +1 -0
  69. package/dist/{isMfaRequiredForAction-B2vKX31S.native.esm.js → isMfaRequiredForAction-DQDoPsi6.native.esm.js} +2 -2
  70. package/dist/{isMfaRequiredForAction-B2vKX31S.native.esm.js.map → isMfaRequiredForAction-DQDoPsi6.native.esm.js.map} +1 -1
  71. package/dist/modules/apiClient/apiClient.types.d.ts +0 -12
  72. package/dist/modules/apiClient/apiClient.types.d.ts.map +1 -1
  73. package/dist/modules/apiClient/createApiClient.d.ts.map +1 -1
  74. package/dist/modules/auth/logoutReason.d.ts +1 -1
  75. package/dist/modules/auth/logoutReason.d.ts.map +1 -1
  76. package/dist/modules/auth/logoutWithReason/logoutWithReason.d.ts.map +1 -1
  77. package/dist/modules/deviceRegistration/getRegisteredDevices/getRegisteredDevices.d.ts.map +1 -1
  78. package/dist/modules/user/refreshAuth/refreshAuth.d.ts.map +1 -1
  79. package/dist/modules/waas/backupWaasKeySharesToGoogleDrive/backupWaasKeySharesToGoogleDrive.d.ts +12 -1
  80. package/dist/modules/waas/backupWaasKeySharesToGoogleDrive/backupWaasKeySharesToGoogleDrive.d.ts.map +1 -1
  81. package/dist/modules/waas/createWaasClient/createWaasClient.d.ts.map +1 -1
  82. package/dist/modules/waas/createWaasClient/translateNativeOpenError.d.ts.map +1 -1
  83. package/dist/modules/waas/createWaasProvider/createWaasProvider.d.ts.map +1 -1
  84. package/dist/modules/waas/waas.types.d.ts +1 -0
  85. package/dist/modules/waas/waas.types.d.ts.map +1 -1
  86. package/dist/modules/wallets/walletProvider/events/onWalletProviderEvent/onWalletProviderEvent.d.ts +1 -2
  87. package/dist/modules/wallets/walletProvider/events/onWalletProviderEvent/onWalletProviderEvent.d.ts.map +1 -1
  88. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  89. package/dist/waas.cjs +7 -4
  90. package/dist/waas.cjs.map +1 -1
  91. package/dist/waas.esm.js +6 -4
  92. package/dist/waas.esm.js.map +1 -1
  93. package/dist/waas.native.esm.js +6 -4
  94. package/dist/waas.native.esm.js.map +1 -1
  95. package/dist/waasCore.cjs +10 -5
  96. package/dist/waasCore.cjs.map +1 -1
  97. package/dist/waasCore.esm.js +10 -5
  98. package/dist/waasCore.esm.js.map +1 -1
  99. package/dist/waasCore.native.esm.js +10 -8
  100. package/dist/waasCore.native.esm.js.map +1 -1
  101. package/dynamic-labs-sdk-client.podspec +27 -0
  102. package/ios/DynamicClientKeychain.h +4 -0
  103. package/ios/DynamicClientKeychain.mm +101 -0
  104. package/ios/DynamicClientManifest.h +4 -0
  105. package/ios/DynamicClientManifest.mm +45 -0
  106. package/ios/DynamicClientWebView.h +5 -0
  107. package/ios/DynamicClientWebView.mm +143 -0
  108. package/ios/DynamicWebViewImpl.swift +603 -0
  109. package/ios/LiveSecKeyAPI.swift +86 -0
  110. package/ios/ReactNativeManifestImpl.swift +20 -0
  111. package/ios/SecKeyAPI.swift +57 -0
  112. package/ios/SecureEnclaveKeyManager.swift +191 -0
  113. package/package.json +16 -5
  114. package/react-native.config.cjs +14 -0
  115. package/src/turboModules/NativeDynamicWebView.native.spec.ts +61 -0
  116. package/src/turboModules/NativeDynamicWebView.ts +40 -0
  117. package/src/turboModules/NativeKeychain.native.spec.ts +47 -0
  118. package/src/turboModules/NativeKeychain.ts +19 -0
  119. package/src/turboModules/NativeReactNativeManifest.native.spec.ts +55 -0
  120. package/src/turboModules/NativeReactNativeManifest.ts +28 -0
  121. package/dist/InvalidParamError-BIptAjbC.native.esm.js.map +0 -1
  122. package/dist/InvalidParamError-BZ0LHaml.esm.js.map +0 -1
  123. package/dist/InvalidParamError-DyXEKMWn.cjs.map +0 -1
  124. package/dist/NotWaasWalletAccountError-BbOA5rPd.native.esm.js.map +0 -1
  125. package/dist/NotWaasWalletAccountError-CElqwdQO.cjs.map +0 -1
  126. package/dist/NotWaasWalletAccountError-nT4jrLbp.esm.js.map +0 -1
  127. package/dist/getNetworkProviderFromNetworkId-B7DIqT2T.native.esm.js.map +0 -1
  128. package/dist/getVerifiedCredentialForWalletAccount-CLyC2nOl.native.esm.js +0 -269
  129. package/dist/getVerifiedCredentialForWalletAccount-CLyC2nOl.native.esm.js.map +0 -1
  130. package/dist/isMfaRequiredForAction-Dy4-S3mr.esm.js +0 -79
  131. package/dist/isMfaRequiredForAction-Dy4-S3mr.esm.js.map +0 -1
  132. package/dist/isMfaRequiredForAction-K2Jh-3-j.cjs +0 -96
  133. package/dist/isMfaRequiredForAction-K2Jh-3-j.cjs.map +0 -1
  134. package/dist/modules/apiClient/utils/sessionExpiryLogoutMiddleware/createSessionExpiryLogoutMiddleware.d.ts +0 -27
  135. package/dist/modules/apiClient/utils/sessionExpiryLogoutMiddleware/createSessionExpiryLogoutMiddleware.d.ts.map +0 -1
@@ -0,0 +1,288 @@
1
+ package xyz.dynamic.client.keychain
2
+
3
+ import android.content.Context
4
+ import android.security.keystore.KeyGenParameterSpec
5
+ import android.security.keystore.KeyProperties
6
+ import android.security.keystore.StrongBoxUnavailableException
7
+ import io.mockk.every
8
+ import io.mockk.mockk
9
+ import io.mockk.mockkStatic
10
+ import io.mockk.slot
11
+ import io.mockk.unmockkAll
12
+ import io.mockk.verify
13
+ import java.security.KeyPair
14
+ import java.security.KeyPairGenerator
15
+ import java.security.KeyStore
16
+ import java.security.Signature
17
+ import java.security.spec.ECGenParameterSpec
18
+ import java.util.Base64
19
+ import org.junit.After
20
+ import org.junit.Assert.assertArrayEquals
21
+ import org.junit.Assert.assertEquals
22
+ import org.junit.Assert.assertFalse
23
+ import org.junit.Assert.assertNull
24
+ import org.junit.Assert.assertThrows
25
+ import org.junit.Assert.assertTrue
26
+ import org.junit.Before
27
+ import org.junit.Test
28
+ import org.junit.runner.RunWith
29
+ import org.robolectric.RobolectricTestRunner
30
+ import org.robolectric.annotation.Config
31
+
32
+ // SECURITY CAVEAT (mirrors SecureEnclaveKeyManagerTests.swift): these tests use
33
+ // MockK to stand in for the AndroidKeyStore JCA provider, which is hardware
34
+ // (TEE/StrongBox) and does not exist under Robolectric. They verify that
35
+ // KeyStoreKeyManager calls the right APIs with the right arguments and handles
36
+ // the returned values correctly — they do NOT verify the KeyStore hardware
37
+ // itself. A manual on-device smoke test of generateKeyPair and sign remains
38
+ // required before release. The base64url and SEC1-extraction logic, by
39
+ // contrast, is exercised for real (android.util.Base64 runs under Robolectric).
40
+ @RunWith(RobolectricTestRunner::class)
41
+ @Config(sdk = [35])
42
+ class KeyStoreKeyManagerTest {
43
+
44
+ private val androidKeyStore = "AndroidKeyStore"
45
+
46
+ // A genuine P-256 keypair generated on the JVM, used as the fixture the
47
+ // mocked AndroidKeyStore "returns". Generated BEFORE any mockkStatic call
48
+ // so the real SunEC provider is still in place.
49
+ private lateinit var realKeyPair: KeyPair
50
+ private lateinit var manager: KeyStoreKeyManager
51
+
52
+ @Before
53
+ fun setUp() {
54
+ val kpg = KeyPairGenerator.getInstance("EC")
55
+ kpg.initialize(ECGenParameterSpec("secp256r1"))
56
+ realKeyPair = kpg.generateKeyPair()
57
+ manager = KeyStoreKeyManager()
58
+ }
59
+
60
+ @After
61
+ fun tearDown() {
62
+ unmockkAll()
63
+ }
64
+
65
+ // Independent oracle for the manager's base64url encoding (last 65 bytes of
66
+ // the X.509 SubjectPublicKeyInfo = the uncompressed SEC1 point).
67
+ private fun expectedEncodedPublicKey(): String {
68
+ val x509 = realKeyPair.public.encoded
69
+ val sec1 = x509.copyOfRange(x509.size - 65, x509.size)
70
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(sec1)
71
+ }
72
+
73
+ // region base64url
74
+
75
+ @Test
76
+ fun base64urlDecode_decodesUrlAlphabetWithoutPadding() {
77
+ // "-_-_AA" is base64url for FB FF BF 00 — exercises the - and _
78
+ // substitutions and the stripped padding (same vector as the iOS test).
79
+ val decoded = KeyStoreKeyManager.base64urlDecode("-_-_AA")
80
+ assertArrayEquals(byteArrayOf(0xFB.toByte(), 0xFF.toByte(), 0xBF.toByte(), 0x00), decoded)
81
+ }
82
+
83
+ @Test
84
+ fun base64urlDecode_roundTripsArbitraryBytes() {
85
+ val original = byteArrayOf(0x04, 0xAB.toByte(), 0xCD.toByte(), 0x00, 0x7F)
86
+ val encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(original)
87
+ assertArrayEquals(original, KeyStoreKeyManager.base64urlDecode(encoded))
88
+ }
89
+
90
+ // endregion
91
+
92
+ // region isAvailable
93
+
94
+ @Test
95
+ fun isAvailable_trueWhenKeyStoreLoads() {
96
+ mockkStatic(KeyStore::class)
97
+ every { KeyStore.getInstance(androidKeyStore) } returns mockk(relaxed = true)
98
+ assertTrue(manager.isAvailable(null))
99
+ }
100
+
101
+ @Test
102
+ fun isAvailable_falseWhenKeyStoreThrows() {
103
+ mockkStatic(KeyStore::class)
104
+ every { KeyStore.getInstance(androidKeyStore) } throws RuntimeException("no provider")
105
+ assertFalse(manager.isAvailable(null))
106
+ }
107
+
108
+ @Test
109
+ fun isAvailable_trueWithStrongBoxFeature() {
110
+ mockkStatic(KeyStore::class)
111
+ every { KeyStore.getInstance(androidKeyStore) } returns mockk(relaxed = true)
112
+ val context = mockk<Context>(relaxed = true)
113
+ every { context.packageManager.hasSystemFeature(any()) } returns true
114
+ assertTrue(manager.isAvailable(context))
115
+ }
116
+
117
+ // endregion
118
+
119
+ // region hasKey / deleteKey
120
+
121
+ @Test
122
+ fun hasKey_reflectsContainsAlias() {
123
+ val keyStore = mockk<KeyStore>(relaxed = true)
124
+ mockkStatic(KeyStore::class)
125
+ every { KeyStore.getInstance(androidKeyStore) } returns keyStore
126
+ every { keyStore.containsAlias("present") } returns true
127
+ every { keyStore.containsAlias("absent") } returns false
128
+
129
+ assertTrue(manager.hasKey("present"))
130
+ assertFalse(manager.hasKey("absent"))
131
+ }
132
+
133
+ @Test
134
+ fun deleteKey_deletesEntryForAlias() {
135
+ val keyStore = mockk<KeyStore>(relaxed = true)
136
+ mockkStatic(KeyStore::class)
137
+ every { KeyStore.getInstance(androidKeyStore) } returns keyStore
138
+
139
+ manager.deleteKey("doomed")
140
+
141
+ verify { keyStore.deleteEntry("doomed") }
142
+ }
143
+
144
+ // endregion
145
+
146
+ // region getPublicKey
147
+
148
+ @Test
149
+ fun getPublicKey_nullWhenEntryMissing() {
150
+ val keyStore = mockk<KeyStore>(relaxed = true)
151
+ mockkStatic(KeyStore::class)
152
+ every { KeyStore.getInstance(androidKeyStore) } returns keyStore
153
+ every { keyStore.getEntry("missing", null) } returns null
154
+
155
+ assertNull(manager.getPublicKey("missing"))
156
+ }
157
+
158
+ @Test
159
+ fun getPublicKey_returnsBase64urlSec1PointForPrivateKeyEntry() {
160
+ val keyStore = mockk<KeyStore>(relaxed = true)
161
+ val entry = mockk<KeyStore.PrivateKeyEntry>(relaxed = true)
162
+ every { entry.certificate.publicKey.encoded } returns realKeyPair.public.encoded
163
+ mockkStatic(KeyStore::class)
164
+ every { KeyStore.getInstance(androidKeyStore) } returns keyStore
165
+ every { keyStore.getEntry("tag", null) } returns entry
166
+
167
+ assertEquals(expectedEncodedPublicKey(), manager.getPublicKey("tag"))
168
+ }
169
+
170
+ // endregion
171
+
172
+ // region sign
173
+
174
+ @Test
175
+ fun sign_throwsWhenKeyMissing() {
176
+ val keyStore = mockk<KeyStore>(relaxed = true)
177
+ mockkStatic(KeyStore::class)
178
+ every { KeyStore.getInstance(androidKeyStore) } returns keyStore
179
+ every { keyStore.getEntry("missing", null) } returns null
180
+
181
+ val ex = assertThrows(IllegalArgumentException::class.java) {
182
+ manager.sign("missing", byteArrayOf(1, 2, 3))
183
+ }
184
+ assertEquals("Key not found: missing", ex.message)
185
+ }
186
+
187
+ @Test
188
+ fun sign_usesEcdsaSha256AndReturnsBase64urlSignature() {
189
+ val keyStore = mockk<KeyStore>(relaxed = true)
190
+ val entry = mockk<KeyStore.PrivateKeyEntry>(relaxed = true)
191
+ every { entry.privateKey } returns realKeyPair.private
192
+ mockkStatic(KeyStore::class)
193
+ every { KeyStore.getInstance(androidKeyStore) } returns keyStore
194
+ every { keyStore.getEntry("tag", null) } returns entry
195
+
196
+ val signatureBytes = byteArrayOf(0xDE.toByte(), 0xAD.toByte())
197
+ val signature = mockk<Signature>(relaxed = true)
198
+ every { signature.sign() } returns signatureBytes
199
+ mockkStatic(Signature::class)
200
+ every { Signature.getInstance("SHA256withECDSA") } returns signature
201
+
202
+ val payload = byteArrayOf(0x01, 0x02)
203
+ val result = manager.sign("tag", payload)
204
+
205
+ assertEquals(
206
+ Base64.getUrlEncoder().withoutPadding().encodeToString(signatureBytes),
207
+ result,
208
+ )
209
+ verify { signature.initSign(realKeyPair.private) }
210
+ verify { signature.update(payload) }
211
+ }
212
+
213
+ // endregion
214
+
215
+ // region generateKeyPair
216
+
217
+ private fun stubKeyStoreWithoutAlias(): KeyStore {
218
+ val keyStore = mockk<KeyStore>(relaxed = true)
219
+ mockkStatic(KeyStore::class)
220
+ every { KeyStore.getInstance(androidKeyStore) } returns keyStore
221
+ every { keyStore.containsAlias(any()) } returns false
222
+ return keyStore
223
+ }
224
+
225
+ @Test
226
+ fun generateKeyPair_throwsWhenAliasAlreadyExists() {
227
+ val keyStore = mockk<KeyStore>(relaxed = true)
228
+ mockkStatic(KeyStore::class)
229
+ every { KeyStore.getInstance(androidKeyStore) } returns keyStore
230
+ every { keyStore.containsAlias("dupe") } returns true
231
+
232
+ val ex = assertThrows(IllegalArgumentException::class.java) {
233
+ manager.generateKeyPair("dupe")
234
+ }
235
+ assertEquals("Key already exists for alias: dupe", ex.message)
236
+ }
237
+
238
+ @Test
239
+ fun generateKeyPair_initializesSpecWithExpectedShape() {
240
+ stubKeyStoreWithoutAlias()
241
+ val kpg = mockk<KeyPairGenerator>(relaxed = true)
242
+ every { kpg.generateKeyPair() } returns realKeyPair
243
+ mockkStatic(KeyPairGenerator::class)
244
+ every { KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, androidKeyStore) } returns kpg
245
+
246
+ val specSlot = slot<java.security.spec.AlgorithmParameterSpec>()
247
+ every { kpg.initialize(capture(specSlot)) } returns Unit
248
+
249
+ val encoded = manager.generateKeyPair("mytag")
250
+
251
+ val spec = specSlot.captured as KeyGenParameterSpec
252
+ assertEquals("mytag", spec.keystoreAlias)
253
+ assertEquals(
254
+ KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY,
255
+ spec.purposes,
256
+ )
257
+ assertTrue(spec.digests.contains(KeyProperties.DIGEST_SHA256))
258
+ assertEquals(
259
+ "secp256r1",
260
+ (spec.algorithmParameterSpec as ECGenParameterSpec).name,
261
+ )
262
+ // On API >= P the first attempt requests StrongBox backing.
263
+ assertTrue(spec.isStrongBoxBacked)
264
+ assertEquals(expectedEncodedPublicKey(), encoded)
265
+ }
266
+
267
+ @Test
268
+ fun generateKeyPair_fallsBackToSoftwareWhenStrongBoxUnavailable() {
269
+ stubKeyStoreWithoutAlias()
270
+ val kpg = mockk<KeyPairGenerator>(relaxed = true)
271
+ // First (StrongBox) attempt fails; second (software) attempt succeeds.
272
+ every { kpg.generateKeyPair() } throws StrongBoxUnavailableException() andThen realKeyPair
273
+ mockkStatic(KeyPairGenerator::class)
274
+ every { KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, androidKeyStore) } returns kpg
275
+
276
+ val specSlots = mutableListOf<java.security.spec.AlgorithmParameterSpec>()
277
+ every { kpg.initialize(capture(specSlots)) } returns Unit
278
+
279
+ val encoded = manager.generateKeyPair("mytag")
280
+
281
+ assertEquals(2, specSlots.size)
282
+ assertTrue((specSlots[0] as KeyGenParameterSpec).isStrongBoxBacked)
283
+ assertFalse((specSlots[1] as KeyGenParameterSpec).isStrongBoxBacked)
284
+ assertEquals(expectedEncodedPublicKey(), encoded)
285
+ }
286
+
287
+ // endregion
288
+ }
@@ -0,0 +1,196 @@
1
+ package xyz.dynamic.client.webview
2
+
3
+ import android.net.Uri
4
+ import android.webkit.WebResourceError
5
+ import android.webkit.WebResourceRequest
6
+ import android.webkit.WebResourceResponse
7
+ import android.webkit.WebView
8
+ import androidx.webkit.WebViewFeature
9
+ import io.mockk.every
10
+ import io.mockk.mockk
11
+ import io.mockk.mockkStatic
12
+ import io.mockk.unmockkAll
13
+ import io.mockk.verify
14
+ import org.junit.After
15
+ import org.junit.Assert.assertFalse
16
+ import org.junit.Assert.assertTrue
17
+ import org.junit.Before
18
+ import org.junit.Test
19
+ import org.junit.runner.RunWith
20
+ import org.robolectric.RobolectricTestRunner
21
+ import org.robolectric.annotation.Config
22
+
23
+ // Exercises the WebViewClient → host delegation in isolation: the host is a
24
+ // MockK mock so no Activity/WebView runtime is needed. Runs under Robolectric
25
+ // because DynamicWebViewClient extends the framework WebViewClient (whose
26
+ // android.jar stub constructor throws off-device) and the tests parse Uris.
27
+ @RunWith(RobolectricTestRunner::class)
28
+ @Config(sdk = [35])
29
+ class DynamicWebViewClientTest {
30
+
31
+ private lateinit var host: DynamicWebViewHost
32
+ private lateinit var client: DynamicWebViewClient
33
+
34
+ @Before
35
+ fun setUp() {
36
+ host = mockk(relaxed = true)
37
+ client = DynamicWebViewClient(host)
38
+ // WebViewFeature is a static facade over the device WebView; stub it so
39
+ // the tests are deterministic. Default: document-start scripts
40
+ // unsupported (so onPageStarted falls back to injecting the bridge).
41
+ mockkStatic(WebViewFeature::class)
42
+ every { WebViewFeature.isFeatureSupported(any()) } returns false
43
+ }
44
+
45
+ @After
46
+ fun tearDown() {
47
+ unmockkAll()
48
+ }
49
+
50
+ // region navigation pinning
51
+
52
+ private fun mainFrameRequestTo(target: String): WebResourceRequest =
53
+ mockk {
54
+ every { isForMainFrame } returns true
55
+ every { url } returns Uri.parse(target)
56
+ }
57
+
58
+ @Test
59
+ fun shouldOverrideUrlLoading_blocksCrossOriginMainFrameNavigation() {
60
+ every { host.expectedOrigin } returns "https://app.dynamicauth.com"
61
+
62
+ val handled = client.shouldOverrideUrlLoading(
63
+ null,
64
+ mainFrameRequestTo("https://evil.example.com/phish"),
65
+ )
66
+
67
+ // true == "we handled it" == the WebView does NOT perform the navigation.
68
+ assertTrue(handled)
69
+ }
70
+
71
+ @Test
72
+ fun shouldOverrideUrlLoading_allowsSameOriginMainFrameNavigation() {
73
+ every { host.expectedOrigin } returns "https://app.dynamicauth.com"
74
+
75
+ val handled = client.shouldOverrideUrlLoading(
76
+ null,
77
+ mainFrameRequestTo("https://app.dynamicauth.com/waas-v1/abc"),
78
+ )
79
+
80
+ assertFalse(handled)
81
+ }
82
+
83
+ @Test
84
+ fun shouldOverrideUrlLoading_ignoresSubframeNavigation() {
85
+ every { host.expectedOrigin } returns "https://app.dynamicauth.com"
86
+ val request = mockk<WebResourceRequest> {
87
+ every { isForMainFrame } returns false
88
+ every { url } returns Uri.parse("https://evil.example.com/x")
89
+ }
90
+
91
+ // Subframe loads are left to the WebView regardless of origin.
92
+ assertFalse(client.shouldOverrideUrlLoading(null, request))
93
+ }
94
+
95
+ @Test
96
+ fun shouldOverrideUrlLoading_allowsWhenOriginNotYetPinned() {
97
+ every { host.expectedOrigin } returns null
98
+
99
+ assertFalse(
100
+ client.shouldOverrideUrlLoading(
101
+ null,
102
+ mainFrameRequestTo("https://anything.example.com"),
103
+ ),
104
+ )
105
+ }
106
+
107
+ // endregion
108
+
109
+ // region bridge-glue injection
110
+
111
+ @Test
112
+ fun onPageStarted_injectsBridgeScriptWhenDocumentStartUnsupported() {
113
+ every {
114
+ WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)
115
+ } returns false
116
+ val view = mockk<WebView>(relaxed = true)
117
+
118
+ client.onPageStarted(view, "https://app.dynamicauth.com", null)
119
+
120
+ verify { view.evaluateJavascript(BRIDGE_USER_SCRIPT, null) }
121
+ }
122
+
123
+ @Test
124
+ fun onPageStarted_skipsInjectionWhenDocumentStartSupported() {
125
+ every {
126
+ WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)
127
+ } returns true
128
+ val view = mockk<WebView>(relaxed = true)
129
+
130
+ client.onPageStarted(view, "https://app.dynamicauth.com", null)
131
+
132
+ verify(exactly = 0) { view.evaluateJavascript(any(), any()) }
133
+ }
134
+
135
+ // endregion
136
+
137
+ // region error / lifecycle routing
138
+
139
+ @Test
140
+ fun onPageCommitVisible_notifiesHost() {
141
+ client.onPageCommitVisible(null, "https://app.dynamicauth.com")
142
+ verify { host.onCommitVisible() }
143
+ }
144
+
145
+ @Test
146
+ fun onReceivedError_mainFrame_reportsNavigationFailed() {
147
+ val request = mockk<WebResourceRequest> { every { isForMainFrame } returns true }
148
+ val error = mockk<WebResourceError> { every { description } returns "net::ERR_FAILED" }
149
+
150
+ client.onReceivedError(null, request, error)
151
+
152
+ verify { host.onNavigationFailed("net::ERR_FAILED") }
153
+ }
154
+
155
+ @Test
156
+ fun onReceivedError_subFrame_isIgnored() {
157
+ val request = mockk<WebResourceRequest> { every { isForMainFrame } returns false }
158
+ val error = mockk<WebResourceError>(relaxed = true)
159
+
160
+ client.onReceivedError(null, request, error)
161
+
162
+ verify(exactly = 0) { host.onNavigationFailed(any()) }
163
+ }
164
+
165
+ @Test
166
+ fun onReceivedHttpError_mainFrame4xx_reportsHttpError() {
167
+ val request = mockk<WebResourceRequest> { every { isForMainFrame } returns true }
168
+ val response = mockk<WebResourceResponse> { every { statusCode } returns 404 }
169
+
170
+ client.onReceivedHttpError(null, request, response)
171
+
172
+ verify { host.onHttpError(404) }
173
+ }
174
+
175
+ @Test
176
+ fun onReceivedHttpError_below400_isIgnored() {
177
+ val request = mockk<WebResourceRequest> { every { isForMainFrame } returns true }
178
+ val response = mockk<WebResourceResponse> { every { statusCode } returns 302 }
179
+
180
+ client.onReceivedHttpError(null, request, response)
181
+
182
+ verify(exactly = 0) { host.onHttpError(any()) }
183
+ }
184
+
185
+ @Test
186
+ fun onRenderProcessGone_notifiesHostAndHandlesCrash() {
187
+ val handled = client.onRenderProcessGone(null, null)
188
+
189
+ // Returning true tells the framework the crash is handled, preventing a
190
+ // host-process crash.
191
+ assertTrue(handled)
192
+ verify { host.onRenderProcessGone() }
193
+ }
194
+
195
+ // endregion
196
+ }