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.
- package/README.md +1 -1
- package/android-template/app/build.gradle.kts +1 -0
- package/android-template/app/src/androidTest/java/com/pakstr/app/PakstrWebBridgeTest.kt +153 -0
- package/android-template/app/src/main/assets/nip07.js +124 -74
- package/android-template/app/src/main/java/com/pakstr/app/AppOrigin.kt +77 -0
- package/android-template/app/src/main/java/com/pakstr/app/PakstrBridge.kt +5 -5
- package/android-template/app/src/main/java/com/pakstr/app/PakstrWebBridge.kt +89 -0
- package/android-template/app/src/main/java/com/pakstr/app/WebMessageRouter.kt +186 -0
- package/android-template/app/src/main/java/com/pakstr/app/WebViewController.kt +65 -49
- package/android-template/app/src/main/java/com/pakstr/app/signer/AmberRequestChannel.kt +156 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/AmberSigner.kt +142 -390
- package/android-template/app/src/main/java/com/pakstr/app/signer/NostrBridge.kt +69 -489
- package/android-template/app/src/main/java/com/pakstr/app/signer/SignerManager.kt +13 -2
- package/android-template/app/src/test/java/com/pakstr/app/AppOriginTest.kt +114 -0
- package/android-template/app/src/test/java/com/pakstr/app/WebMessageRouterTest.kt +279 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/AmberRequestChannelTest.kt +294 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/NostrBridgeDispatchTest.kt +113 -0
- package/android-template/gradle/libs.versions.toml +2 -0
- 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
|
+
}
|