aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_playlist_store.py
blob: 203033c01a25273efac6380ea24b990a6d08c085 (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
"""
The playlist store: editing, and putting it on a node.

The rules (`playlist-merge.js`) and the sealing (`playlist-crypto.js`) are pure
and are executed by their own tests. `playlists.js` is neither — it is
IndexedDB, WebCrypto and a transport, and node has no IndexedDB at all — so the
shipped module is driven in Chrome against a node stubbed to record what it was
handed. That stub is also the only way to check the thing that matters most:
what leaves the browser is sealed.

Four properties, and none of them is "it round-trips":

  - a **stale node cannot lower** what this browser holds, because the local
    copy is one of the merge inputs;
  - a **deletion is not resurrected** by a node that still has the playlist;
  - an **edit made on another device arrives**, body and all; and
  - a sync with nothing to do **writes nothing**, because sync rides someone
    else's connection and must not spend it.
"""

import json
import shutil
import subprocess
from pathlib import Path

import pytest

HARNESS = Path(__file__).parent / "harness" / "playlist_store_probe.py"
STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"

pytestmark = pytest.mark.skipif(
    shutil.which("google-chrome") is None or not (STATIC / "playlists.js").exists(),
    reason="Chrome or the SPA sources are not available")


@pytest.fixture(scope="module")
def steps():
    proc = subprocess.run(["python3", str(HARNESS)],
                          capture_output=True, text=True, timeout=300)
    assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
    out = json.loads(proc.stdout)
    assert "error" not in out, out
    assert not out.get("logs"), f"the page logged: {out['logs']}"
    return {s["step"]: s for s in out["steps"]}


# ── editing ──────────────────────────────────────────────────────────────────

def test_favourites_exists_without_having_been_created(steps):
    """Never created by the user, materialised on first use, always first."""
    listed = steps["after editing"]["list"]
    assert listed[0]["id"] == "favorites"
    assert [p["count"] for p in listed] == [1, 2]


def test_a_stored_track_carries_what_the_player_needs_to_fetch_it(steps):
    """`size` and `name` above all: without them the entry cannot be
    downloaded at all, and the failure is invisible until playback."""
    tracks = steps["tracks read back"]["tracks"]
    assert [t["id"] for t in tracks] == ["hash-1", "hash-2"]
    assert [t["size"] for t in tracks] == [1001, 1002]
    assert [t["name"] for t in tracks] == ["1 - Titre.flac", "2 - Titre.flac"]
    assert all(t["groupId"] == "g1" for t in tracks)
    assert [t["title"] for t in tracks] == ["Titre 1", "Titre 2"]


def test_favourites_is_a_toggle_and_an_ordinary_playlist_is_not(steps):
    """Starring something twice leaves one star. An ordinary playlist takes the
    same track twice, because real playlists do."""
    assert steps["favourites is idempotent"]["added"] == 0
    assert steps["favourites is idempotent"]["count"] == 1
    assert steps["an ordinary playlist takes duplicates"]["count"] == 3


def test_a_name_that_differs_only_by_case_or_accent_is_refused(steps):
    """"Soirée" and "soiree" being two playlists is nobody's intention and very
    easy to do by accident."""
    assert steps["a folded duplicate name is refused"]["why"] == "duplicate name"


def test_favourites_cannot_be_deleted(steps):
    """Refused outright rather than special-cased, which is what keeps the
    tombstone rule free of an exception — and a tombstone rule with an
    exception is how the resurrection defect ships."""
    assert steps["favourites cannot be deleted"]["why"] == (
        "favorites cannot be deleted")


# ── what leaves the browser ──────────────────────────────────────────────────

def test_the_node_is_handed_one_blob_per_playlist_plus_a_manifest(steps):
    s = steps["first sync"]
    assert s["result"]["ok"] is True
    assert s["kinds"][0].startswith("playlist:")
    assert "playlist:favorites" in s["kinds"]
    assert "playlists" in s["kinds"]


def test_the_node_gets_ciphertext_and_nothing_else(steps):
    """The claim, checked rather than asserted: the names are in the blob and
    are not readable in it."""
    s = steps["what the node holds"]
    assert s["leaks"] == []
    assert s["names"] == ["Favoris", "Soirée"], (
        "the manifest must still open with the key that sealed it")


def test_a_sync_with_nothing_to_do_writes_nothing(steps):
    """Sync rides a connection opened for something else. The first version of
    this pushed every body on every sync, because it compared against the
    merged watermark rather than against what *that node* actually holds."""
    assert steps["second sync is quiet"]["wrote"] == 0
    assert steps["second sync is quiet"]["result"]["pushed"] == 0


# ── nodes that are behind, ahead, or wrong ───────────────────────────────────

def test_a_stale_node_cannot_lower_the_merged_state(steps):
    """The rollback case. AEAD authenticates a blob; it does not stop a node
    handing back an older one it still has. What does is that the local copy is
    one of the merge inputs, so a stale node can only lose the tie."""
    s = steps["a stale node cannot lower anything"]
    assert s["tracks"] == 3, "a node with an older copy rolled the playlist back"
    assert {p["id"]: p["count"] for p in s["list"]}["favorites"] == 1


def test_an_edit_made_on_another_device_arrives_with_its_tracks(steps):
    """The manifest says a playlist exists that this browser has never seen;
    its body is then fetched because this node is ahead on that kind."""
    s = steps["an edit made elsewhere arrives"]
    assert s["list"] == ["Favoris", "Route", "Soirée"]
    assert s["routeTracks"] == ["Route 1"], "the body never arrived"


def test_a_node_that_still_holds_a_deleted_playlist_does_not_resurrect_it(steps):
    """A node rehomed after three weeks. This is the single most likely defect
    in the design and it looks like a sync working correctly."""
    assert steps["a deletion is not resurrected"]["list"] == ["Favoris", "Route"]


def test_a_deleted_playlists_body_is_reclaimed_from_the_node(steps):
    """The tombstone in the manifest is what has to survive, not the tracks.
    Without this the body of every playlist ever deleted stays on every node
    for ever, and the account's 8 MB quota fills up with graves."""
    assert steps["a deleted body is reclaimed"]["stillThere"] is False


def test_a_session_from_before_the_hkdf_handle_degrades_rather_than_failing(steps):
    """A bundle key loaded out of IndexedDB from before `deriveBundleKeys`
    existed has no HKDF handle, and the passphrase is not in memory to
    re-derive from. Playlists stay local until the next sign-in — reported,
    rather than silently doing nothing."""
    r = steps["a session from before the HKDF handle"]["result"]
    assert r["ok"] is False and r["reason"] == "no_key"
    assert r["pushed"] == 0