@dynamic-labs-sdk/client 1.7.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.
- package/android/build.gradle +231 -0
- package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/android/gradle/wrapper/gradle-wrapper.properties +7 -0
- package/android/gradle.properties +8 -0
- package/android/gradlew +251 -0
- package/android/gradlew.bat +94 -0
- package/android/settings.gradle +30 -0
- package/android/src/main/java/xyz/dynamic/client/DynamicClientPackage.kt +52 -0
- package/android/src/main/java/xyz/dynamic/client/keychain/KeyStoreKeyManager.kt +147 -0
- package/android/src/main/java/xyz/dynamic/client/keychain/KeychainModule.kt +85 -0
- package/android/src/main/java/xyz/dynamic/client/manifest/ReactNativeManifestModule.kt +25 -0
- package/android/src/main/java/xyz/dynamic/client/webview/DynamicWebViewClient.kt +83 -0
- package/android/src/main/java/xyz/dynamic/client/webview/DynamicWebViewHost.kt +518 -0
- package/android/src/main/java/xyz/dynamic/client/webview/DynamicWebViewModule.kt +147 -0
- package/android/src/test/java/xyz/dynamic/client/keychain/KeyStoreKeyManagerTest.kt +288 -0
- package/android/src/test/java/xyz/dynamic/client/webview/DynamicWebViewClientTest.kt +196 -0
- package/android/src/test/java/xyz/dynamic/client/webview/DynamicWebViewHostOpenTest.kt +347 -0
- package/android/src/test/java/xyz/dynamic/client/webview/DynamicWebViewHostTest.kt +171 -0
- package/android/src/test/java/xyz/dynamic/client/webview/DynamicWebViewModuleTest.kt +191 -0
- package/dist/{InvalidParamError-Crwntjl7.native.esm.js → InvalidParamError-Bc6OZBog.native.esm.js} +3 -3
- package/dist/{InvalidParamError-Crwntjl7.native.esm.js.map → InvalidParamError-Bc6OZBog.native.esm.js.map} +1 -1
- package/dist/{InvalidParamError-fQLJaGBW.esm.js → InvalidParamError-IHYLdzFd.esm.js} +437 -5
- package/dist/InvalidParamError-IHYLdzFd.esm.js.map +1 -0
- package/dist/{InvalidParamError-CTssOe86.cjs → InvalidParamError-S56rFFDP.cjs} +550 -4
- package/dist/InvalidParamError-S56rFFDP.cjs.map +1 -0
- package/dist/{NotWaasWalletAccountError-BJLGGYmU.native.esm.js → NotWaasWalletAccountError-BP-bODbJ.native.esm.js} +3 -3
- package/dist/{NotWaasWalletAccountError-BJLGGYmU.native.esm.js.map → NotWaasWalletAccountError-BP-bODbJ.native.esm.js.map} +1 -1
- package/dist/{NotWaasWalletAccountError-DF_S7gqo.esm.js → NotWaasWalletAccountError-BYEL2l-W.esm.js} +3 -3
- package/dist/{NotWaasWalletAccountError-DF_S7gqo.esm.js.map → NotWaasWalletAccountError-BYEL2l-W.esm.js.map} +1 -1
- package/dist/{NotWaasWalletAccountError-2RaDipZu.cjs → NotWaasWalletAccountError-CbhIKUDM.cjs} +3 -3
- package/dist/{NotWaasWalletAccountError-2RaDipZu.cjs.map → NotWaasWalletAccountError-CbhIKUDM.cjs.map} +1 -1
- package/dist/core.cjs +23 -23
- package/dist/core.cjs.map +1 -1
- package/dist/core.esm.js +5 -5
- package/dist/core.native.esm.js +7 -7
- package/dist/core.native.esm.js.map +1 -1
- package/dist/errors/WebViewLoadFailedError.d.ts +1 -1
- package/dist/errors/WebViewLoadFailedError.d.ts.map +1 -1
- package/dist/exports/index.d.ts +2 -0
- package/dist/exports/index.d.ts.map +1 -1
- package/dist/{getNetworkProviderFromNetworkId-C1p9Rrg3.cjs → getNetworkProviderFromNetworkId-BDdcpGHH.cjs} +11 -317
- package/dist/getNetworkProviderFromNetworkId-BDdcpGHH.cjs.map +1 -0
- package/dist/{getNetworkProviderFromNetworkId-BWhaD23J.esm.js → getNetworkProviderFromNetworkId-D8rWYXlT.esm.js} +4 -242
- package/dist/getNetworkProviderFromNetworkId-D8rWYXlT.esm.js.map +1 -0
- package/dist/{getNetworkProviderFromNetworkId-BCSmILlH.native.esm.js → getNetworkProviderFromNetworkId-DVmF7pH2.native.esm.js} +6 -6
- package/dist/getNetworkProviderFromNetworkId-DVmF7pH2.native.esm.js.map +1 -0
- package/dist/{getSignedSessionId-CxxYNC_8.cjs → getSignedSessionId-BBaBCi9S.cjs} +3 -3
- package/dist/{getSignedSessionId-CxxYNC_8.cjs.map → getSignedSessionId-BBaBCi9S.cjs.map} +1 -1
- package/dist/{getSignedSessionId-c7dS4PQ9.esm.js → getSignedSessionId-CND07Eca.esm.js} +3 -3
- package/dist/{getSignedSessionId-c7dS4PQ9.esm.js.map → getSignedSessionId-CND07Eca.esm.js.map} +1 -1
- package/dist/{getSignedSessionId-DPOXdh6y.native.esm.js → getSignedSessionId-RwV7JPn8.native.esm.js} +3 -3
- package/dist/{getSignedSessionId-DPOXdh6y.native.esm.js.map → getSignedSessionId-RwV7JPn8.native.esm.js.map} +1 -1
- package/dist/{getVerifiedCredentialForWalletAccount-Cahp763h.esm.js → getVerifiedCredentialForWalletAccount-BVrGV8qf.native.esm.js} +2 -2
- package/dist/{getVerifiedCredentialForWalletAccount-DuNbORNW.native.esm.js.map → getVerifiedCredentialForWalletAccount-BVrGV8qf.native.esm.js.map} +1 -1
- package/dist/getVerifiedCredentialForWalletAccount-BiOS65Tk.esm.js +269 -0
- package/dist/getVerifiedCredentialForWalletAccount-BiOS65Tk.esm.js.map +1 -0
- package/dist/getVerifiedCredentialForWalletAccount-FrxYWSuD.cjs +347 -0
- package/dist/getVerifiedCredentialForWalletAccount-FrxYWSuD.cjs.map +1 -0
- package/dist/index.cjs +67 -67
- package/dist/index.cjs.map +1 -1
- package/dist/index.esm.js +5 -5
- package/dist/index.esm.js.map +1 -1
- package/dist/index.native.esm.js +5 -5
- package/dist/index.native.esm.js.map +1 -1
- package/dist/isMfaRequiredForAction-BPL_dgIM.esm.js +318 -0
- package/dist/isMfaRequiredForAction-BPL_dgIM.esm.js.map +1 -0
- package/dist/isMfaRequiredForAction-BolTrtCO.cjs +405 -0
- package/dist/isMfaRequiredForAction-BolTrtCO.cjs.map +1 -0
- package/dist/{isMfaRequiredForAction-D8G5PBAd.native.esm.js → isMfaRequiredForAction-DQDoPsi6.native.esm.js} +2 -2
- package/dist/{isMfaRequiredForAction-D8G5PBAd.native.esm.js.map → isMfaRequiredForAction-DQDoPsi6.native.esm.js.map} +1 -1
- package/dist/modules/waas/createWaasClient/createWaasClient.d.ts.map +1 -1
- package/dist/modules/waas/createWaasClient/translateNativeOpenError.d.ts.map +1 -1
- package/dist/modules/wallets/walletProvider/events/onWalletProviderEvent/onWalletProviderEvent.d.ts +1 -2
- package/dist/modules/wallets/walletProvider/events/onWalletProviderEvent/onWalletProviderEvent.d.ts.map +1 -1
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/dist/waas.cjs +9 -9
- package/dist/waas.cjs.map +1 -1
- package/dist/waas.esm.js +3 -3
- package/dist/waas.native.esm.js +3 -3
- package/dist/waasCore.cjs +8 -5
- package/dist/waasCore.cjs.map +1 -1
- package/dist/waasCore.esm.js +8 -4
- package/dist/waasCore.esm.js.map +1 -1
- package/dist/waasCore.native.esm.js +8 -7
- package/dist/waasCore.native.esm.js.map +1 -1
- package/dynamic-labs-sdk-client.podspec +27 -0
- package/ios/DynamicClientKeychain.h +4 -0
- package/ios/DynamicClientKeychain.mm +101 -0
- package/ios/DynamicClientManifest.h +4 -0
- package/ios/DynamicClientManifest.mm +45 -0
- package/ios/DynamicClientWebView.h +5 -0
- package/ios/DynamicClientWebView.mm +143 -0
- package/ios/DynamicWebViewImpl.swift +603 -0
- package/ios/LiveSecKeyAPI.swift +86 -0
- package/ios/ReactNativeManifestImpl.swift +20 -0
- package/ios/SecKeyAPI.swift +57 -0
- package/ios/SecureEnclaveKeyManager.swift +191 -0
- package/package.json +14 -5
- package/react-native.config.cjs +14 -0
- package/src/turboModules/NativeDynamicWebView.native.spec.ts +61 -0
- package/src/turboModules/NativeDynamicWebView.ts +40 -0
- package/src/turboModules/NativeKeychain.native.spec.ts +47 -0
- package/src/turboModules/NativeKeychain.ts +19 -0
- package/src/turboModules/NativeReactNativeManifest.native.spec.ts +55 -0
- package/src/turboModules/NativeReactNativeManifest.ts +28 -0
- package/dist/InvalidParamError-CTssOe86.cjs.map +0 -1
- package/dist/InvalidParamError-fQLJaGBW.esm.js.map +0 -1
- package/dist/getNetworkProviderFromNetworkId-BCSmILlH.native.esm.js.map +0 -1
- package/dist/getNetworkProviderFromNetworkId-BWhaD23J.esm.js.map +0 -1
- package/dist/getNetworkProviderFromNetworkId-C1p9Rrg3.cjs.map +0 -1
- package/dist/getVerifiedCredentialForWalletAccount-Cahp763h.esm.js.map +0 -1
- package/dist/getVerifiedCredentialForWalletAccount-Di8dM5Wx.cjs +0 -887
- package/dist/getVerifiedCredentialForWalletAccount-Di8dM5Wx.cjs.map +0 -1
- package/dist/getVerifiedCredentialForWalletAccount-DuNbORNW.native.esm.js +0 -701
- package/dist/isMfaRequiredForAction-BjRvPrU6.esm.js +0 -79
- package/dist/isMfaRequiredForAction-BjRvPrU6.esm.js.map +0 -1
- package/dist/isMfaRequiredForAction-hlhj0qAC.cjs +0 -96
- package/dist/isMfaRequiredForAction-hlhj0qAC.cjs.map +0 -1
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
package xyz.dynamic.client.webview
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.net.Uri
|
|
5
|
+
import android.webkit.WebSettings
|
|
6
|
+
import android.webkit.WebView
|
|
7
|
+
import androidx.webkit.JavaScriptReplyProxy
|
|
8
|
+
import androidx.webkit.ProfileStore
|
|
9
|
+
import androidx.webkit.WebMessageCompat
|
|
10
|
+
import androidx.webkit.WebViewCompat
|
|
11
|
+
import androidx.webkit.WebViewFeature
|
|
12
|
+
import com.facebook.react.bridge.Arguments
|
|
13
|
+
import com.facebook.react.bridge.JavaOnlyMap
|
|
14
|
+
import com.facebook.react.bridge.Promise
|
|
15
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
16
|
+
import com.facebook.react.bridge.WritableMap
|
|
17
|
+
import io.mockk.CapturingSlot
|
|
18
|
+
import io.mockk.Runs
|
|
19
|
+
import io.mockk.every
|
|
20
|
+
import io.mockk.just
|
|
21
|
+
import io.mockk.mockk
|
|
22
|
+
import io.mockk.mockkStatic
|
|
23
|
+
import io.mockk.slot
|
|
24
|
+
import io.mockk.unmockkAll
|
|
25
|
+
import io.mockk.verify
|
|
26
|
+
import org.junit.After
|
|
27
|
+
import org.junit.Assert.assertEquals
|
|
28
|
+
import org.junit.Assert.assertFalse
|
|
29
|
+
import org.junit.Assert.assertTrue
|
|
30
|
+
import org.junit.Before
|
|
31
|
+
import org.junit.Test
|
|
32
|
+
import org.junit.runner.RunWith
|
|
33
|
+
import org.robolectric.Robolectric
|
|
34
|
+
import org.robolectric.RobolectricTestRunner
|
|
35
|
+
import org.robolectric.Shadows.shadowOf
|
|
36
|
+
import org.robolectric.annotation.Config
|
|
37
|
+
|
|
38
|
+
// Exercises the open() happy path that constructs a real (Robolectric-shadowed)
|
|
39
|
+
// WebView. The androidx.webkit statics (WebViewCompat / WebViewFeature /
|
|
40
|
+
// ProfileStore) are mocked because they delegate to the device's System WebView
|
|
41
|
+
// provider, which does not exist off-device — so the tests assert that the host
|
|
42
|
+
// CALLS them correctly (origin-scoped bridge, document-start glue, per-instance
|
|
43
|
+
// profile) and applies the secure WebSettings, rather than exercising the
|
|
44
|
+
// provider itself. WEB_MESSAGE_LISTENER is reported supported so open() proceeds.
|
|
45
|
+
@RunWith(RobolectricTestRunner::class)
|
|
46
|
+
@Config(sdk = [35])
|
|
47
|
+
class DynamicWebViewHostOpenTest {
|
|
48
|
+
|
|
49
|
+
private lateinit var activity: Activity
|
|
50
|
+
private lateinit var reactContext: ReactApplicationContext
|
|
51
|
+
|
|
52
|
+
private lateinit var webSlot: CapturingSlot<WebView>
|
|
53
|
+
private lateinit var originRulesSlot: CapturingSlot<Set<String>>
|
|
54
|
+
private lateinit var listenerSlot: CapturingSlot<WebViewCompat.WebMessageListener>
|
|
55
|
+
|
|
56
|
+
@Before
|
|
57
|
+
fun setUp() {
|
|
58
|
+
activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
|
59
|
+
reactContext = mockk(relaxed = true)
|
|
60
|
+
every { reactContext.currentActivity } returns activity
|
|
61
|
+
// Run UI-queue work inline so onBridgeMessage delivery is synchronous.
|
|
62
|
+
// (The http-error-race tests re-stub this to capture instead.)
|
|
63
|
+
every { reactContext.runOnUiQueueThread(any()) } answers { firstArg<Runnable>().run() }
|
|
64
|
+
|
|
65
|
+
mockkStatic(Arguments::class)
|
|
66
|
+
every { Arguments.createMap() } answers { JavaOnlyMap() }
|
|
67
|
+
|
|
68
|
+
mockkStatic(WebViewFeature::class)
|
|
69
|
+
// Default: only the (required) bridge feature is available. Document-start
|
|
70
|
+
// scripts and multi-profile default off; individual tests opt in.
|
|
71
|
+
every { WebViewFeature.isFeatureSupported(any()) } returns false
|
|
72
|
+
every {
|
|
73
|
+
WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)
|
|
74
|
+
} returns true
|
|
75
|
+
|
|
76
|
+
mockkStatic(WebViewCompat::class)
|
|
77
|
+
webSlot = slot()
|
|
78
|
+
originRulesSlot = slot()
|
|
79
|
+
listenerSlot = slot()
|
|
80
|
+
every {
|
|
81
|
+
WebViewCompat.addWebMessageListener(
|
|
82
|
+
capture(webSlot),
|
|
83
|
+
any(),
|
|
84
|
+
capture(originRulesSlot),
|
|
85
|
+
capture(listenerSlot),
|
|
86
|
+
)
|
|
87
|
+
} just Runs
|
|
88
|
+
every {
|
|
89
|
+
WebViewCompat.addDocumentStartJavaScript(any(), any(), any())
|
|
90
|
+
} returns mockk(relaxed = true)
|
|
91
|
+
every { WebViewCompat.setProfile(any(), any()) } just Runs
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@After
|
|
95
|
+
fun tearDown() {
|
|
96
|
+
unmockkAll()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private fun host(
|
|
100
|
+
instanceId: String = "instance-1",
|
|
101
|
+
onMessage: (Any?) -> Unit = {},
|
|
102
|
+
onRelease: () -> Unit = {},
|
|
103
|
+
) = DynamicWebViewHost(reactContext, instanceId, onMessage, onRelease)
|
|
104
|
+
|
|
105
|
+
private fun openHost(
|
|
106
|
+
url: String = "https://app.dynamicauth.com/waas-v1/env",
|
|
107
|
+
instanceId: String = "instance-1",
|
|
108
|
+
onMessage: (Any?) -> Unit = {},
|
|
109
|
+
): Pair<DynamicWebViewHost, Promise> {
|
|
110
|
+
val subject = host(instanceId = instanceId, onMessage = onMessage)
|
|
111
|
+
val promise = mockk<Promise>(relaxed = true)
|
|
112
|
+
subject.open(url, promise)
|
|
113
|
+
return subject to promise
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// region secure WebSettings
|
|
117
|
+
|
|
118
|
+
@Test
|
|
119
|
+
fun open_appliesSecureWebSettings() {
|
|
120
|
+
openHost()
|
|
121
|
+
val s = webSlot.captured.settings
|
|
122
|
+
|
|
123
|
+
assertTrue(s.javaScriptEnabled)
|
|
124
|
+
assertTrue(s.domStorageEnabled)
|
|
125
|
+
assertFalse(s.allowFileAccess)
|
|
126
|
+
assertFalse(s.allowContentAccess)
|
|
127
|
+
@Suppress("DEPRECATION")
|
|
128
|
+
assertFalse(s.allowFileAccessFromFileURLs)
|
|
129
|
+
@Suppress("DEPRECATION")
|
|
130
|
+
assertFalse(s.allowUniversalAccessFromFileURLs)
|
|
131
|
+
assertEquals(WebSettings.MIXED_CONTENT_NEVER_ALLOW, s.mixedContentMode)
|
|
132
|
+
assertFalse(s.javaScriptCanOpenWindowsAutomatically)
|
|
133
|
+
// Multi-window is enabled so onCreateWindow fires (CVE-2020-6506
|
|
134
|
+
// mitigation: contains iframe window requests instead of letting them
|
|
135
|
+
// navigate the top frame).
|
|
136
|
+
assertTrue(s.supportMultipleWindows())
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// onCreateWindow must deny every window request (popups / target=_blank /
|
|
140
|
+
// window.open), so the enabled multi-window support never actually opens one.
|
|
141
|
+
@Test
|
|
142
|
+
fun open_deniesWindowCreation() {
|
|
143
|
+
openHost()
|
|
144
|
+
val chromeClient = shadowOf(webSlot.captured).webChromeClient
|
|
145
|
+
assertFalse(chromeClient.onCreateWindow(webSlot.captured, false, false, null))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// endregion
|
|
149
|
+
|
|
150
|
+
// region origin-scoped bridge + navigation target
|
|
151
|
+
|
|
152
|
+
@Test
|
|
153
|
+
fun open_registersWebMessageListenerScopedToNormalizedOrigin() {
|
|
154
|
+
openHost(url = "https://app.dynamicauth.com:443/waas-v1/env")
|
|
155
|
+
// Default https port normalized away → matches the page's origin.
|
|
156
|
+
assertEquals(setOf("https://app.dynamicauth.com"), originRulesSlot.captured)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
@Test
|
|
160
|
+
fun open_loadsTheValidatedUrl() {
|
|
161
|
+
openHost(url = "https://app.dynamicauth.com/waas-v1/xyz")
|
|
162
|
+
assertEquals(
|
|
163
|
+
"https://app.dynamicauth.com/waas-v1/xyz",
|
|
164
|
+
shadowOf(webSlot.captured).lastLoadedUrl,
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
@Test
|
|
169
|
+
fun open_injectsDocumentStartScriptScopedToOriginWhenSupported() {
|
|
170
|
+
every {
|
|
171
|
+
WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)
|
|
172
|
+
} returns true
|
|
173
|
+
|
|
174
|
+
openHost(url = "https://app.dynamicauth.com/x")
|
|
175
|
+
|
|
176
|
+
verify {
|
|
177
|
+
WebViewCompat.addDocumentStartJavaScript(
|
|
178
|
+
any(),
|
|
179
|
+
BRIDGE_USER_SCRIPT,
|
|
180
|
+
setOf("https://app.dynamicauth.com"),
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
@Test
|
|
186
|
+
fun open_skipsDocumentStartScriptWhenUnsupported() {
|
|
187
|
+
openHost()
|
|
188
|
+
verify(exactly = 0) {
|
|
189
|
+
WebViewCompat.addDocumentStartJavaScript(any(), any(), any())
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// endregion
|
|
194
|
+
|
|
195
|
+
// region per-instance profile (storage isolation)
|
|
196
|
+
|
|
197
|
+
@Test
|
|
198
|
+
fun open_assignsPerInstanceProfileWhenMultiProfileSupported() {
|
|
199
|
+
every {
|
|
200
|
+
WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)
|
|
201
|
+
} returns true
|
|
202
|
+
val store = mockk<ProfileStore>(relaxed = true)
|
|
203
|
+
mockkStatic(ProfileStore::class)
|
|
204
|
+
every { ProfileStore.getInstance() } returns store
|
|
205
|
+
|
|
206
|
+
openHost(instanceId = "abc")
|
|
207
|
+
|
|
208
|
+
verify { store.getOrCreateProfile("dynamic-waas-abc") }
|
|
209
|
+
verify { WebViewCompat.setProfile(any(), "dynamic-waas-abc") }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
@Test
|
|
213
|
+
fun open_skipsProfileWhenMultiProfileUnsupported() {
|
|
214
|
+
openHost()
|
|
215
|
+
verify(exactly = 0) { WebViewCompat.setProfile(any(), any()) }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// endregion
|
|
219
|
+
|
|
220
|
+
// region http-error vs commit race (#8)
|
|
221
|
+
|
|
222
|
+
@Test
|
|
223
|
+
fun httpErrorClaimsPromiseEvenWhenCommitFiresFirst() {
|
|
224
|
+
val deferred = slot<Runnable>()
|
|
225
|
+
// Capture the deferred resolve instead of running it inline.
|
|
226
|
+
every { reactContext.runOnUiQueueThread(capture(deferred)) } just Runs
|
|
227
|
+
val (subject, promise) = openHost()
|
|
228
|
+
|
|
229
|
+
subject.onCommitVisible() // schedules the deferred resolve
|
|
230
|
+
subject.onHttpError(503) // synchronously claims the pending promise
|
|
231
|
+
deferred.captured.run() // deferred resolve runs last — but promise is gone
|
|
232
|
+
|
|
233
|
+
verify { promise.reject("webview_http_error", "HTTP 503", any<WritableMap>()) }
|
|
234
|
+
verify(exactly = 0) { promise.resolve(any()) }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
@Test
|
|
238
|
+
fun httpErrorBeforeCommit_reportsHttpError() {
|
|
239
|
+
val deferred = slot<Runnable>()
|
|
240
|
+
every { reactContext.runOnUiQueueThread(capture(deferred)) } just Runs
|
|
241
|
+
val (subject, promise) = openHost()
|
|
242
|
+
|
|
243
|
+
subject.onHttpError(404)
|
|
244
|
+
subject.onCommitVisible()
|
|
245
|
+
if (deferred.isCaptured) deferred.captured.run()
|
|
246
|
+
|
|
247
|
+
verify { promise.reject("webview_http_error", "HTTP 404", any<WritableMap>()) }
|
|
248
|
+
verify(exactly = 0) { promise.resolve(any()) }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
@Test
|
|
252
|
+
fun commitVisibleResolvesWhenNoHttpError() {
|
|
253
|
+
val (subject, promise) = openHost()
|
|
254
|
+
subject.onCommitVisible()
|
|
255
|
+
verify { promise.resolve(null) }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// endregion
|
|
259
|
+
|
|
260
|
+
// region sendMessage via reply proxy (#9)
|
|
261
|
+
|
|
262
|
+
@Test
|
|
263
|
+
fun sendMessage_postsJsonViaReplyProxyVerbatimIncludingLineSeparators() {
|
|
264
|
+
val (subject, _) = openHost()
|
|
265
|
+
|
|
266
|
+
// The page opens the reply channel by posting first; invoke the captured
|
|
267
|
+
// listener to deliver a replyProxy to the host.
|
|
268
|
+
val proxy = mockk<JavaScriptReplyProxy>(relaxed = true)
|
|
269
|
+
val handshake = mockk<WebMessageCompat> { every { data } returns "{\"type\":\"ready\"}" }
|
|
270
|
+
listenerSlot.captured.onPostMessage(
|
|
271
|
+
webSlot.captured,
|
|
272
|
+
handshake,
|
|
273
|
+
Uri.parse("https://app.dynamicauth.com"),
|
|
274
|
+
true,
|
|
275
|
+
proxy,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
// A dApp-controlled string containing U+2028 must round-trip verbatim —
|
|
279
|
+
// the old evaluateJavascript('...') path missed U+2028/U+2029 and would
|
|
280
|
+
// have broken out of the JS string literal.
|
|
281
|
+
val payload = JavaOnlyMap().apply {
|
|
282
|
+
putString("type", "signMessage")
|
|
283
|
+
putString("message", "approve\u2028tx")
|
|
284
|
+
}
|
|
285
|
+
subject.sendMessage(payload)
|
|
286
|
+
|
|
287
|
+
val sent = slot<String>()
|
|
288
|
+
verify { proxy.postMessage(capture(sent)) }
|
|
289
|
+
assertTrue(sent.captured.contains("\u2028"))
|
|
290
|
+
assertTrue(sent.captured.contains("signMessage"))
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
@Test
|
|
294
|
+
fun sendMessage_buffersBeforeReplyChannelThenFlushesInOrder() {
|
|
295
|
+
val (subject, _) = openHost()
|
|
296
|
+
|
|
297
|
+
// Host sends before the page has posted (no replyProxy yet): both must
|
|
298
|
+
// be buffered, not dropped.
|
|
299
|
+
subject.sendMessage(JavaOnlyMap().apply { putString("seq", "first") })
|
|
300
|
+
subject.sendMessage(JavaOnlyMap().apply { putString("seq", "second") })
|
|
301
|
+
|
|
302
|
+
// Page opens the reply channel → buffered messages flush in order.
|
|
303
|
+
val proxy = mockk<JavaScriptReplyProxy>(relaxed = true)
|
|
304
|
+
val handshake = mockk<WebMessageCompat> { every { data } returns "{\"type\":\"ready\"}" }
|
|
305
|
+
listenerSlot.captured.onPostMessage(
|
|
306
|
+
webSlot.captured,
|
|
307
|
+
handshake,
|
|
308
|
+
Uri.parse("https://app.dynamicauth.com"),
|
|
309
|
+
true,
|
|
310
|
+
proxy,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
// The capturing list accumulates invocations in call order.
|
|
314
|
+
val sent = mutableListOf<String>()
|
|
315
|
+
verify(exactly = 2) { proxy.postMessage(capture(sent)) }
|
|
316
|
+
assertEquals(2, sent.size)
|
|
317
|
+
assertTrue(sent[0].contains("first"))
|
|
318
|
+
assertTrue(sent[1].contains("second"))
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
@Test
|
|
322
|
+
fun sendMessage_doesNotCrashWhenReplyChannelNeverOpens() {
|
|
323
|
+
val (subject, _) = openHost()
|
|
324
|
+
// No listener callback ever → message stays buffered, no crash.
|
|
325
|
+
subject.sendMessage(JavaOnlyMap().apply { putString("type", "early") })
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
@Test
|
|
329
|
+
fun bridgeListener_dropsNonMainFrameMessages() {
|
|
330
|
+
var delivered: Any? = "UNSET"
|
|
331
|
+
val (subject, _) = openHost(onMessage = { delivered = it })
|
|
332
|
+
val proxy = mockk<JavaScriptReplyProxy>(relaxed = true)
|
|
333
|
+
val message = mockk<WebMessageCompat> { every { data } returns "{\"type\":\"x\"}" }
|
|
334
|
+
|
|
335
|
+
listenerSlot.captured.onPostMessage(
|
|
336
|
+
webSlot.captured,
|
|
337
|
+
message,
|
|
338
|
+
Uri.parse("https://app.dynamicauth.com"),
|
|
339
|
+
false, // not main frame
|
|
340
|
+
proxy,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
assertEquals("UNSET", delivered)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// endregion
|
|
347
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
package xyz.dynamic.client.webview
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import androidx.webkit.WebViewFeature
|
|
5
|
+
import com.facebook.react.bridge.Arguments
|
|
6
|
+
import com.facebook.react.bridge.JavaOnlyArray
|
|
7
|
+
import com.facebook.react.bridge.JavaOnlyMap
|
|
8
|
+
import com.facebook.react.bridge.Promise
|
|
9
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
10
|
+
import com.facebook.react.bridge.ReadableMap
|
|
11
|
+
import com.facebook.react.bridge.WritableMap
|
|
12
|
+
import io.mockk.every
|
|
13
|
+
import io.mockk.mockk
|
|
14
|
+
import io.mockk.mockkStatic
|
|
15
|
+
import io.mockk.unmockkAll
|
|
16
|
+
import io.mockk.verify
|
|
17
|
+
import org.junit.After
|
|
18
|
+
import org.junit.Assert.assertEquals
|
|
19
|
+
import org.junit.Assert.assertTrue
|
|
20
|
+
import org.junit.Before
|
|
21
|
+
import org.junit.Test
|
|
22
|
+
import org.junit.runner.RunWith
|
|
23
|
+
import org.robolectric.RobolectricTestRunner
|
|
24
|
+
import org.robolectric.annotation.Config
|
|
25
|
+
|
|
26
|
+
// Runs under Robolectric for the android.* types. Arguments.createMap()/
|
|
27
|
+
// createArray() are JNI-backed (WritableNativeMap) and can't run off-device, so
|
|
28
|
+
// they are stubbed to return the pure-Java JavaOnlyMap/JavaOnlyArray — the
|
|
29
|
+
// canonical RN unit-test stand-ins for ReadableMap/WritableMap.
|
|
30
|
+
@RunWith(RobolectricTestRunner::class)
|
|
31
|
+
@Config(sdk = [35])
|
|
32
|
+
class DynamicWebViewHostTest {
|
|
33
|
+
|
|
34
|
+
private lateinit var reactContext: ReactApplicationContext
|
|
35
|
+
|
|
36
|
+
@Before
|
|
37
|
+
fun setUp() {
|
|
38
|
+
mockkStatic(Arguments::class)
|
|
39
|
+
every { Arguments.createMap() } answers { JavaOnlyMap() }
|
|
40
|
+
every { Arguments.createArray() } answers { JavaOnlyArray() }
|
|
41
|
+
reactContext = mockk(relaxed = true)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@After
|
|
45
|
+
fun tearDown() {
|
|
46
|
+
unmockkAll()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private fun host(onMessage: (Any?) -> Unit = {}, onRelease: () -> Unit = {}) =
|
|
50
|
+
DynamicWebViewHost(reactContext, "instance-1", onMessage, onRelease)
|
|
51
|
+
|
|
52
|
+
@Test
|
|
53
|
+
fun open_withNoForegroundActivity_rejectsAndReleases() {
|
|
54
|
+
every { reactContext.currentActivity } returns null
|
|
55
|
+
var released = false
|
|
56
|
+
val subject = host(onRelease = { released = true })
|
|
57
|
+
val promise = mockk<Promise>(relaxed = true)
|
|
58
|
+
|
|
59
|
+
subject.open("https://app.dynamic.xyz", promise)
|
|
60
|
+
|
|
61
|
+
verify {
|
|
62
|
+
promise.reject(
|
|
63
|
+
"webview_no_host_view",
|
|
64
|
+
"No foreground activity available to attach WebView.",
|
|
65
|
+
any<WritableMap>(),
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
assertTrue(released)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@Test
|
|
72
|
+
fun onBridgeMessage_passesNonJsonPayloadThroughUnchanged() {
|
|
73
|
+
every { reactContext.runOnUiQueueThread(any()) } answers {
|
|
74
|
+
firstArg<Runnable>().run()
|
|
75
|
+
}
|
|
76
|
+
var received: Any? = "UNSET"
|
|
77
|
+
val subject = host(onMessage = { received = it })
|
|
78
|
+
|
|
79
|
+
subject.onBridgeMessage("plain-text")
|
|
80
|
+
|
|
81
|
+
assertEquals("plain-text", received)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@Test
|
|
85
|
+
fun onBridgeMessage_marshalsJsonObjectIntoReadableMap() {
|
|
86
|
+
every { reactContext.runOnUiQueueThread(any()) } answers {
|
|
87
|
+
firstArg<Runnable>().run()
|
|
88
|
+
}
|
|
89
|
+
var received: Any? = null
|
|
90
|
+
val subject = host(onMessage = { received = it })
|
|
91
|
+
|
|
92
|
+
subject.onBridgeMessage("""{"foo":"bar","n":7,"flag":true}""")
|
|
93
|
+
|
|
94
|
+
val map = received as ReadableMap
|
|
95
|
+
assertEquals("bar", map.getString("foo"))
|
|
96
|
+
assertEquals(7, map.getInt("n"))
|
|
97
|
+
assertTrue(map.getBoolean("flag"))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// region URL validation (https + host pin)
|
|
101
|
+
|
|
102
|
+
// A non-https / hostless URL is rejected before any WebView is constructed,
|
|
103
|
+
// so a relaxed Activity stub (never actually touched on the reject path) is
|
|
104
|
+
// enough to get past the foreground-activity guard.
|
|
105
|
+
private fun assertRejectsInvalidUrl(url: String) {
|
|
106
|
+
every { reactContext.currentActivity } returns mockk<Activity>(relaxed = true)
|
|
107
|
+
val promise = mockk<Promise>(relaxed = true)
|
|
108
|
+
|
|
109
|
+
host().open(url, promise)
|
|
110
|
+
|
|
111
|
+
verify {
|
|
112
|
+
promise.reject("webview_invalid_url", "Invalid URL: $url", any<WritableMap>())
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@Test
|
|
117
|
+
fun open_rejectsHttpScheme() = assertRejectsInvalidUrl("http://app.dynamicauth.com")
|
|
118
|
+
|
|
119
|
+
@Test
|
|
120
|
+
fun open_rejectsFileScheme() = assertRejectsInvalidUrl("file:///etc/passwd")
|
|
121
|
+
|
|
122
|
+
@Test
|
|
123
|
+
fun open_rejectsJavascriptScheme() = assertRejectsInvalidUrl("javascript:alert(1)")
|
|
124
|
+
|
|
125
|
+
@Test
|
|
126
|
+
fun open_rejectsContentScheme() = assertRejectsInvalidUrl("content://com.evil/secret")
|
|
127
|
+
|
|
128
|
+
@Test
|
|
129
|
+
fun open_rejectsAboutScheme() = assertRejectsInvalidUrl("about:blank")
|
|
130
|
+
|
|
131
|
+
@Test
|
|
132
|
+
fun open_rejectsHttpsWithoutHost() = assertRejectsInvalidUrl("https://")
|
|
133
|
+
|
|
134
|
+
// endregion
|
|
135
|
+
|
|
136
|
+
// region origin pin + fail-closed
|
|
137
|
+
//
|
|
138
|
+
// Both reject before any WebView is constructed (the WEB_MESSAGE_LISTENER
|
|
139
|
+
// check sits between URL validation and WebView creation), so they exercise
|
|
140
|
+
// originOf() normalization + expectedOrigin assignment without a real WebView.
|
|
141
|
+
|
|
142
|
+
@Test
|
|
143
|
+
fun open_pinsExpectedOriginAndFailsClosedWhenWebMessageListenerUnsupported() {
|
|
144
|
+
mockkStatic(WebViewFeature::class)
|
|
145
|
+
every { WebViewFeature.isFeatureSupported(any()) } returns false
|
|
146
|
+
every { reactContext.currentActivity } returns mockk<Activity>(relaxed = true)
|
|
147
|
+
val subject = host()
|
|
148
|
+
val promise = mockk<Promise>(relaxed = true)
|
|
149
|
+
|
|
150
|
+
// The explicit default https port (:443) must normalize away so the
|
|
151
|
+
// pinned origin matches the page's normalized origin / allowedOriginRules.
|
|
152
|
+
subject.open("https://app.dynamicauth.com:443/waas-v1/env", promise)
|
|
153
|
+
|
|
154
|
+
assertEquals("https://app.dynamicauth.com", subject.expectedOrigin)
|
|
155
|
+
verify { promise.reject("webview_unsupported", any<String>(), any<WritableMap>()) }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
@Test
|
|
159
|
+
fun open_pinsExpectedOriginKeepingNonDefaultPort() {
|
|
160
|
+
mockkStatic(WebViewFeature::class)
|
|
161
|
+
every { WebViewFeature.isFeatureSupported(any()) } returns false
|
|
162
|
+
every { reactContext.currentActivity } returns mockk<Activity>(relaxed = true)
|
|
163
|
+
val subject = host()
|
|
164
|
+
|
|
165
|
+
subject.open("https://localhost:8443/waas", mockk(relaxed = true))
|
|
166
|
+
|
|
167
|
+
assertEquals("https://localhost:8443", subject.expectedOrigin)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// endregion
|
|
171
|
+
}
|