pakstr 0.31.0 → 1.0.1

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 (19) hide show
  1. package/README.md +1 -1
  2. package/android-template/app/build.gradle.kts +1 -0
  3. package/android-template/app/src/androidTest/java/com/pakstr/app/PakstrWebBridgeTest.kt +153 -0
  4. package/android-template/app/src/main/assets/nip07.js +124 -74
  5. package/android-template/app/src/main/java/com/pakstr/app/AppOrigin.kt +77 -0
  6. package/android-template/app/src/main/java/com/pakstr/app/PakstrBridge.kt +5 -5
  7. package/android-template/app/src/main/java/com/pakstr/app/PakstrWebBridge.kt +89 -0
  8. package/android-template/app/src/main/java/com/pakstr/app/WebMessageRouter.kt +186 -0
  9. package/android-template/app/src/main/java/com/pakstr/app/WebViewController.kt +65 -49
  10. package/android-template/app/src/main/java/com/pakstr/app/signer/AmberRequestChannel.kt +156 -0
  11. package/android-template/app/src/main/java/com/pakstr/app/signer/AmberSigner.kt +142 -390
  12. package/android-template/app/src/main/java/com/pakstr/app/signer/NostrBridge.kt +69 -489
  13. package/android-template/app/src/main/java/com/pakstr/app/signer/SignerManager.kt +13 -2
  14. package/android-template/app/src/test/java/com/pakstr/app/AppOriginTest.kt +114 -0
  15. package/android-template/app/src/test/java/com/pakstr/app/WebMessageRouterTest.kt +279 -0
  16. package/android-template/app/src/test/java/com/pakstr/app/signer/AmberRequestChannelTest.kt +294 -0
  17. package/android-template/app/src/test/java/com/pakstr/app/signer/NostrBridgeDispatchTest.kt +113 -0
  18. package/android-template/gradle/libs.versions.toml +2 -0
  19. package/package.json +1 -1
