aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-android/app/src/test/kotlin/org/meshbay/client')
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/CastRelayTest.kt153
1 files changed, 153 insertions, 0 deletions
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/CastRelayTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/CastRelayTest.kt
new file mode 100644
index 0000000..9fe1cfd
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/CastRelayTest.kt
@@ -0,0 +1,153 @@
+package org.meshbay.client
+
+import org.json.JSONObject
+import org.junit.After
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.meshbay.client.cast.BoxAccumulator
+import org.meshbay.client.cast.CastRelay
+import java.io.ByteArrayOutputStream
+import java.io.DataInputStream
+import java.net.InetAddress
+import java.net.Socket
+
+/** The real relay on loopback, read with a raw socket: what a receiver sees. */
+class CastRelayTest {
+ private val relay = CastRelay { InetAddress.getByName("127.0.0.1") }
+
+ @After fun stop() = relay.stop()
+
+ private class Reply(val status: Int, val headers: Map<String, String>, val body: ByteArray)
+
+ private fun get(url: String, method: String = "GET", readBytes: Int = -1): Reply {
+ val u = java.net.URI(url)
+ Socket(u.host, u.port).use { s ->
+ s.soTimeout = 5000
+ s.getOutputStream().write("$method ${u.rawPath}${u.rawQuery?.let { "?$it" } ?: ""} HTTP/1.1\r\nHost: x\r\n\r\n".toByteArray())
+ val input = DataInputStream(s.getInputStream())
+ val headLines = ArrayList<String>()
+ val line = StringBuilder()
+ while (true) {
+ val c = input.read()
+ if (c == -1) break
+ if (c == '\n'.code) { val l = line.toString().trimEnd('\r'); if (l.isEmpty()) break; headLines.add(l); line.clear() }
+ else line.append(c.toChar())
+ }
+ val status = headLines[0].split(' ')[1].toInt()
+ val headers = headLines.drop(1).associate { it.substringBefore(':').lowercase() to it.substringAfter(':').trim() }
+ val body = ByteArrayOutputStream()
+ if (headers["transfer-encoding"] == "chunked") {
+ while (readBytes < 0 || body.size() < readBytes) {
+ val sizeLine = StringBuilder()
+ while (true) { val c = input.read(); if (c == -1 || c == '\n'.code) break; sizeLine.append(c.toChar()) }
+ val size = sizeLine.toString().trim().toIntOrNull(16) ?: break
+ if (size == 0) break
+ val buf = ByteArray(size); input.readFully(buf); body.write(buf); input.read(); input.read()
+ }
+ } else {
+ val n = headers["content-length"]?.toInt() ?: 0
+ val buf = ByteArray(n); input.readFully(buf); body.write(buf)
+ }
+ return Reply(status, headers, body.toByteArray())
+ }
+ }
+
+ private fun box(type: String, payload: Int): ByteArray {
+ val size = 8 + payload
+ return byteArrayOf((size ushr 24).toByte(), (size ushr 16).toByte(), (size ushr 8).toByte(), size.toByte()) +
+ type.toByteArray() + ByteArray(payload) { (it % 251).toByte() }
+ }
+
+ private fun fragment(n: Int) = box("moof", 16 + n) + box("mdat", 1000 + n)
+
+ @Test fun `fragments are re-framed from arbitrary slices`() {
+ val acc = BoxAccumulator()
+ val stream = box("ftyp", 12) + fragment(1) + fragment(2) + fragment(3)
+ val out = ArrayList<ByteArray>()
+ var i = 0
+ while (i < stream.size) { val n = minOf(37, stream.size - i); out += acc.push(stream, i, n); i += n }
+ assertEquals(3, out.size)
+ assertArrayEquals(fragment(2), out[1])
+ }
+
+ @Test fun `a lost frame is recovered by rescanning for the next moof`() {
+ val acc = BoxAccumulator()
+ BoxAccumulator.warn = {}
+ val out = acc.push(fragment(1) + byteArrayOf(0, 0, 0, 1, 1, 2, 3, 4) + fragment(2))
+ assertEquals(2, out.size)
+ }
+
+ @Test fun `the stream is init then the backlog then what follows`() {
+ val init = box("ftyp", 20) + box("moov", 50)
+ val started = relay.start(init, null)
+ relay.push(fragment(1)); relay.push(fragment(2))
+ val reply = get(started.getString("url"), readBytes = init.size + fragment(1).size + fragment(2).size)
+ assertEquals(200, reply.status)
+ assertEquals("video/mp4", reply.headers["content-type"])
+ assertEquals("no-store", reply.headers["cache-control"])
+ assertArrayEquals(init + fragment(1) + fragment(2), reply.body)
+ }
+
+ @Test fun `the token is required and unguessable`() {
+ val url = relay.start(null, null).getString("url")
+ val token = url.substringAfter("t=")
+ assertTrue(Regex("^[0-9a-f]{32}$").matches(token))
+ assertEquals(403, get(url.replace(token, "0".repeat(32))).status)
+ assertEquals(403, get(url.substringBefore("?")).status)
+ assertEquals(405, get(url, method = "POST").status)
+ assertEquals(404, get(url.replace("/stream.mp4", "/other")).status)
+ }
+
+ @Test fun `the subtitle is webvtt behind the token, readable cross-origin, re-addressed when it changes`() {
+ val r = relay.start(null, JSONObject().put("vtt", "WEBVTT\n\n00:00.000 --> 00:01.000\nhi\n").put("language", "fr").put("label", "Français"))
+ val sub = r.getJSONObject("subtitle")
+ val reply = get(sub.getString("url"))
+ assertEquals(200, reply.status)
+ assertEquals("text/vtt; charset=utf-8", reply.headers["content-type"])
+ assertEquals("*", reply.headers["access-control-allow-origin"])
+ assertTrue(String(reply.body).startsWith("WEBVTT"))
+ assertEquals(403, get(sub.getString("url").replace(Regex("t=[0-9a-f]+"), "t=x")).status)
+
+ val second = relay.setSubtitle(JSONObject().put("vtt", "WEBVTT\n"))!!
+ assertNotEquals(sub.getString("url"), second.getString("url"))
+ assertNull(relay.setSubtitle(null))
+ assertEquals(404, get(second.getString("url")).status)
+ assertEquals(200, get(r.getString("url"), readBytes = 0).status)
+ }
+
+ @Test fun `the preflight is answered before the token is checked`() {
+ val url = relay.start(null, null).getString("url")
+ val reply = get(url.substringBefore("?"), method = "OPTIONS")
+ assertEquals(204, reply.status)
+ assertEquals("GET, OPTIONS", reply.headers["access-control-allow-methods"])
+ assertTrue(reply.headers["access-control-allow-headers"]!!.contains("Range"))
+ }
+
+ @Test fun `the stream carries the same CORS headers as its subtitle`() {
+ val url = relay.start(null, null).getString("url")
+ val reply = get(url, readBytes = 0)
+ for ((k, v) in CastRelay.CORS_HEADERS) assertEquals(k, v, reply.headers[k.lowercase()])
+ }
+
+ @Test fun `the backlog is bounded in bytes, not only in fragments`() {
+ relay.start(null, null)
+ // 40 fragments of 4 MB: under the fragment cap, far over a phone's heap.
+ val big = box("moof", 16) + box("mdat", 4 * 1024 * 1024)
+ repeat(40) { relay.push(big) }
+ assertTrue("backlog ${relay.backlogBytes()}", relay.backlogBytes() <= CastRelay.RING_MAX_BYTES)
+ assertTrue(relay.backlogBytes() >= CastRelay.RING_MAX_BYTES - big.size)
+ }
+
+ @Test fun `stopping closes the port`() {
+ val url = relay.start(null, null).getString("url")
+ relay.stop()
+ val u = java.net.URI(url)
+ val refused = try { Socket(u.host, u.port).close(); false } catch (e: java.net.ConnectException) { true }
+ assertTrue(refused)
+ assertNull(relay.url)
+ }
+}