react-native-audio-browser 0.2.0-next.10 → 0.2.0-next.11

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.
@@ -1,19 +1,32 @@
1
1
  package com.audiobrowser.browser
2
2
 
3
+ import com.audiobrowser.TestFixtures.imageRowItem
4
+ import com.audiobrowser.TestFixtures.resolvedTrack
5
+ import com.audiobrowser.TestFixtures.staticRoute
6
+ import com.audiobrowser.TestFixtures.track
7
+ import com.audiobrowser.util.BrowserPathHelper
3
8
  import kotlinx.coroutines.runBlocking
4
- import org.junit.Assert.*
9
+ import org.junit.Assert.assertEquals
10
+ import org.junit.Assert.assertNull
11
+ import org.junit.Assert.fail
5
12
  import org.junit.Before
6
13
  import org.junit.Test
7
14
  import org.junit.runner.RunWith
8
15
  import org.robolectric.RobolectricTestRunner
9
16
 
10
- // TODO: Most tests disabled — BrowserConfig API changed (routes is now Array<NativeRouteEntry>).
11
- // When revived, add expandQueueFromContextualUrl glue tests: found id → its
12
- // SectionScope section as the queue; vanished id → null (single-track
13
- // fallback). The section logic and the caller fallback are covered
14
- // (SectionScopeTest, TrackSelectorTests); the wiring between them is not.
15
- // Tests that used BrowserSource/BrowserList need to be rewritten.
16
-
17
+ /**
18
+ * Navigation and queue-expansion glue.
19
+ *
20
+ * [BrowserManager.expandQueueFromContextualUrl] is the seam between the page a listener tapped in
21
+ * and the queue that starts playing. Its two halves are covered on their own — the section logic in
22
+ * [SectionScopeTest], the caller's single-track fallback in the player tests — but the wiring
23
+ * between them is what decides whether tapping row 3 of a page resumes the right station, and only
24
+ * these tests exercise it.
25
+ *
26
+ * Routes serve `browseStatic`, the one browse arm that needs neither the JNI bridge nor HTTP.
27
+ * Robolectric is required for `android.net.Uri` (contextual-URL parsing) and
28
+ * `android.util.LruCache` (the content cache).
29
+ */
17
30
  @RunWith(RobolectricTestRunner::class)
18
31
  class BrowserManagerTest {
19
32
 
@@ -24,6 +37,22 @@ class BrowserManagerTest {
24
37
  browserManager = BrowserManager()
25
38
  }
26
39
 
40
+ /** Configures a single static route at [path] serving [children] as its page. */
41
+ private fun servePage(
42
+ path: String,
43
+ vararg children: com.margelo.nitro.audiobrowser.Track,
44
+ singleTrack: Boolean = false,
45
+ ) {
46
+ browserManager.config =
47
+ BrowserConfig(
48
+ routes =
49
+ arrayOf(staticRoute(path, resolvedTrack(url = path, children = arrayOf(*children)))),
50
+ singleTrack = singleTrack,
51
+ )
52
+ }
53
+
54
+ private fun contextual(path: String, trackId: String) = BrowserPathHelper.build(path, trackId)
55
+
27
56
  @Test
28
57
  fun `getPath returns default path initially`() {
29
58
  assertEquals("/", browserManager.getPath())
@@ -64,4 +93,134 @@ class BrowserManagerTest {
64
93
  } catch (_: ContentNotFoundException) {}
65
94
  }
66
95
  }