@@ -0,0 +1,114 @@
1
+ package com.pakstr.app
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertTrue
6
+ import org.junit.Test
7
+
8
+ class AppOriginTest {
9
+ private val port = Config.PORT
10
+
11
+ @Test
12
+ fun originRuleIsTheExactLocalServerOrigin() {
13
+ assertEquals("http://127.0.0.1:$port", AppOrigin.ORIGIN)
14
+ }
15
+
16
+ @Test
17
+ fun onlyTheExactAppOriginIsTrusted() {
18
+ assertTrue(AppOrigin.isTrustedOrigin("http", "127.0.0.1", port, null, null))
19
+ assertTrue(AppOrigin.isTrustedOrigin("http", "127.0.0.1", port, null, ""))
20
+ assertTrue(AppOrigin.isTrustedOrigin("http", "127.0.0.1", port, null, "/"))
21
+ }
22
+
23
+ @Test
24
+ fun nearMissOriginsAreRejected() {
25
+ val rejected = listOf(
26
+ arrayOf<Any?>("https", "127.0.0.1", port, null, null),
27
+ arrayOf<Any?>("HTTP", "127.0.0.1", port, null, null),
28
+ arrayOf<Any?>("http", "127.0.0.1", port + 1, null, null),
29
+ arrayOf<Any?>("http", "127.0.0.1", -1, null, null),
30
+ arrayOf<Any?>("http", "127.0.0.1", 80, null, null),
31
+ arrayOf<Any?>("http", "localhost", port, null, null),
32
+ arrayOf<Any?>("http", "127.0.0.2", port, null, null),
33
+ arrayOf<Any?>("http", "[::1]", port, null, null),
34
+ arrayOf<Any?>("http", "127.0.0.1.example.com", port, null, null),
35
+ arrayOf<Any?>("http", "example.com", port, null, null),
36
+ arrayOf<Any?>("http", "127.0.0.1", port, "user", null),
37
+ arrayOf<Any?>("http", "127.0.0.1", port, null, "/index.html"),
38
+ arrayOf<Any?>("file", null, -1, null, "/android_asset/www/index.html"),
39
+ arrayOf<Any?>("data", null, -1, null, "text/html,x"),
40
+ arrayOf<Any?>(null, null, -1, null, null)
41
+ )
42
+ for (origin in rejected) {
43
+ assertFalse(
44
+ origin.contentToString(),
45
+ AppOrigin.isTrustedOrigin(
46
+ origin[0] as String?,
47
+ origin[1] as String?,
48
+ origin[2] as Int,
49
+ origin[3] as String?,
50
+ origin[4] as String?
51
+ )
52
+ )
53
+ }
54
+ }
55
+
56
+ @Test
57
+ fun mainFrameNavigationStaysOnTheAppOrigin() {
58
+ assertEquals(
59
+ NavigationDecision.ALLOW,
60
+ navigationDecision("http", "127.0.0.1", port, null, isForMainFrame = true)
61
+ )
62
+ assertEquals(
63
+ NavigationDecision.ALLOW,
64
+ navigationDecision("http", "127.0.0.1", port, null, isForMainFrame = false)
65
+ )
66
+ }
67
+
68
+ @Test
69
+ fun navigationOffTheAppOriginIsBlocked() {
70
+ val blocked = listOf(
71
+ Triple("http", "127.0.0.1", port + 1),
72
+ Triple("http", "127.0.0.1", -1),
73
+ Triple("https", "127.0.0.1", port),
74
+ Triple("http", "localhost", port),
75
+ Triple("https", "example.com", -1),
76
+ Triple("intent", null, -1),
77
+ Triple("javascript", null, -1),
78
+ Triple("file", null, -1),
79
+ Triple("about", null, -1),
80
+ Triple("data", null, -1)
81
+ )
82
+ for ((scheme, host, navigationPort) in blocked) {
83
+ assertEquals(
84
+ "$scheme://$host:$navigationPort",
85
+ NavigationDecision.BLOCK,
86
+ navigationDecision(scheme, host, navigationPort, null, isForMainFrame = true)
87
+ )
88
+ }
89
+ assertEquals(
90
+ NavigationDecision.BLOCK,
91
+ navigationDecision("http", "127.0.0.1", port, "user:pass", isForMainFrame = true)
92
+ )
93
+ assertEquals(
94
+ NavigationDecision.BLOCK,
95
+ navigationDecision(null, null, -1, null, isForMainFrame = true)
96
+ )
97
+ }
98
+
99
+ @Test
100
+ fun nip55RequestsAreDispatchedOnlyFromTheMainFrame() {
101
+ assertEquals(
102
+ NavigationDecision.NIP55_SIGN_REQUEST,
103
+ navigationDecision("nostrsigner", null, -1, null, isForMainFrame = true)
104
+ )
105
+ assertEquals(
106
+ NavigationDecision.NIP55_SIGN_REQUEST,
107
+ navigationDecision("NostrSigner", null, -1, null, isForMainFrame = true)
108
+ )
109
+ assertEquals(
110
+ NavigationDecision.REJECT_SUBFRAME_NIP55,
111
+ navigationDecision("nostrsigner", null, -1, null, isForMainFrame = false)
112
+ )
113
+ }
114
+ }
@@ -0,0 +1,279 @@
1
+ package com.pakstr.app
2
+
3
+ import com.pakstr.app.signer.NostrBridge
4
+ import com.pakstr.app.signer.exceptions.SignerUnavailableException
5
+ import com.pakstr.app.signer.interfaces.NostrSigner
6
+ import kotlinx.coroutines.CoroutineScope
7
+ import kotlinx.coroutines.Dispatchers
8
+ import org.json.JSONObject
9
+ import org.junit.Assert.assertEquals
10
+ import org.junit.Assert.assertFalse
11
+ import org.junit.Assert.assertNull
12
+ import org.junit.Assert.assertTrue
13
+ import org.junit.Test
14
+
15
+ class WebMessageRouterTest {
16
+ private val trusted = MessageSource("http", "127.0.0.1", Config.PORT, null, null, isMainFrame = true)
17
+
18
+ @Test
19
+ fun requestFromTheAppTopFrameIsAnsweredToTheSender() {
20
+ val bridge = Harness()
21
+
22
+ val replies = bridge.send(trusted, request("id-1", "nostr.getPublicKey"))
23
+
24
+ assertEquals(1, replies.size)
25
+ val reply = JSONObject(replies.single())
26
+ assertEquals(1, reply.getInt("v"))
27
+ assertEquals("id-1", reply.getString("id"))
28
+ assertTrue(reply.getBoolean("ok"))
29
+ assertEquals(PUBKEY, reply.getString("result"))
30
+ assertEquals(listOf("getPublicKey"), bridge.signer.calls)
31
+ }
32
+
33
+ @Test
34
+ fun subframesAreRejectedWithoutReachingTheSigner() {
35
+ val bridge = Harness()
36
+
37
+ val replies = bridge.send(trusted.copy(isMainFrame = false), request("id-1", "nostr.signEvent", EVENT))
38
+
39
+ assertTrue(replies.isEmpty())
40
+ assertTrue(bridge.signer.calls.isEmpty())
41
+ assertEquals(0, bridge.handled)
42
+ }
43
+
44
+ @Test
45
+ fun untrustedOriginsAreRejectedWithoutReachingAnyHandler() {
46
+ val untrusted = listOf(
47
+ trusted.copy(scheme = "https"),
48
+ trusted.copy(port = Config.PORT + 1),
49
+ trusted.copy(host = "localhost"),
50
+ trusted.copy(host = "evil.example"),
51
+ trusted.copy(userInfo = "user"),
52
+ trusted.copy(path = "/index.html"),
53
+ MessageSource(null, null, -1, null, null, isMainFrame = true)
54
+ )
55
+ for (source in untrusted) {
56
+ val bridge = Harness()
57
+ for (method in listOf("nostr.nip44_decrypt", "runtime.setApiBaseUrl", "runtime.getApiBaseUrl")) {
58
+ val replies = bridge.send(
59
+ source,
60
+ request("id-1", method, JSONObject().put("url", "https://evil.example"))
61
+ )
62
+ assertTrue("$source $method", replies.isEmpty())
63
+ }
64
+ assertEquals(source.toString(), 0, bridge.handled)
65
+ assertNull(bridge.preferences.values[RuntimeApiBaseSettings.API_BASE_OVERRIDE_KEY])
66
+ }
67
+ }
68
+
69
+ @Test
70
+ fun nonStringOversizedAndMalformedMessagesAreDropped() {
71
+ val bridge = Harness()
72
+ val oversized = request(
73
+ "id-1",
74
+ "nostr.nip44_encrypt",
75
+ JSONObject().put("pubkey", PUBKEY).put("plaintext", "x".repeat(WebMessageRouter.MAX_MESSAGE_BYTES))
76
+ )
77
+ val dropped = listOf(
78
+ null,
79
+ oversized,
80
+ "",
81
+ "not json",
82
+ "[1,2,3]",
83
+ JSONObject().put("id", "id-1").put("method", "nostr.getPublicKey").toString(),
84
+ request("id-1", "nostr.getPublicKey").replace("\"v\":1", "\"v\":2"),
85
+ request("id-1", "nostr.getPublicKey").replace("\"v\":1", "\"v\":\"1\""),
86
+ JSONObject().put("v", 1).put("method", "nostr.getPublicKey").toString(),
87
+ request("bad id", "nostr.getPublicKey"),
88
+ request("x".repeat(65), "nostr.getPublicKey"),
89
+ JSONObject().put("v", 1).put("id", 7).put("method", "nostr.getPublicKey").toString()
90
+ )
91
+ for (message in dropped) {
92
+ assertTrue(message?.take(80).toString(), bridge.send(trusted, message).isEmpty())
93
+ }
94
+ assertEquals(0, bridge.handled)
95
+ }
96
+
97
+ @Test
98
+ fun multiByteMessagesAreMeasuredInUtf8Bytes() {
99
+ val bridge = Harness()
100
+ // Under the limit in UTF-16 characters, over it in UTF-8 bytes.
101
+ val plaintext = "é".repeat(WebMessageRouter.MAX_MESSAGE_BYTES / 2)
102
+ val message = request("id-1", "nostr.nip44_encrypt", JSONObject().put("pubkey", PUBKEY).put("plaintext", plaintext))
103
+
104
+ assertTrue(message.length <= WebMessageRouter.MAX_MESSAGE_BYTES)
105
+ assertTrue(bridge.send(trusted, message).isEmpty())
106
+ }
107
+
108
+ @Test
109
+ fun invalidRequestsWithAValidIdGetAnErrorReply() {
110
+ val bridge = Harness()
111
+ val noMethod = JSONObject().put("v", 1).put("id", "id-1").toString()
112
+ val arrayParams = JSONObject().put("v", 1).put("id", "id-2").put("method", "nostr.signEvent")
113
+ .put("params", org.json.JSONArray().put(1)).toString()
114
+
115
+ for ((message, id) in listOf(noMethod to "id-1", arrayParams to "id-2")) {
116
+ val reply = JSONObject(bridge.send(trusted, message).single())
117
+ assertEquals(id, reply.getString("id"))
118
+ assertFalse(reply.getBoolean("ok"))
119
+ assertEquals("SIGNER_ERROR", reply.getJSONObject("error").getString("error"))
120
+ assertEquals("Invalid request", reply.getJSONObject("error").getString("message"))
121
+ }
122
+ assertEquals(0, bridge.handled)
123
+ }
124
+
125
+ @Test
126
+ fun unknownMethodsAreRejected() {
127
+ val bridge = Harness()
128
+ for (method in listOf("nostr.unknown", "nostr.", "runtime.reset", "AndroidBridge.call", "debug.unknown")) {
129
+ val reply = JSONObject(bridge.send(trusted, request("id-1", method)).single())
130
+ assertFalse(method, reply.getBoolean("ok"))
131
+ assertEquals("SIGNER_ERROR", reply.getJSONObject("error").getString("error"))
132
+ assertEquals("Unsupported method", reply.getJSONObject("error").getString("message"))
133
+ }
134
+ assertTrue(bridge.signer.calls.isEmpty())
135
+ }
136
+
137
+ @Test
138
+ fun signerFailuresKeepTheirErrorCodes() {
139
+ val unavailable = Harness(FakeSigner(failure = SignerUnavailableException("No signer is paired")))
140
+ val unavailableReply = JSONObject(unavailable.send(trusted, request("id-1", "nostr.getPublicKey")).single())
141
+ assertEquals("SIGNER_UNAVAILABLE", unavailableReply.getJSONObject("error").getString("error"))
142
+ assertEquals("No signer is paired", unavailableReply.getJSONObject("error").getString("message"))
143
+
144
+ val failing = Harness(FakeSigner(failure = IllegalStateException("rejected")))
145
+ val failingReply = JSONObject(failing.send(trusted, request("id-1", "nostr.signEvent", EVENT)).single())
146
+ assertFalse(failingReply.getBoolean("ok"))
147
+ assertEquals("SIGNER_ERROR", failingReply.getJSONObject("error").getString("error"))
148
+ assertEquals("rejected", failingReply.getJSONObject("error").getString("message"))
149
+ }
150
+
151
+ @Test
152
+ fun plaintextThatLooksLikeAnErrorObjectIsStillAResult() {
153
+ val plaintext = """{"error":"x","message":"y"}"""
154
+ val bridge = Harness(FakeSigner(decrypted = plaintext))
155
+
156
+ val reply = JSONObject(
157
+ bridge.send(
158
+ trusted,
159
+ request("id-1", "nostr.nip44_decrypt", JSONObject().put("pubkey", PUBKEY).put("ciphertext", "c"))
160
+ ).single()
161
+ )
162
+
163
+ assertTrue(reply.getBoolean("ok"))
164
+ assertEquals(plaintext, reply.getString("result"))
165
+ }
166
+
167
+ @Test
168
+ fun runtimeMethodsUseTheRuntimeApiBaseSettings() {
169
+ val bridge = Harness()
170
+
171
+ val set = JSONObject(
172
+ bridge.send(trusted, request("id-1", "runtime.setApiBaseUrl", JSONObject().put("url", "https://api.example.com/"))).single()
173
+ )
174
+ assertTrue(set.getBoolean("result"))
175
+ val get = JSONObject(bridge.send(trusted, request("id-2", "runtime.getApiBaseUrl")).single())
176
+ assertEquals("https://api.example.com", get.getString("result"))
177
+
178
+ for (params in listOf(JSONObject().put("url", "http://insecure.example.com"), JSONObject().put("url", 5), null)) {
179
+ val rejected = JSONObject(bridge.send(trusted, request("id-3", "runtime.setApiBaseUrl", params)).single())
180
+ assertTrue(rejected.getBoolean("ok"))
181
+ assertFalse(rejected.getBoolean("result"))
182
+ }
183
+ assertEquals("https://api.example.com", bridge.preferences.values[RuntimeApiBaseSettings.API_BASE_OVERRIDE_KEY])
184
+
185
+ val callback = JSONObject(bridge.send(trusted, request("id-4", "runtime.getNip55CallbackUrl")).single())
186
+ assertEquals(Nip55Callback.callbackPrefix, callback.getString("result"))
187
+ }
188
+
189
+ @Test
190
+ fun debugLogIsANotificationWithoutAReply() {
191
+ val bridge = Harness()
192
+ val message = JSONObject().put("v", 1).put("method", WebMessageRouter.DEBUG_LOG_METHOD)
193
+ .put("params", JSONObject().put("level", "WARN").put("message", "hello")).toString()
194
+
195
+ assertTrue(bridge.send(trusted, message).isEmpty())
196
+ assertEquals(listOf("WARN" to "hello"), bridge.debugLogs)
197
+
198
+ assertTrue(bridge.send(trusted.copy(isMainFrame = false), message).isEmpty())
199
+ assertEquals(1, bridge.debugLogs.size)
200
+ }
201
+
202
+ @Test
203
+ fun repliesArePostedThroughTheMainThreadHook() {
204
+ var posted = 0
205
+ val bridge = Harness(runOnMain = { block -> posted++; block() })
206
+
207
+ bridge.send(trusted, request("id-1", "nostr.getPublicKey"))
208
+ bridge.send(trusted, JSONObject().put("v", 1).put("id", "id-2").toString())
209
+
210
+ assertEquals(2, posted)
211
+ }
212
+
213
+ private fun request(id: String, method: String, params: JSONObject? = null): String =
214
+ JSONObject().put("v", 1).put("id", id).put("method", method)
215
+ .apply { if (params != null) put("params", params) }
216
+ .toString()
217
+
218
+ private class Harness(
219
+ val signer: FakeSigner = FakeSigner(),
220
+ runOnMain: (() -> Unit) -> Unit = { block -> block() }
221
+ ) {
222
+ val preferences = FakePreferences()
223
+ val debugLogs = mutableListOf<Pair<String, String>>()
224
+ var handled = 0
225
+ private val methods = PakstrBridgeMethods(
226
+ nostr = NostrBridge(signerProvider = { signer }),
227
+ runtime = PakstrBridge(RuntimeApiBaseSettings.createForTesting(preferences) { null }),
228
+ debugLog = { level, message -> debugLogs += level to message }
229
+ )
230
+ private val router = WebMessageRouter(
231
+ handler = { method, params -> handled++; methods.handle(method, params) },
232
+ scope = CoroutineScope(Dispatchers.Unconfined),
233
+ runOnMain = runOnMain
234
+ )
235
+
236
+ fun send(source: MessageSource, data: String?): List<String> {
237
+ val replies = mutableListOf<String>()
238
+ router.onMessage(source, data) { replies += it }
239
+ return replies
240
+ }
241
+ }
242
+
243
+ private class FakeSigner(
244
+ private val failure: Exception? = null,
245
+ private val decrypted: String = "plaintext"
246
+ ) : NostrSigner {
247
+ val calls = mutableListOf<String>()
248
+
249
+ private fun record(name: String) {
250
+ calls += name
251
+ failure?.let { throw it }
252
+ }
253
+
254
+ override suspend fun getPublicKey(): String = PUBKEY.also { record("getPublicKey") }
255
+ override suspend fun signEvent(eventJson: String): String = eventJson.also { record("signEvent") }
256
+ override suspend fun nip04Encrypt(pubkey: String, plaintext: String) = "c04".also { record("nip04Encrypt") }
257
+ override suspend fun nip04Decrypt(pubkey: String, ciphertext: String) = decrypted.also { record("nip04Decrypt") }
258
+ override suspend fun nip44Encrypt(pubkey: String, plaintext: String) = "c44".also { record("nip44Encrypt") }
259
+ override suspend fun nip44Decrypt(pubkey: String, ciphertext: String) = decrypted.also { record("nip44Decrypt") }
260
+ }
261
+
262
+ private class FakePreferences : RuntimeApiBaseSettings.Preferences {
263
+ val values = mutableMapOf<String, String>()
264
+ override fun getString(key: String): String? = values[key]
265
+ override fun putString(key: String, value: String): Boolean {
266
+ values[key] = value
267
+ return true
268
+ }
269
+ override fun remove(key: String): Boolean {
270
+ values.remove(key)
271
+ return true
272
+ }
273
+ }
274
+
275
+ private companion object {
276
+ val PUBKEY = "a".repeat(64)
277
+ val EVENT: JSONObject = JSONObject().put("kind", 1).put("created_at", 1).put("tags", org.json.JSONArray()).put("content", "hi")
278
+ }
279
+ }
@@ -0,0 +1,294 @@
1
+ package com.pakstr.app.signer
2
+
3
+ import com.pakstr.app.signer.exceptions.SignerUnavailableException
4
+ import kotlinx.coroutines.CoroutineScope
5
+ import kotlinx.coroutines.Deferred
6
+ import kotlinx.coroutines.Dispatchers
7
+ import kotlinx.coroutines.SupervisorJob
8
+ import kotlinx.coroutines.async
9
+ import kotlinx.coroutines.cancel
10
+ import kotlinx.coroutines.cancelAndJoin
11
+ import kotlinx.coroutines.channels.Channel
12
+ import kotlinx.coroutines.delay
13
+ import kotlinx.coroutines.runBlocking
14
+ import kotlinx.coroutines.withTimeout
15
+ import org.junit.After
16
+ import org.junit.Assert.assertEquals
17
+ import org.junit.Assert.assertFalse
18
+ import org.junit.Assert.assertThrows
19
+ import org.junit.Assert.assertTrue
20
+ import org.junit.Test
21
+ import java.util.concurrent.atomic.AtomicInteger
22
+
23
+ class AmberRequestChannelTest {
24
+ private val launched = Channel<String>(Channel.UNLIMITED)
25
+ private val ids = AtomicInteger()
26
+
27
+ // Failed requests must not cancel the test body that awaits them.
28
+ private val requests = CoroutineScope(SupervisorJob() + Dispatchers.Default)
29
+
30
+ @After
31
+ fun cancelRequests() {
32
+ requests.cancel()
33
+ }
34
+
35
+ private fun channel(timeoutMillis: Long = 5_000) =
36
+ AmberRequestChannel(timeoutMillis) { "req-${ids.incrementAndGet()}" }
37
+
38
+ @Test
39
+ fun concurrent_requests_launch_one_at_a_time_and_get_their_own_results() = runBlocking {
40
+ val channel = channel()
41
+ val session = channel.openSession()
42
+ val pending = (1..3).map { request(session) }
43
+
44
+ val results = mutableMapOf<String, String>()
45
+ repeat(3) {
46
+ val id = nextLaunch()
47
+ assertNoLaunch()
48
+ assertTrue(channel.deliver(id, Result.success("result-$id")))
49
+ results[id] = "result-$id"
50
+ }
51
+
52
+ assertEquals(results.values.toSet(), pending.map { it.await() }.toSet())
53
+ }
54
+
55
+ @Test
56
+ fun duplicate_and_unexpected_results_are_dropped() = runBlocking {
57
+ val channel = channel()
58
+ val session = channel.openSession()
59
+ assertFalse(channel.deliver(null, Result.success("nobody asked")))
60
+
61
+ val pending = request(session)
62
+ val id = nextLaunch()
63
+ assertTrue(channel.deliver(id, Result.success("right")))
64
+ assertFalse(channel.deliver(id, Result.success("duplicate")))
65
+ assertFalse(channel.deliver(null, Result.success("duplicate")))
66
+ assertEquals("right", pending.await())
67
+ }
68
+
69
+ @Test
70
+ fun results_without_an_id_resolve_the_outstanding_request() = runBlocking {
71
+ val channel = channel()
72
+ val pending = request(channel.openSession())
73
+ nextLaunch()
74
+
75
+ assertTrue(channel.deliver(null, Result.success("")))
76
+ assertEquals("", pending.await())
77
+ }
78
+
79
+ @Test
80
+ fun mismatched_result_fails_the_request_without_its_data_and_frees_the_slot() = runBlocking {
81
+ val channel = channel()
82
+ val session = channel.openSession()
83
+ val pending = request(session)
84
+ nextLaunch()
85
+ val queued = request(session)
86
+ assertNoLaunch()
87
+
88
+ assertFalse(channel.deliver("other-request", Result.success("secret payload")))
89
+
90
+ val error = failure(pending)
91
+ assertTrue(error is AmberResponseMismatchException)
92
+ assertFalse(error?.message.orEmpty().contains("secret payload"))
93
+
94
+ // The queued request launches and receives only its own result.
95
+ val id = nextLaunch()
96
+ assertEquals("req-2", id)
97
+ assertTrue(channel.deliver(id, Result.success("fresh")))
98
+ assertEquals("fresh", queued.await())
99
+ }
100
+
101
+ @Test
102
+ fun mismatched_result_releases_a_slot_held_by_an_abandoned_request() = runBlocking {
103
+ val channel = channel(timeoutMillis = 100)
104
+ val session = channel.openSession()
105
+ val abandoned = request(session)
106
+ nextLaunch()
107
+ assertTrue(failure(abandoned) is AmberRequestTimeoutException)
108
+
109
+ val next = request(session, timeout = false)
110
+ assertNoLaunch()
111
+
112
+ assertFalse(channel.deliver("other-request", Result.success("secret payload")))
113
+ val id = nextLaunch()
114
+ assertTrue(channel.deliver(id, Result.success("fresh")))
115
+ assertEquals("fresh", next.await())
116
+ }
117
+
118
+ @Test
119
+ fun rejected_result_fails_the_request_and_frees_the_slot() = runBlocking {
120
+ val channel = channel()
121
+ val session = channel.openSession()
122
+ val pending = request(session)
123
+ val id = nextLaunch()
124
+
125
+ assertTrue(channel.deliver(id, Result.failure(AmberRequestRejectedException())))
126
+ assertTrue(failure(pending) is AmberRequestRejectedException)
127
+
128
+ val next = request(session)
129
+ val nextId = nextLaunch()
130
+ assertTrue(channel.deliver(nextId, Result.success("ok")))
131
+ assertEquals("ok", next.await())
132
+ }
133
+
134
+ @Test
135
+ fun timeout_fails_with_a_regular_exception_and_late_result_does_not_leak() = runBlocking {
136
+ val channel = channel(timeoutMillis = 100)
137
+ val session = channel.openSession()
138
+ val abandoned = request(session)
139
+ nextLaunch()
140
+ val error = failure(abandoned)
141
+ assertTrue(error is AmberRequestTimeoutException)
142
+ assertEquals("Amber request timed out", error?.message)
143
+
144
+ val next = request(session, timeout = false)
145
+ assertNoLaunch()
146
+
147
+ // The abandoned activity's id-less result drains its own slot.
148
+ assertTrue(channel.deliver(null, Result.success("late")))
149
+ val id = nextLaunch()
150
+ assertTrue(channel.deliver(id, Result.success("fresh")))
151
+ assertEquals("fresh", next.await())
152
+ }
153
+
154
+ @Test
155
+ fun cancelled_caller_keeps_its_slot_until_its_result_arrives() = runBlocking {
156
+ val channel = channel()
157
+ val session = channel.openSession()
158
+ val cancelled = request(session)
159
+ val cancelledId = nextLaunch()
160
+ cancelled.cancelAndJoin()
161
+
162
+ val next = request(session)
163
+ assertNoLaunch()
164
+
165
+ assertTrue(channel.deliver(cancelledId, Result.success("late")))
166
+ val id = nextLaunch()
167
+ assertTrue(channel.deliver(id, Result.success("fresh")))
168
+ assertEquals("fresh", next.await())
169
+ }
170
+
171
+ @Test
172
+ fun requests_timing_out_in_the_queue_are_never_launched() = runBlocking {
173
+ val channel = channel(timeoutMillis = 100)
174
+ val session = channel.openSession()
175
+ val first = request(session)
176
+ val firstId = nextLaunch()
177
+ val queued = request(session)
178
+
179
+ assertTrue(failure(queued) is AmberRequestTimeoutException)
180
+ assertTrue(failure(first) is AmberRequestTimeoutException)
181
+ assertTrue(channel.deliver(firstId, Result.success("late")))
182
+ assertNoLaunch()
183
+ }
184
+
185
+ @Test
186
+ fun launch_failure_propagates_and_frees_the_signer() = runBlocking {
187
+ val channel = channel()
188
+ val session = channel.openSession()
189
+ assertThrows(IllegalStateException::class.java) {
190
+ runBlocking {
191
+ session.request { throw IllegalStateException("no activity") }
192
+ }
193
+ }
194
+
195
+ val next = request(session)
196
+ val id = nextLaunch()
197
+ assertTrue(channel.deliver(id, Result.success("ok")))
198
+ assertEquals("ok", next.await())
199
+ }
200
+
201
+ @Test
202
+ fun closing_a_session_fails_its_requests_at_once_and_queued_ones_never_launch() = runBlocking {
203
+ val channel = channel()
204
+ val old = channel.openSession()
205
+ val inFlight = request(old)
206
+ val inFlightId = nextLaunch()
207
+ val queued = request(old)
208
+ assertNoLaunch()
209
+
210
+ old.close()
211
+
212
+ withTimeout(1_000) {
213
+ assertTrue(failure(inFlight) is SignerUnavailableException)
214
+ assertTrue(failure(queued) is SignerUnavailableException)
215
+ }
216
+ assertTrue(failure(request(old)) is SignerUnavailableException)
217
+
218
+ // The old session's Amber activity still drains its own slot.
219
+ assertTrue(channel.deliver(inFlightId, Result.success("late")))
220
+ assertNoLaunch()
221
+ }
222
+
223
+ @Test
224
+ fun session_closed_just_before_launch_never_launches() = runBlocking {
225
+ lateinit var session: AmberRequestChannel.Session
226
+ // newRequestId runs after the request has its turn, right before launch.
227
+ val channel = AmberRequestChannel(5_000) { session.close(); "req-1" }
228
+ session = channel.openSession()
229
+
230
+ assertTrue(failure(request(session)) is SignerUnavailableException)
231
+ assertNoLaunch()
232
+ assertFalse(channel.deliver(null, Result.success("nobody asked")))
233
+ }
234
+
235
+ @Test
236
+ fun replacement_session_waits_for_the_closed_sessions_launch_and_gets_only_its_own_result() = runBlocking {
237
+ val channel = channel()
238
+ val old = channel.openSession()
239
+ val stale = request(old)
240
+ val staleId = nextLaunch()
241
+ old.close()
242
+ assertTrue(failure(stale) is SignerUnavailableException)
243
+
244
+ val replacement = channel.openSession()
245
+ val next = request(replacement)
246
+ assertNoLaunch()
247
+
248
+ // The stale result answers the old launch, not the replacement's request.
249
+ assertTrue(channel.deliver(staleId, Result.success("stale payload")))
250
+ val id = nextLaunch()
251
+ assertFalse(next.isCompleted)
252
+ assertTrue(channel.deliver(id, Result.success("fresh")))
253
+ assertEquals("fresh", next.await())
254
+ }
255
+
256
+ @Test
257
+ fun result_outcome_follows_nip55_result_codes_and_rejection() {
258
+ val extras = mapOf("result" to "sig", "event" to "{}", "id" to "req-1")
259
+
260
+ assertEquals("sig", amberResultOutcome(RESULT_OK, false, extras::get).getOrThrow())
261
+ assertEquals("{}", amberResultOutcome(RESULT_OK, false, mapOf("event" to "{}")::get).getOrThrow())
262
+ assertEquals("", amberResultOutcome(RESULT_OK, false, emptyMap<String, String>()::get).getOrThrow())
263
+ assertEquals("", amberResultOutcome(RESULT_CANCELED, false, extras::get).getOrThrow())
264
+
265
+ val read = mutableListOf<String>()
266
+ val rejected = amberResultOutcome(RESULT_OK, true) { name -> read += name; extras[name] }
267
+ assertTrue(rejected.exceptionOrNull() is AmberRequestRejectedException)
268
+ assertEquals(emptyList<String>(), read)
269
+ }
270
+
271
+ private fun request(
272
+ session: AmberRequestChannel.Session,
273
+ timeout: Boolean = true
274
+ ): Deferred<String> = requests.async {
275
+ val call = suspend { session.request { id -> launched.trySend(id) } }
276
+ if (timeout) session.withRequestTimeout { call() } else call()
277
+ }
278
+
279
+ private suspend fun failure(request: Deferred<String>): Throwable? =
280
+ runCatching { request.await() }.exceptionOrNull()
281
+
282
+ private suspend fun nextLaunch(): String =
283
+ withTimeout(2_000) { launched.receive() }
284
+
285
+ private suspend fun assertNoLaunch() {
286
+ delay(150)
287
+ assertTrue(launched.tryReceive().isFailure)
288
+ }
289
+
290
+ private companion object {
291
+ const val RESULT_OK = -1
292
+ const val RESULT_CANCELED = 0
293
+ }
294
+ }