aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/tests/test_stream.py
blob: 46dc630ae3091f478fbe3d7b4b91bb35eb7ce617 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
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)