96
+
97
+ @Test
98
+ fun `navigate serves a static route and gives playable children contextual urls`() = runBlocking {
99
+ servePage("/library", track(src = "a", title = "A"), track(src = "b", title = "B"))
100
+
101
+ val resolved = browserManager.navigate("/library")
102
+
103
+ assertEquals(listOf("A", "B"), resolved.children?.map { it.title })
104
+ // The tap target: a child's url is rewritten to point back at this page plus its own id.
105
+ assertEquals(
106
+ listOf(contextual("/library", "a"), contextual("/library", "b")),
107
+ resolved.children?.map { it.url },
108
+ )
109
+ assertEquals("/library", browserManager.getPath())
110
+ }
111
+
112
+ // MARK: - expandQueueFromContextualUrl
113
+
114
+ @Test
115
+ fun `expandQueueFromContextualUrl scopes the queue to the tapped section`() = runBlocking {
116
+ servePage(
117
+ "/home",
118
+ track(src = "a", groupTitle = "Recent"),
119
+ track(src = "b", groupTitle = "Recent"),
120
+ track(src = "c", groupTitle = "Popular"),
121
+ track(src = "d", groupTitle = "Popular"),
122
+ )
123
+
124
+ val expanded = browserManager.expandQueueFromContextualUrl(contextual("/home", "c"))
125
+
126
+ // "Popular" only — a page aggregating sections must not leak next/previous across them.
127
+ val (queue, index) = requireNotNull(expanded)
128
+ assertEquals(listOf("c", "d"), queue.map { it.src })
129
+ assertEquals(0, index)
130
+ }
131
+
132
+ @Test
133
+ fun `expandQueueFromContextualUrl indexes the tapped track within its section`() = runBlocking {
134
+ servePage(
135
+ "/home",
136
+ track(src = "a", groupTitle = "Recent"),
137
+ track(src = "b", groupTitle = "Popular"),
138
+ track(src = "c", groupTitle = "Popular"),
139
+ )
140
+
141
+ val (queue, index) =
142
+ requireNotNull(browserManager.expandQueueFromContextualUrl(contextual("/home", "c")))
143
+
144
+ assertEquals(listOf("b", "c"), queue.map { it.src })
145
+ assertEquals(1, index)
146
+ }
147
+
148
+ @Test
149
+ fun `expandQueueFromContextualUrl returns null when the id has vanished from the page`() =
150
+ runBlocking {
151
+ // The stored url points at a track the page no longer lists — the page changed under a
152
+ // resumed session. Queueing the current list would resume the wrong station, so expansion
153
+ // aborts and the caller falls back to the stored single track.
154
+ servePage("/home", track(src = "a"), track(src = "b"))
155
+
156
+ assertNull(browserManager.expandQueueFromContextualUrl(contextual("/home", "gone")))
157
+ }
158
+
159
+ @Test
160
+ fun `expandQueueFromContextualUrl returns null for a non-contextual url`() = runBlocking {
161
+ servePage("/home", track(src = "a"))
162
+
163
+ assertNull(browserManager.expandQueueFromContextualUrl("/home"))
164
+ }
165
+
166
+ @Test
167
+ fun `expandQueueFromContextualUrl returns null when the page has no children`() = runBlocking {
168
+ browserManager.config =
169
+ BrowserConfig(routes = arrayOf(staticRoute("/home", resolvedTrack(url = "/home"))))
170
+
171
+ assertNull(browserManager.expandQueueFromContextualUrl(contextual("/home", "a")))
172
+ }
173
+
174
+ @Test
175
+ fun `expandQueueFromContextualUrl expands an image row into its items`() = runBlocking {
176
+ servePage(
177
+ "/home",
178
+ track(
179
+ title = "Most Played",
180
+ src = null,
181
+ imageRow = arrayOf(imageRowItem("r1"), imageRowItem("r2")),
182
+ ),
183
+ track(src = "a"),
184
+ )
185
+
186
+ val (queue, index) =
187
+ requireNotNull(browserManager.expandQueueFromContextualUrl(contextual("/home", "r2")))
188
+
189
+ // A tile tap queues its row, not the list rows beside it.
190
+ assertEquals(listOf("r1", "r2"), queue.map { it.src })
191
+ assertEquals(1, index)
192
+ }
193
+
194
+ @Test
195
+ fun `expandQueueFromContextualUrl drops unplayable siblings`() = runBlocking {
196
+ servePage(
197
+ "/home",
198
+ track(src = "a", groupTitle = "Mixed"),
199
+ // A browsable row sitting inside the same section — no src, so not queueable.
200
+ track(src = null, groupTitle = "Mixed").copy(url = "/sub"),
201
+ track(src = "b", groupTitle = "Mixed"),
202
+ )
203
+
204
+ val (queue, index) =
205
+ requireNotNull(browserManager.expandQueueFromContextualUrl(contextual("/home", "b")))
206
+
207
+ assertEquals(listOf("a", "b"), queue.map { it.src })
208
+ assertEquals(1, index)
209
+ }
210
+
211
+ @Test
212
+ fun `expandQueueFromContextualUrl honours singleTrack`() = runBlocking {
213
+ servePage(
214
+ "/home",
215
+ track(src = "a", groupTitle = "Recent"),
216
+ track(src = "b", groupTitle = "Recent"),
217
+ singleTrack = true,
218
+ )
219
+
220
+ val (queue, index) =
221
+ requireNotNull(browserManager.expandQueueFromContextualUrl(contextual("/home", "b")))
222
+
223
+ assertEquals(listOf("b"), queue.map { it.src })
224
+ assertEquals(0, index)
225
+ }
67
226
  }
