catalyst-core-internal 0.1.2 → 0.1.4

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 (53) hide show
  1. package/README.md +4 -4
  2. package/bin/catalyst.js +8 -1
  3. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/BridgeMessageValidator.kt +3 -11
  4. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +12 -1
  5. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +18 -3
  6. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/CatalystPlugin.kt +7 -0
  7. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/GeneratedPluginIndex.kt +6 -0
  8. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/PluginBridge.kt +253 -0
  9. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/SecurityBridgeTest.kt +199 -0
  10. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/plugins/PluginBridgeTest.kt +139 -0
  11. package/dist/native/bridge/hooks.js +4 -4
  12. package/dist/native/bridge/useBaseHook.js +5 -4
  13. package/dist/native/bridge/utils/NativeBridge.js +4 -4
  14. package/dist/native/buildAppAndroid.js +2 -2
  15. package/dist/native/buildAppIos.js +10 -17
  16. package/dist/native/internal-plugins/device-info-plugin/android/DeviceInfoPlugin.kt +43 -0
  17. package/dist/native/internal-plugins/device-info-plugin/ios/DeviceInfoPlugin.swift +28 -0
  18. package/dist/native/internal-plugins/device-info-plugin/manifest.json +19 -0
  19. package/dist/native/internalPluginUtils.js +1 -0
  20. package/dist/native/iosnativeWebView/Sources/Core/Plugins/CatalystPlugin.swift +5 -0
  21. package/dist/native/iosnativeWebView/Sources/Core/Plugins/GeneratedPluginIndex.swift +6 -0
  22. package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +364 -0
  23. package/dist/native/iosnativeWebView/Sources/Core/Utils/CacheManager.swift +13 -2
  24. package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +13 -2
  25. package/dist/native/iosnativeWebView/Sources/Core/WebView/WeakScriptMessageHandler.swift +14 -0
  26. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +6 -0
  27. package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +4 -0
  28. package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +36 -0
  29. package/dist/native/iosnativeWebView/iosnativeWebView.xctestplan +1 -0
  30. package/dist/native/iosnativeWebView/iosnativeWebViewTests/BridgeCommandHandlerSecurityTests.swift +212 -0
  31. package/dist/native/iosnativeWebView/iosnativeWebViewTests/FrameworkServerUtilsTests.swift +14 -4
  32. package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +160 -0
  33. package/dist/native/iosnativeWebView/iosnativeWebViewTests/ScreenSecureManagerTests.swift +121 -0
  34. package/dist/native/iosnativeWebView/iosnativeWebViewTests/WebViewTests.swift +9 -21
  35. package/dist/native/plugin-bridge/PluginBridge.js +1 -0
  36. package/dist/native/pluginComposerAndroid.js +9 -0
  37. package/dist/native/pluginComposerIos.js +7 -0
  38. package/dist/scripts/plugins.js +1 -0
  39. package/package.json +3 -2
  40. package/mcp_v2/conversion-tasks.json +0 -371
  41. package/mcp_v2/knowledge-base.json +0 -1450
  42. package/mcp_v2/lib/helpers.js +0 -145
  43. package/mcp_v2/mcp.js +0 -366
  44. package/mcp_v2/package.json +0 -13
  45. package/mcp_v2/schema.sql +0 -88
  46. package/mcp_v2/setup.js +0 -262
  47. package/mcp_v2/tools/build.js +0 -449
  48. package/mcp_v2/tools/config.js +0 -262
  49. package/mcp_v2/tools/conversion.js +0 -492
  50. package/mcp_v2/tools/debug.js +0 -62
  51. package/mcp_v2/tools/knowledge.js +0 -213
  52. package/mcp_v2/tools/sync.js +0 -21
  53. package/mcp_v2/tools/tasks.js +0 -844
package/README.md CHANGED
@@ -7,8 +7,8 @@
7
7
 
8
8
  ## Table of Contents
9
9
 
10
- - Overview
11
- - Installation
10
+ - Overview
11
+ - Installation
12
12
 
13
13
  ## Overview
14
14
 
@@ -20,8 +20,8 @@ This version adds support for Universal app for both Android and iOS. Please che
20
20
 
21
21
  **System Requirements**
22
22
 
