summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/tests/test_stream.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-cli/tests/test_stream.py')
-rw-r--r--packages/meshbay-cli/tests/test_stream.py285
1 files changed, 285 insertions, 0 deletions
diff --git a/packages/meshbay-cli/tests/test_stream.py b/packages/meshbay-cli/tests/test_stream.py
new file mode 100644
index 0000000..46dc630
--- /dev/null
+++ b/packages/meshbay-cli/tests/test_stream.py
@@ -0,0 +1,285 @@
+"""
+`stream`: a file served on this machine to a media player — byte ranges from
+the node's chunks, read without a lease, only to a request that names this
+address and carries the token; the connection opened again when it dies, and
+the stream stopped by the node's refusal, by Ctrl+C or by closing the player.
+"""
+
+import asyncio
+import json
+import os
+import secrets
+import stat
+import sys
+from urllib.parse import urlsplit
+
+import pytest
+from fake_hub import PASSPHRASE, USERNAME, FakeHub
+from fake_node import FakeNode
+from meshbay_cli import __main__ as cli
+from meshbay_cli.client import relay
+from meshbay_cli.client.relay import byte_range, content_type
+from meshbay_cli.commands import context, stream
+from meshbay_cli.errors import NODE, NOT_FOUND, USAGE, CliError
+
+MiB = 1024 * 1024
+FILM = secrets.token_bytes(3 * MiB + 12345)
+
+
+@pytest.fixture
+def world(monkeypatch, capsys):
+ hub, node = FakeHub(), FakeNode()
+ monkeypatch.setattr(context, "Hub", lambda url: hub)
+ sessions = []
+
+ async def open_group(account, group, **_):
+ sessions.append(node.session())
+ return sessions[-1]
+ monkeypatch.setattr(stream, "open_group", open_group)
+ monkeypatch.setattr(relay, "PING_EVERY", 0.05)
+ monkeypatch.setattr(relay, "RECONNECT_WAITS", (0.01,))
+ node.add("share/films", "Holiday.mkv", FILM)["type"] = "video"
+ node.add("share/docs", "notes.txt", b"hello")
+ assert cli.main(["--hub", hub.url, "-u", USERNAME, "--password", PASSPHRASE,
+ "login"]) == 0
+ capsys.readouterr()
+
+ def start(*argv):
+ args = cli.build_parser().parse_args(list(argv))
+ return asyncio.ensure_future(stream.run(context.Context(args)))
+ return node, sessions, start
+
+
+def node_film(node) -> dict:
+ return next(e for e in node.files.values() if e["name"] == "Holiday.mkv")
+
+
+async def _until(cond, timeout=5.0):
+ deadline = asyncio.get_running_loop().time() + timeout
+ while not cond():
+ assert asyncio.get_running_loop().time() < deadline, "timed out"
+ await asyncio.sleep(0.02)
+
+
+async def _url(task, capsys, said: list | None = None) -> str:
+ """The address printed on stdout; what was said on stderr meanwhile into `said`."""
+ out = []
+
+ def printed():
+ assert not task.done(), task.exception()
+ got = capsys.readouterr()
+ out.extend(got.out.split())
+ if said is not None:
+ said.append(got.err)
+ return out
+ await _until(printed)
+ return out[0]
+
+
+async def http(url: str, *, method="GET", headers=None, host=None, path=None,
+ keep=False, reader_writer=None):
+ """One request; (status, headers, body). With `keep`, the connection is returned
+ for another request."""
+ u = urlsplit(url)
+ reader, writer = reader_writer or await asyncio.open_connection(u.hostname, u.port)
+ lines = [f"{method} {path or u.path} HTTP/1.1", f"Host: {host or u.netloc}",
+ *(f"{k}: {v}" for k, v in (headers or {}).items())]
+ if not keep:
+ lines.append("Connection: close")
+ writer.write(("\r\n".join(lines) + "\r\n\r\n").encode())
+ await writer.drain()
+ head = (await reader.readuntil(b"\r\n\r\n")).decode("latin-1").split("\r\n")
+ status = int(head[0].split(" ")[1])
+ fields = {k.strip().lower(): v.strip() for k, _, v in
+ (line.partition(":") for line in head[1:] if line)}
+ body = b""
+ if method != "HEAD":
+ body = await reader.readexactly(int(fields.get("content-length", 0)))
+ if keep:
+ return status, fields, body, (reader, writer)
+ writer.close()
+ return status, fields, body
+
+
+# ── Ranges and types ─────────────────────────────────────────────────────────
+
+def test_a_range_is_read_as_players_send_it():
+ assert byte_range(None, 1000) is None
+ assert byte_range("bytes=0-", 1000) == (0, 999)
+ assert byte_range("bytes=10-19", 1000) == (10, 19)
+ assert byte_range("bytes=990-5000", 1000) == (990, 999)
+ assert byte_range("bytes=-100", 1000) == (900, 999)
+ assert byte_range("bytes=-5000", 1000) == (0, 999)
+ assert byte_range("bytes=0-1,5-9", 1000) is None # several: the whole file
+ assert byte_range("items=0-9", 1000) is None
+ for bad in ("bytes=1000-", "bytes=20-10", "bytes=x-", "bytes=-0"):
+ with pytest.raises(ValueError):
+ byte_range(bad, 1000)
+
+
+def test_a_file_is_served_as_audio_or_video_and_never_as_a_page():
+ assert content_type("Film.MKV", "video") == "video/x-matroska"
+ assert content_type("a.flac", "audio") == "audio/flac"
+ assert content_type("trap.html", "video") == "video/octet-stream"
+ assert content_type("trap.svg", "audio") == "audio/octet-stream"
+
+
+# ── Serving ──────────────────────────────────────────────────────────────────
+
+async def test_the_player_gets_the_file_and_any_range_of_it(world, capsys):
+ node, sessions, start = world
+ task = start("stream", "Family/share/films/Holiday.mkv")
+ try:
+ said = []
+ url = await _url(task, capsys, said)
+ assert url.startswith("http://127.0.0.1:") and url.endswith("/Holiday.mkv")
+ assert "Ctrl+C to stop" in "".join(said)
+
+ status, fields, body = await http(url)
+ assert status == 200 and body == FILM
+ assert fields["content-type"] == "video/x-matroska"
+ assert fields["accept-ranges"] == "bytes"
+ assert fields["x-content-type-options"] == "nosniff"
+
+ # Across a chunk boundary, as a seek lands.
+ a, b = MiB - 100, 2 * MiB + 50
+ status, fields, body = await http(url, headers={"Range": f"bytes={a}-{b}"})
+ assert status == 206 and body == FILM[a:b + 1]
+ assert fields["content-range"] == f"bytes {a}-{b}/{len(FILM)}"
+
+ # The end of the file, as a player probes for an index there; then the
+ # same connection again.
+ status, _, body, conn = await http(url, headers={"Range": "bytes=-64"}, keep=True)
+ assert status == 206 and body == FILM[-64:]
+ status, _, body, conn = await http(url, headers={"Range": "bytes=5-9"}, keep=True,
+ reader_writer=conn)
+ assert status == 206 and body == FILM[5:10]
+ conn[1].close()
+
+ status, fields, body = await http(url, method="HEAD")
+ assert status == 200 and fields["content-length"] == str(len(FILM)) and body == b""
+ status, fields, _ = await http(url, headers={"Range": f"bytes={len(FILM)}-"})
+ assert status == 416 and fields["content-range"] == f"bytes */{len(FILM)}"
+
+ # Read as a player reads, without a lease; chunks kept are not asked twice.
+ assert node.leases == {} and node.leaseless_reads
+ assert sorted(node.leaseless_reads) == [(node_film(node)["id"], i) for i in range(4)]
+ assert len(sessions) == 1
+ finally:
+ task.cancel()
+ await asyncio.gather(task, return_exceptions=True)
+
+
+async def test_only_this_address_with_its_token_is_answered(world, capsys):
+ node, _, start = world
+ task = start("stream", "Family/share/films/Holiday.mkv")
+ try:
+ url = await _url(task, capsys)
+ port = urlsplit(url).port
+ # A page that rebinds its own name to 127.0.0.1 sends its own Host.
+ status, _, body = await http(url, host=f"evil.example:{port}")
+ assert status == 403 and body == b""
+ status, _, body = await http(url, path="/not-the-token/Holiday.mkv")
+ assert status == 404 and body == b""
+ status, _, _ = await http(url, method="POST")
+ assert status == 405
+ assert node.leaseless_reads == [], "nothing refused reached the node"
+ status, _, body = await http(url, host=f"localhost:{port}",
+ headers={"Range": "bytes=0-3"})
+ assert status == 206 and body == FILM[:4]
+ finally:
+ task.cancel()
+ await asyncio.gather(task, return_exceptions=True)
+
+
+async def test_a_dead_connection_is_opened_again(world, capsys):
+ node, sessions, start = world
+ task = start("stream", "Family/share/films/Holiday.mkv")
+ try:
+ url = await _url(task, capsys)
+ assert (await http(url, headers={"Range": "bytes=0-9"}))[2] == FILM[:10]
+
+ sessions[-1].ch.closed.set() # the link goes down
+ await _until(lambda: len(sessions) == 2)
+ a = 3 * MiB
+ assert (await http(url, headers={"Range": f"bytes={a}-"}))[2] == FILM[a:]
+
+ node.unresponsive = True # it stops answering pings
+ await _until(lambda: len(sessions) == 3)
+ node.unresponsive = False
+ assert "reconnecting" in capsys.readouterr().err
+ finally:
+ task.cancel()
+ await asyncio.gather(task, return_exceptions=True)
+
+
+async def test_a_refusal_by_the_node_stops_the_stream(world, capsys):
+ node, _, start = world
+ node.fail_chunk = (node_film(node)["id"], 2)
+ task = start("stream", "Family/share/films/Holiday.mkv")
+ url = await _url(task, capsys)
+ status, _, _ = await http(url, headers={"Range": f"bytes={2 * MiB}-"})
+ assert status == 502
+ with pytest.raises(CliError) as e:
+ await asyncio.wait_for(task, 5)
+ assert e.value.code == NODE and "drive stopped answering" in e.value.message
+
+
+async def test_what_cannot_be_streamed_is_said(world, capsys):
+ _, _, start = world
+ for argv, code, says in [
+ (["Family/share/docs/notes.txt"], USAGE, "not a video or a sound file"),
+ (["Family/share/films"], USAGE, "is a folder"),
+ (["Family/share/films/Nope.mkv"], NOT_FOUND, "Nothing"),
+ (["Family"], USAGE, "Name the file"),
+ (["--player", "no-such-player-here", "Family/share/films/Holiday.mkv"], USAGE,
+ "was not found"),
+ ]:
+ with pytest.raises(CliError) as e:
+ await start("stream", *argv)
+ assert e.value.code == code and says in e.value.message, argv
+
+
+# ── --player ─────────────────────────────────────────────────────────────────
+
+def _player(tmp_path, body: str) -> str:
+ script = tmp_path / "player"
+ script.write_text(f"#!{sys.executable}\nimport os, sys, time, urllib.request\n{body}\n")
+ script.chmod(script.stat().st_mode | stat.S_IXUSR)
+ return str(script)
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="a script as a program")
+async def test_closing_the_player_stops_the_stream(world, capsys, tmp_path):
+ out = tmp_path / "played"
+ player = _player(tmp_path, (
+ "opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))\n"
+ f"open({str(out)!r}, 'wb').write(opener.open(sys.argv[1]).read())"))
+ _, _, start = world
+ code = await asyncio.wait_for(
+ start("stream", "--player", player, "Family/share/films/Holiday.mkv"), 10)
+ assert code == 0 and out.read_bytes() == FILM
+ assert "player was closed" in capsys.readouterr().err
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="a script as a program")
+async def test_stopping_the_stream_closes_the_player_it_started(world, capsys, tmp_path):
+ pid = tmp_path / "pid"
+ player = _player(tmp_path, f"open({str(pid)!r}, 'w').write(str(os.getpid()))\n"
+ "time.sleep(60)")
+ _, _, start = world
+ task = start("--json", "stream", "--player", player, "Family/share/films/Holiday.mkv")
+ await _until(lambda: pid.exists() and pid.read_text())
+ shown = json.loads(capsys.readouterr().out)
+ assert shown["name"] == "Holiday.mkv" and shown["size"] == len(FILM)
+ task.cancel() # Ctrl+C
+ await asyncio.gather(task, return_exceptions=True)
+
+ def gone():
+ try:
+ os.kill(int(pid.read_text()), 0)
+ except ProcessLookupError:
+ return True
+ with open(f"/proc/{pid.read_text()}/stat") as f:
+ return f.read().split()[2] == "Z" # ended, not yet reaped
+ await _until(gone)