diff options
Diffstat (limited to 'packages/meshbay-android/app/src/test/kotlin/org/meshbay')
| -rw-r--r-- | packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/CastRelayTest.kt | 153 |
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) + } +} |