23
- - Node version 20.4.0 or later
24
- - Compatible with **macOS** and **Linux**
23
+ - Node version 20.4.0 or later
24
+ - Compatible with **macOS** and **Linux**
25
25
 
26
26
  **Automatic Installation**
27
27
 
package/bin/catalyst.js CHANGED
@@ -13,6 +13,8 @@ const validCommands = [
13
13
  "serve",
14
14
  "devBuild",
15
15
  "devServe",
16
+ "plugin",
17
+ "plugins",
16
18
  "buildApp",
17
19
  "buildApp:ios",
18
20
  "buildApp:android",
@@ -67,11 +69,14 @@ const scriptIndex = args.findIndex(
67
69
  x === "serve" ||
68
70
  x === "devBuild" ||
69
71
  x === "devServe" ||
72
+ x === "plugin" ||
73
+ x === "plugins" ||
70
74
  isPlatformCommand(x, "buildApp") ||
71
75
  isPlatformCommand(x, "setupEmulator")
72
76
  )
73
77
  const script = scriptIndex === -1 ? args[0] : args[scriptIndex]
74
78
  const nodeArgs = scriptIndex > 0 ? args.slice(0, scriptIndex) : []
79
+ const resolvedScript = script === "plugin" ? "plugins" : script
75
80
 
76
81
  if (validCommands.includes(script)) {
77
82
  // Handle platform-specific or combined commands
@@ -90,7 +95,9 @@ if (validCommands.includes(script)) {
90
95
  // Original commands
91
96
  const result = spawnSync(
92
97
  process.execPath,
93
- nodeArgs.concat(require.resolve("../dist/scripts/" + script)).concat(args.slice(scriptIndex + 1)),
98
+ nodeArgs
99
+ .concat(require.resolve("../dist/scripts/" + resolvedScript))
100
+ .concat(args.slice(scriptIndex + 1)),
94
101
  { stdio: "inherit" }
95
102
  )
96
103
  handleProcessResult(result)
@@ -60,18 +60,10 @@ data class SchemaDefinition(
60
60
  object BridgeMessageValidator {
61
61
 
62
62
  private const val TAG = "BridgeMessageValidator"
63
- private const val MAX_MESSAGE_SIZE = 10 * 1024 * 1024 // 10MB (match iOS)
63
+ private const val MAX_MESSAGE_SIZE = CatalystConstants.Bridge.MAX_MESSAGE_SIZE
64
64
 
65
- // Valid commands (should match iOS CatalystConstants.Bridge.validCommands)
66
- private val validCommands = setOf(
67
- "openCamera",
68
- "requestCameraPermission",
69
- "pickFile",
70
- "requestHapticFeedback",
71
- "openFileWithIntent",
72
- "getDeviceInfo",
73
- "logger"
74
- )
65
+ // Valid commands single source of truth from CatalystConstants
66
+ private val validCommands = CatalystConstants.Bridge.VALID_COMMANDS
75
67
 
76
68
  // MARK: - Schema Definitions (mirror iOS schemas)
77
69
 
@@ -233,7 +233,7 @@ class CustomWebView(
233
233
  }
234
234
 
235
235
  // Additional security check: only allow whitelisted interface names
236
- val allowedInterfaces = setOf("NativeBridge", "AndroidBridge")
236
+ val allowedInterfaces = setOf("NativeBridge", "AndroidBridge", "PluginBridge")
237
237
  if (name !in allowedInterfaces) {
238
238
  Log.e(TAG, "❌ Security: Interface name '$name' is not in whitelist. Refusing to add interface.")
239
239
  return
@@ -246,6 +246,17 @@ class CustomWebView(
246
246
  }
247
247
  }
248
248
 
249
+ fun removeJavascriptInterface(name: String) {
250
+ try {
251
+ webView.removeJavascriptInterface(name)
252
+ if (BuildConfig.DEBUG) {
253
+ Log.d(TAG, "🔌 Removed JavaScript interface: $name")
254
+ }
255
+ } catch (e: Exception) {
256
+ Log.w(TAG, "⚠️ Failed to remove JavaScript interface '$name': ${e.message}")
257
+ }
258
+ }
259
+
249
260
  fun destroy() {
250
261
  job.cancel()
251
262
  webView.destroy()
@@ -14,6 +14,7 @@ import org.json.JSONObject
14
14
  import java.util.Properties
15
15
  import io.yourname.androidproject.databinding.ActivityMainBinding
16
16
  import io.yourname.androidproject.NativeBridge
17
+ import io.yourname.androidproject.plugins.PluginBridge
17
18
  import io.yourname.androidproject.utils.BridgeUtils
18
19
  import io.yourname.androidproject.utils.KeyboardUtil
19
20
  import io.yourname.androidproject.utils.NetworkUtils
@@ -39,6 +40,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
39
40
 
40
41
  private lateinit var binding: ActivityMainBinding
41
42
  private lateinit var nativeBridge: NativeBridge
43
+ private lateinit var pluginBridge: PluginBridge
42
44
  private lateinit var customWebView: CustomWebView
43
45
  lateinit var properties: Properties
44
46
  private lateinit var metricsMonitor: MetricsMonitor
@@ -328,6 +330,14 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
328
330
  Log.e(TAG, "Failed to initialize NativeBridge: ${e.message}")
329
331
  }
330
332
 
333
+ // Setup isolated PluginBridge
334
+ try {
335
+ pluginBridge = PluginBridge(this, customWebView.getWebView(), properties)
336
+ customWebView.addJavascriptInterface(pluginBridge, "PluginBridge")
337
+ } catch (e: Exception) {
338
+ Log.e(TAG, "Failed to initialize PluginBridge: ${e.message}")
339
+ }
340
+
331
341
  setupSafeAreaHandling()
332
342
 
333
343
  // Setup back press handler (modern API)
@@ -445,11 +455,16 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
445
455
  if (::keyboardUtil.isInitialized) {
446
456
  keyboardUtil.cleanup()
447
457
  }
448
- if (::nativeBridge.isInitialized) {
449
- nativeBridge.cleanup()
458
+ if (::customWebView.isInitialized) {
459
+ if (::pluginBridge.isInitialized) {
460
+ customWebView.removeJavascriptInterface("PluginBridge")
461
+ }
462
+ if (::nativeBridge.isInitialized) {
463
+ customWebView.removeJavascriptInterface("NativeBridge")
464
+ }
465
+ customWebView.destroy()
450
466
  }
451
467
  coroutineContext.cancelChildren()
452
- customWebView.destroy()
453
468
  super.onDestroy()
454
469
  }
455
470
 
@@ -0,0 +1,7 @@
1
+ package io.yourname.androidproject.plugins
2
+
3
+ import org.json.JSONObject
4
+
5
+ interface CatalystPlugin {
6
+ fun handle(command: String, data: JSONObject?, bridge: PluginBridgeContext)
7
+ }
@@ -0,0 +1,6 @@
1
+ package io.yourname.androidproject.plugins
2
+
3
+ object GeneratedPluginIndex {
4
+ val pluginIdToClassName: Map<String, String> = emptyMap()
5
+ val pluginToCommands: Map<String, Set<String>> = emptyMap()
6
+ }
@@ -0,0 +1,253 @@
1
+ package io.yourname.androidproject.plugins
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import android.util.Log
6
+ import android.webkit.JavascriptInterface
7
+ import android.webkit.WebView
8
+ import io.yourname.androidproject.CatalystConstants
9
+ import org.json.JSONArray
10
+ import org.json.JSONException
11
+ import org.json.JSONObject
12
+ import java.util.Properties
13
+
14
+ internal data class PluginRequest(
15
+ val pluginId: String,
16
+ val command: String,
17
+ val data: JSONObject?,
18
+ val requestId: String?
19
+ )
20
+
21
+ private class PluginBridgeRuntimeError(
22
+ val publicMessage: String,
23
+ val code: String,
24
+ cause: Throwable? = null
25
+ ) : Exception(publicMessage, cause)
26
+
27
+ class PluginBridge(
28
+ private val activity: Activity,
29
+ private val webView: WebView,
30
+ private val properties: Properties
31
+ ) {
32
+ companion object {
33
+ private const val TAG = "PluginBridge"
34
+ private const val ERROR_EVENT = "PLUGIN_BRIDGE_ERROR"
35
+ private const val SYSTEM_PLUGIN_ID = "__bridge__"
36
+ private const val ERROR_CODE_INVALID_PAYLOAD = "INVALID_PAYLOAD"
37
+ private const val ERROR_CODE_PLUGIN_NOT_FOUND = "PLUGIN_NOT_FOUND"
38
+ private const val ERROR_CODE_COMMAND_NOT_SUPPORTED = "COMMAND_NOT_SUPPORTED"
39
+ private const val ERROR_CODE_PLUGIN_NOT_REGISTERED = "PLUGIN_NOT_REGISTERED"
40
+ private const val ERROR_CODE_PLUGIN_INSTANTIATION_FAILED = "PLUGIN_INSTANTIATION_FAILED"
41
+ private const val ERROR_CODE_PLUGIN_EXECUTION_FAILED = "PLUGIN_EXECUTION_FAILED"
42
+
43
+ private fun readRequiredString(body: JSONObject, key: String): String {
44
+ if (!body.has(key) || body.isNull(key)) {
45
+ return ""
46
+ }
47
+
48
+ val rawValue = body.get(key)
49
+ if (rawValue !is String) {
50
+ throw IllegalArgumentException("$key must be a string")
51
+ }
52
+
53
+ return rawValue.trim()
54
+ }
55
+
56
+ private fun readOptionalString(body: JSONObject, key: String): String? {
57
+ if (!body.has(key) || body.isNull(key)) {
58
+ return null
59
+ }
60
+
61
+ val rawValue = body.get(key)
62
+ if (rawValue !is String) {
63
+ throw IllegalArgumentException("$key must be a string when provided")
64
+ }
65
+
66
+ return rawValue.trim().ifEmpty { null }
67
+ }
68
+
69
+ private fun readOptionalObject(body: JSONObject, key: String): JSONObject? {
70
+ if (!body.has(key) || body.isNull(key)) {
71
+ return null
72
+ }
73
+
74
+ val rawValue = body.get(key)
75
+ if (rawValue !is JSONObject) {
76
+ throw IllegalArgumentException("$key must be an object when provided")
77
+ }
78
+
79
+ return rawValue
80
+ }
81
+
82
+ internal fun parseRequest(payload: String?): PluginRequest {
83
+ if (payload.isNullOrBlank()) {
84
+ throw IllegalArgumentException("Payload is required")
85
+ }
86
+ val messageSize = payload.toByteArray(Charsets.UTF_8).size
87
+ if (messageSize > CatalystConstants.Bridge.MAX_MESSAGE_SIZE) {
88
+ throw IllegalArgumentException("Payload exceeds maximum size")
89
+ }
90
+
91
+ val body = JSONObject(payload)
92
+ return PluginRequest(
93
+ pluginId = readRequiredString(body, "pluginId"),
94
+ command = readRequiredString(body, "command"),
95
+ data = readOptionalObject(body, "data"),
96
+ requestId = readOptionalString(body, "requestId")
97
+ )
98
+ }
99
+ }
100
+
101
+ private val pluginIdToClassName = GeneratedPluginIndex.pluginIdToClassName
102
+ private val pluginToCommands = GeneratedPluginIndex.pluginToCommands
103
+
104
+ @JavascriptInterface
105
+ fun emit(payload: String?) {
106
+ var request: PluginRequest? = null
107
+
108
+ try {
109
+ request = parseRequest(payload)
110
+
111
+ if (request.pluginId.isEmpty()) {
112
+ sendBridgeError("pluginId is required", ERROR_CODE_INVALID_PAYLOAD, request)
113
+ return
114
+ }
115
+ if (request.command.isEmpty()) {
116
+ sendBridgeError("command is required", ERROR_CODE_INVALID_PAYLOAD, request)
117
+ return
118
+ }
119
+
120
+ if (!hasPlugin(request.pluginId)) {
121
+ sendBridgeError("Unsupported plugin: ${request.pluginId}", ERROR_CODE_PLUGIN_NOT_FOUND, request)
122
+ return
123
+ }
124
+
125
+ if (!hasCommand(request.pluginId, request.command)) {
126
+ sendBridgeError(
127
+ "Unsupported command '${request.command}' for plugin '${request.pluginId}'",
128
+ ERROR_CODE_COMMAND_NOT_SUPPORTED,
129
+ request
130
+ )
131
+ return
132
+ }
133
+
134
+ val plugin = try {
135
+ getPluginForId(request.pluginId)
136
+ } catch (error: PluginBridgeRuntimeError) {
137
+ sendBridgeError(error.publicMessage, error.code, request)
138
+ return
139
+ }
140
+
141
+ val callbackContext = PluginBridgeContext(
142
+ activity = activity,
143
+ webView = webView,
144
+ properties = properties,
145
+ pluginId = request.pluginId,
146
+ command = request.command,
147
+ requestId = request.requestId
148
+ )
149
+
150
+ plugin.handle(request.command, request.data, callbackContext)
151
+ } catch (error: IllegalArgumentException) {
152
+ sendBridgeError(error.message ?: "Invalid payload", ERROR_CODE_INVALID_PAYLOAD, request)
153
+ } catch (error: JSONException) {
154
+ sendBridgeError("Invalid JSON payload", ERROR_CODE_INVALID_PAYLOAD, request)
155
+ } catch (error: Exception) {
156
+ Log.e(TAG, "Plugin command failed for ${request?.pluginId ?: "<unknown>"}.${request?.command ?: "<unknown>"}", error)
157
+ sendBridgeError("Plugin execution failed", ERROR_CODE_PLUGIN_EXECUTION_FAILED, request)
158
+ }
159
+ }
160
+
161
+ private fun sendBridgeError(message: String, code: String, request: PluginRequest?) {
162
+ PluginBridgeContext(
163
+ activity = activity,
164
+ webView = webView,
165
+ properties = properties,
166
+ pluginId = SYSTEM_PLUGIN_ID,
167
+ command = request?.command,
168
+ requestId = request?.requestId
169
+ ).callback(
170
+ ERROR_EVENT,
171
+ JSONObject().apply {
172
+ put("message", message)
173
+ put("code", code)
174
+ put("pluginId", request?.pluginId ?: SYSTEM_PLUGIN_ID)
175
+ request?.command?.takeIf { it.isNotEmpty() }?.let { put("command", it) }
176
+ }
177
+ )
178
+ }
179
+
180
+ private fun hasPlugin(pluginId: String): Boolean {
181
+ return pluginIdToClassName.containsKey(pluginId)
182
+ }
183
+
184
+ private fun hasCommand(pluginId: String, command: String): Boolean {
185
+ return pluginToCommands[pluginId]?.contains(command) ?: false
186
+ }
187
+
188
+ private fun getPluginForId(pluginId: String): CatalystPlugin {
189
+ val className = pluginIdToClassName[pluginId]
190
+ ?: throw PluginBridgeRuntimeError(
191
+ "Plugin is not registered",
192
+ ERROR_CODE_PLUGIN_NOT_REGISTERED
193
+ )
194
+
195
+ return try {
196
+ val clazz = Class.forName(className)
197
+ val instance = clazz.getDeclaredConstructor().newInstance()
198
+ instance as? CatalystPlugin
199
+ ?: throw IllegalStateException("Plugin class '$className' must implement CatalystPlugin")
200
+ } catch (error: Exception) {
201
+ Log.e(TAG, "Failed to instantiate plugin class $className for plugin $pluginId", error)
202
+ throw PluginBridgeRuntimeError(
203
+ "Plugin could not be initialized",
204
+ ERROR_CODE_PLUGIN_INSTANTIATION_FAILED,
205
+ error
206
+ )
207
+ }
208
+ }
209
+ }
210
+
211
+ class PluginBridgeContext(
212
+ val activity: Activity,
213
+ val webView: WebView,
214
+ val properties: Properties,
215
+ val pluginId: String,
216
+ val command: String?,
217
+ val requestId: String?
218
+ ) {
219
+ val context: Context
220
+ get() = activity
221
+
222
+ fun callback(
223
+ eventName: String,
224
+ data: Any?,
225
+ command: String? = this.command
226
+ ) {
227
+ require(eventName.isNotBlank()) { "Callback eventName is required" }
228
+
229
+ val pluginLiteral = JSONObject.quote(pluginId)
230
+ val eventLiteral = JSONObject.quote(eventName)
231
+ val dataLiteral = toJavaScriptLiteral(data)
232
+ val requestLiteral = requestId?.let(JSONObject::quote) ?: "null"
233
+ val commandLiteral = command?.takeIf { it.isNotBlank() }?.let(JSONObject::quote) ?: "null"
234
+
235
+ webView.post {
236
+ webView.evaluateJavascript(
237
+ "window.PluginBridgeWeb && window.PluginBridgeWeb.callback($pluginLiteral, $eventLiteral, $dataLiteral, $requestLiteral, $commandLiteral);",
238
+ null
239
+ )
240
+ }
241
+ }
242
+
243
+ private fun toJavaScriptLiteral(value: Any?): String {
244
+ return when (value) {
245
+ null -> "null"
246
+ is JSONObject -> value.toString()
247
+ is JSONArray -> value.toString()
248
+ is Number, is Boolean -> value.toString()
249
+ is String -> JSONObject.quote(value)
250
+ else -> JSONObject.wrap(value)?.toString() ?: "null"
251
+ }
252
+ }
253
+ }
@@ -0,0 +1,199 @@
1
+ package io.yourname.androidproject
2
+
3
+ import org.json.JSONObject
4
+ import org.junit.Assert.*
5
+ import org.junit.Test
6
+
7
+ /**
8
+ * Unit tests for security bridge command routing and JSON injection safety.
9
+ *
10
+ * Coverage:
11
+ * - setScreenSecure command routing (5 tests)
12
+ * - getScreenSecure command routing (2 tests)
13
+ * - clearWebData command routing (2 tests)
14
+ * - JSON injection safety in error responses (3 tests)
15
+ *
16
+ * Total: 12 tests
17
+ *
18
+ * Note: These tests operate on static parsing/validation logic and JSON data
19
+ * structures. Full lifecycle (FLAG_SECURE, CookieManager) is exercised in
20
+ * device integration tests; here we confirm command acceptance, param parsing,
21
+ * and that error payloads produced via JSONObject cannot carry injected content.
22
+ */
23
+ class SecurityBridgeTest {
24
+
25
+ // ============================================================
26
+ // CATEGORY 1: setScreenSecure COMMAND ROUTING (5 tests)
27
+ // ============================================================
28
+
29
+ @Test
30
+ fun `test setScreenSecure command is accepted by validator`() {
31
+ val messageJson = """{"command": "setScreenSecure", "data": {"enable": true}}"""
32
+
33
+ val result = NativeBridge.parseAndValidateMessage(messageJson)
34
+
35
+ assertTrue("setScreenSecure should be a valid command", result.isValid)
36
+ assertEquals("setScreenSecure", result.command)
37
+ assertNull(result.error)
38
+ }
39
+
40
+ @Test
41
+ fun `test setScreenSecure enable true parsed from params`() {
42
+ val params = JSONObject().apply { put("enable", true) }
43
+
44
+ val enable = params.optBoolean("enable", false)
45
+
46
+ assertTrue("enable should be true", enable)
47
+ }
48
+
49
+ @Test
50
+ fun `test setScreenSecure enable false parsed from params`() {
51
+ val params = JSONObject().apply { put("enable", false) }
52
+
53
+ val enable = params.optBoolean("enable", true)
54
+
55
+ assertFalse("enable should be false", enable)
56
+ }
57
+
58
+ @Test
59
+ fun `test setScreenSecure defaults to true when params are malformed`() {
60
+ // NativeBridge.setScreenSecure falls back to true when JSON parse fails
61
+ val malformedParams = "not-json"
62
+ val enable = try {
63
+ JSONObject(malformedParams).optBoolean("enable", true)
64
+ } catch (e: Exception) {
65
+ true
66
+ }
67
+
68
+ assertTrue("Malformed params should default to enable=true", enable)
69
+ }
70
+
71
+ @Test
72
+ fun `test setScreenSecure success response shape`() {
73
+ val enable = true
74
+ val response = JSONObject().apply {
75
+ put("secure", enable)
76
+ put("success", true)
77
+ }
78
+
79
+ assertTrue(response.getBoolean("secure"))
80
+ assertTrue(response.getBoolean("success"))
81
+ // Verify no extra fields bleed in
82
+ assertEquals(2, response.length())
83
+ }
84
+
85
+ // ============================================================
86
+ // CATEGORY 2: getScreenSecure COMMAND ROUTING (2 tests)
87
+ // ============================================================
88
+
89
+ @Test
90
+ fun `test getScreenSecure command is accepted by validator`() {
91
+ val messageJson = """{"command": "getScreenSecure", "data": {}}"""
92
+
93
+ val result = NativeBridge.parseAndValidateMessage(messageJson)
94
+
95
+ assertTrue("getScreenSecure should be a valid command", result.isValid)
96
+ assertEquals("getScreenSecure", result.command)
97
+ assertNull(result.error)
98
+ }
99
+
100
+ @Test
101
+ fun `test getScreenSecure status response shape`() {
102
+ // Validate that the JSON payload produced by getScreenSecure is well-formed
103
+ val isSecure = false
104
+ val response = JSONObject().apply {
105
+ put("secure", isSecure)
106
+ put("success", true)
107
+ }
108
+
109
+ assertFalse(response.getBoolean("secure"))
110
+ assertTrue(response.getBoolean("success"))
111
+ assertEquals(2, response.length())
112
+ }
113
+
114
+ // ============================================================
115
+ // CATEGORY 3: clearWebData COMMAND ROUTING (2 tests)
116
+ // ============================================================
117
+
118
+ @Test
119
+ fun `test clearWebData command is accepted by validator`() {
120
+ val messageJson = """{"command": "clearWebData", "data": {}}"""
121
+
122
+ val result = NativeBridge.parseAndValidateMessage(messageJson)
123
+
124
+ assertTrue("clearWebData should be a valid command", result.isValid)
125
+ assertEquals("clearWebData", result.command)
126
+ assertNull(result.error)
127
+ }
128
+
129
+ @Test
130
+ fun `test clearWebData success response shape`() {
131
+ val cookiesRemoved = true
132
+ val response = JSONObject().apply {
133
+ put("success", true)
134
+ put("cookiesRemoved", cookiesRemoved)
135
+ }
136
+
137
+ assertTrue(response.getBoolean("success"))
138
+ assertTrue(response.getBoolean("cookiesRemoved"))
139
+ assertEquals(2, response.length())
140
+ }
141
+
142
+ // ============================================================
143
+ // CATEGORY 4: JSON INJECTION SAFETY (3 tests)
144
+ // ============================================================
145
+
146
+ @Test
147
+ fun `test error message with injection characters is safe via JSONObject`() {
148
+ // Simulate an exception message that contains characters dangerous in naive JS
149
+ // string interpolation: double-quotes and backslashes.
150
+ // JSONObject must escape them so the serialized JSON stays valid and cannot
151
+ // break out of the surrounding JS string literal in notifyWebJson.
152
+ val injectionPayload = "failed\"; window[\"evil\"]=\"injected"
153
+
154
+ val response = JSONObject().apply {
155
+ put("success", false)
156
+ put("error", injectionPayload)
157
+ }
158
+
159
+ val serialized = response.toString()
160
+
161
+ // The raw unescaped double-quote sequence that would break JS must not appear
162
+ assertFalse(
163
+ "Unescaped double-quote injection sequence must not appear in serialized JSON",
164
+ serialized.contains("\"evil\"")
165
+ )
166
+ // The value must survive a round-trip intact
167
+ assertEquals(injectionPayload, JSONObject(serialized).getString("error"))
168
+ }
169
+
170
+ @Test
171
+ fun `test error message with backslash sequences is safe via JSONObject`() {
172
+ val backslashPayload = "err\\\"injected\\\""
173
+
174
+ val response = JSONObject().apply {
175
+ put("success", false)
176
+ put("error", backslashPayload)
177
+ }
178
+
179
+ // Round-trip must survive without throwing
180
+ val roundTripped = JSONObject(response.toString()).getString("error")
181
+ assertEquals(backslashPayload, roundTripped)
182
+ }
183
+
184
+ @Test
185
+ fun `test setScreenSecure and clearWebData commands are in VALID_COMMANDS whitelist`() {
186
+ assertTrue(
187
+ "setScreenSecure must be whitelisted",
188
+ CatalystConstants.Bridge.VALID_COMMANDS.contains("setScreenSecure")
189
+ )
190
+ assertTrue(
191
+ "getScreenSecure must be whitelisted",
192
+ CatalystConstants.Bridge.VALID_COMMANDS.contains("getScreenSecure")
193
+ )
194
+ assertTrue(
195
+ "clearWebData must be whitelisted",
196
+ CatalystConstants.Bridge.VALID_COMMANDS.contains("clearWebData")
197
+ )
198
+ }
199
+ }