@@ -7,6 +7,7 @@ import java.security.cert.PKIXParameters
7
7
  import java.security.cert.TrustAnchor
8
8
  import java.security.cert.X509Certificate
9
9
  import java.util.Date
10
+ import javax.security.auth.x500.X500Principal
10
11
  import org.junit.Assert.assertEquals
11
12
  import org.junit.Assert.assertThrows
12
13
  import org.junit.Test
@@ -15,12 +16,51 @@ import org.mockito.Mockito.`when`
15
16
 
16
17
  class AiaCertChaserTest {
17
18
 
18
- /** A certificate whose AIA extension value is exactly [extensionValue]. */
19
+ /**
20
+ * A certificate whose AIA extension value is exactly [extensionValue].
21
+ *
22
+ * Subject and issuer are stubbed to distinct principals on purpose: an unstubbed mock answers
23
+ * null to both, which `completeChain` would read as a self-signed root and stop on before
24
+ * fetching anything.
25
+ */
19
26
  private fun certWithAia(extensionValue: ByteArray): X509Certificate =
20
27
  mock(X509Certificate::class.java).also {
21
28
  `when`(it.getExtensionValue("1.3.6.1.5.5.7.1.1")).thenReturn(extensionValue)
29
+ `when`(it.subjectX500Principal).thenReturn(X500Principal("CN=leaf"))
30
+ `when`(it.issuerX500Principal).thenReturn(X500Principal("CN=issuer"))
22
31
  }
23
32
 
33
+ /** A DER tag-length-value, with the length in whichever form fits. */
34
+ private fun der(tag: Int, content: ByteArray): ByteArray {
35
+ val length =
36
+ when (val n = content.size) {
37
+ in 0..0x7F -> byteArrayOf(n.toByte())
38
+ in 0x80..0xFF -> byteArrayOf(0x81.toByte(), n.toByte())
39
+ in 0x100..0xFFFF -> byteArrayOf(0x82.toByte(), (n shr 8).toByte(), n.toByte())
40
+ else -> byteArrayOf(0x83.toByte(), (n shr 16).toByte(), (n shr 8).toByte(), n.toByte())
41
+ }
42
+ return byteArrayOf(tag.toByte()) + length + content
43
+ }
44
+
45
+ /** A well-formed AIA extension value listing [urls] as caIssuers locations. */
46
+ private fun aiaExtension(urls: List<String>): ByteArray {
47
+ val caIssuersOid = byteArrayOf(0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x02)
48
+ val descriptions =
49
+ urls.fold(ByteArray(0)) { acc, url ->
50
+ acc + der(0x30, der(0x06, caIssuersOid) + der(0x86, url.toByteArray(Charsets.US_ASCII)))
51
+ }
52
+ return der(0x04, der(0x30, descriptions))
53
+ }
54
+
55
+ @Test
56
+ fun `the AIA fixture builder round-trips through the parser`() {
57
+ // Guards the two tests below: if this builder were malformed they would pass by parsing
58
+ // nothing at all, rather than by the cap doing its job.
59
+ val urls = listOf("http://ca1.example/i.crt", "http://ca2.example/i.crt")
60
+
61
+ assertEquals(urls, AiaCertChaser.extractCaIssuerUrls(certWithAia(aiaExtension(urls))))
62
+ }
63
+
24
64
  @Test(timeout = 5_000)
25
65
  fun `a negative long-form length does not hang the parser`() {
26
66
  // OCTET STRING { SEQUENCE { tag 0x00, long-form length 0xFFFFFFFA = -6 } }.
@@ -73,7 +113,7 @@ class AiaCertChaserTest {
73
113
  val leaf = CertFixtures.cert("stationplaylist-leaf.pem")
74
114
  val r13 = CertFixtures.cert("letsencrypt-r13.pem")
75
115
  // Fetcher knows only the R13 URL (the leaf's AIA pointer); empty for the root URL.
76
- val fetch = { url: String ->
116
+ val fetch = { url: String, _: Long ->
77
117
  if (url == "http://r13.i.lencr.org/") listOf(r13) else emptyList()
78
118
  }
79
119
 
@@ -90,8 +130,8 @@ class AiaCertChaserTest {
90
130
 
91
131
  // A PKCS#7-style bundle where the genuine issuer is not first must still be selected.
92
132
  val completed =
93
- AiaCertChaser.completeChain(listOf(leaf)) {
94
- if (it == "http://r13.i.lencr.org/") listOf(unrelated, r13) else emptyList()
133
+ AiaCertChaser.completeChain(listOf(leaf)) { url, _ ->
134
+ if (url == "http://r13.i.lencr.org/") listOf(unrelated, r13) else emptyList()
95
135
  }
96
136
 
97
137
  assertEquals(listOf(leaf, r13), completed)
@@ -103,7 +143,7 @@ class AiaCertChaserTest {
103
143
  var fetches = 0
104
144
 
105
145
  val completed =
106
- AiaCertChaser.completeChain(listOf(root)) {
146
+ AiaCertChaser.completeChain(listOf(root)) { _, _ ->
107
147
  fetches++
108
148
  emptyList()
109
149
  }
@@ -112,11 +152,115 @@ class AiaCertChaserTest {
112
152
  assertEquals(0, fetches)
113
153
  }
114
154
 
155
+ @Test
156
+ fun `completeChain stops at a chain that already reaches a trust anchor`() {
157
+ val leaf = CertFixtures.cert("stationplaylist-leaf.pem")
158
+ val r13 = CertFixtures.cert("letsencrypt-r13.pem")
159
+ val root = CertFixtures.cert("isrg-root-x1.pem")
160
+ val fetched = mutableListOf<String>()
161
+
162
+ // R13's own AIA points at the ISRG root, so without knowing the anchors this walks one hop
163
+ // too far and pays for a second blocking fetch on the ordinary rescue path.
164
+ val completed =
165
+ AiaCertChaser.completeChain(
166
+ listOf(leaf),
167
+ anchorSubjects = setOf(root.subjectX500Principal),
168
+ ) { url, _ ->
169
+ fetched.add(url)
170
+ if (url == "http://r13.i.lencr.org/") listOf(r13) else listOf(root)
171
+ }
172
+
173
+ assertEquals(listOf(leaf, r13), completed)
174
+ assertEquals(listOf("http://r13.i.lencr.org/"), fetched)
175
+ }
176
+
177
+ @Test
178
+ fun `completeChain fetches nothing when the presented chain is already anchored`() {
179
+ val leaf = CertFixtures.cert("stationplaylist-leaf.pem")
180
+ val r13 = CertFixtures.cert("letsencrypt-r13.pem")
181
+ val root = CertFixtures.cert("isrg-root-x1.pem")
182
+ var fetches = 0
183
+
184
+ // The shape of every non-path failure — expiry, hostname mismatch, CT — where the server
185
+ // sent a complete chain and validation failed for some other reason.
186
+ val completed =
187
+ AiaCertChaser.completeChain(
188
+ listOf(leaf, r13),
189
+ anchorSubjects = setOf(root.subjectX500Principal),
190
+ ) { _, _ ->
191
+ fetches++
192
+ emptyList()
193
+ }
194
+
195
+ assertEquals(listOf(leaf, r13), completed)
196
+ assertEquals(0, fetches)
197
+ }
198
+
199
+ @Test
200
+ fun `completeChain tries only a few of a certificate's CA-Issuers URLs`() {
201
+ // The extension is authored by the server that just failed validation, and nothing limits
202
+ // how many URLs it lists. Each one costs a blocking connect on the handshake thread, so
203
+ // hundreds of them are a denial of service whatever each individual fetch costs.
204
+ val manyUrls = (1..500).map { "http://ca$it.example/issuer.crt" }
205
+ val cert = certWithAia(aiaExtension(manyUrls))
206
+ val fetched = mutableListOf<String>()
207
+
208
+ AiaCertChaser.completeChain(listOf(cert)) { url, _ ->
209
+ fetched.add(url)
210
+ emptyList()
211
+ }
212
+
213
+ assertEquals(AiaCertChaser.MAX_URLS_PER_CERT, fetched.size)
214
+ }
215
+
216
+ @Test
217
+ fun `duplicate CA-Issuers URLs do not consume the URL cap`() {
218
+ // 500 copies of one dead URL followed by a distinct one: the cap must buy distinct hosts,
219
+ // not three visits to the same dead one.
220
+ val urls = List(500) { "http://ca.example/issuer.crt" } + "http://ca-b.example/issuer.crt"
221
+ val cert = certWithAia(aiaExtension(urls))
222
+ val fetched = mutableListOf<String>()
223
+
224
+ AiaCertChaser.completeChain(listOf(cert)) { url, _ ->
225
+ fetched.add(url)
226
+ emptyList()
227
+ }
228
+
229
+ assertEquals(listOf("http://ca.example/issuer.crt", "http://ca-b.example/issuer.crt"), fetched)
230
+ }
231
+
232
+ @Test
233
+ fun `completeChain stops fetching once its wall-clock budget is spent`() {
234
+ val cert = certWithAia(aiaExtension((1..500).map { "http://ca$it.example/issuer.crt" }))
235
+ var fetches = 0
236
+ var nanos = 0L
237
+ val deadlines = mutableListOf<Long>()
238
+
239
+ // Each fetch "takes" 8s against a 20s budget, so the walk must stop after a handful even
240
+ // though the URL cap alone would have allowed more.
241
+ AiaCertChaser.completeChain(
242
+ listOf(cert),
243
+ maxUrlsPerCert = 500,
244
+ budgetMs = 20_000,
245
+ nowNanos = { nanos },
246
+ ) { _, deadline ->
247
+ fetches++
248
+ deadlines.add(deadline)
249
+ nanos += 8_000_000_000
250
+ emptyList()
251
+ }
252
+
253
+ assertEquals(3, fetches)
254
+ // Every fetch is handed the same walk-wide deadline, so it can clip its own I/O to it —
255
+ // the budget bounds the fetches themselves, not just how many of them start.
256
+ assertEquals(listOf(20_000_000_000L, 20_000_000_000L, 20_000_000_000L), deadlines)
257
+ }
258
+
115
259
  @Test
116
260
  fun `completeChain stops when the issuer cannot be fetched`() {
117
261
  val leaf = CertFixtures.cert("stationplaylist-leaf.pem")
118
262
 
119
- val completed = AiaCertChaser.completeChain(listOf(leaf)) { emptyList() }
263
+ val completed = AiaCertChaser.completeChain(listOf(leaf)) { _, _ -> emptyList() }
120
264
 
121
265
  assertEquals(listOf(leaf), completed)
122
266
  }
@@ -134,8 +278,8 @@ class AiaCertChaserTest {
134
278
 
135
279
  // After AIA chasing splices in R13, the chain validates to the trusted root.
136
280
  val completed =
137
- AiaCertChaser.completeChain(listOf(leaf)) {
138
- if (it == "http://r13.i.lencr.org/") listOf(r13) else emptyList()
281
+ AiaCertChaser.completeChain(listOf(leaf)) { url, _ ->
282
+ if (url == "http://r13.i.lencr.org/") listOf(r13) else emptyList()
139
283
  }
140
284
  validatePath(completed, root, date) // must not throw
141
285
  }