aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_playlist_store.py
blob: 59afaee6da6b692f579a94feae754123e1e8f179 (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
"""
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


# ── every write leaves the browser ───────────────────────────────────────────
#
# The defect these exist for, reported from a phone: signing in with the same
# account showed no playlists at all. `syncWith` was called from exactly one
# place in the interface — after adding tracks from a cover — so creating a
# playlist, deleting one, removing a track and saving the queue all wrote to
# IndexedDB and stopped there.
#
# Nothing caught it because every test drove `syncWith` directly. The node's own
# audit log did: two events, ever. The store pushes itself now, so a new
# mutation cannot forget to.

@pytest.mark.parametrize("step, expect_body", [
    ("create pushes", True),
    ("add pushes", True),
    ("remove pushes", True),
    ("save-queue pushes", True),
    ("delete pushes", False),
])
def test_every_mutation_reaches_the_node(steps, step, expect_body):
    kinds = steps[step]["kinds"]
    assert "playlists" in kinds, f"{step}: the manifest never left the browser"
    bodies = [k for k in kinds if k.startswith("playlist:")]
    if expect_body:
        assert bodies, f"{step}: no playlist body was pushed"
    else:
        assert not bodies, f"{step}: a deletion pushed a body"


def test_deleting_a_playlist_takes_its_body_off_the_node(steps):
    assert steps["delete pushes"]["bodyGone"] is True


def test_a_burst_of_writes_is_one_push(steps):
    """Five stars in a row is one manifest and one body, not five of each. The
    push rides a connection borrowed from something else and must not spend it
    once per keystroke."""
    assert steps["a burst is coalesced"]["writes"] == 2


def test_a_device_that_has_nothing_fetches_once(steps):
    """The report, exactly: a phone signing in with the same account.

    §7 said "on sign-in, nothing", on the grounds that the local copy is
    authoritative and complete — true of a device that has been used before and
    false of a new one, which is the case the whole feature exists for. Nothing
    went looking until a group's Music tab happened to be opened.
    """
    s = steps["a fresh device pulls once"]
    assert s["before"] > 0 and s["emptied"] == 0, (
        "the fixture did not actually become a fresh device")
    assert s["result"]["ok"] is True
    assert s["result"]["pulled"] > 0
    assert s["lists"], "a fresh device found no playlists — this is the bug"
    assert "Depuis le menu" in s["lists"]


# ── a push that does not land the first time ─────────────────────────────────

def test_a_failed_push_is_retried(steps):
    """A node that is busy, or a connection that drops between the write and
    the push, used to mean waiting for the next Music tab to be opened. One
    retry catches the common case: the node came back."""
    s = steps["a failed push is retried once"]
    assert s["afterFirstTry"] == 0, "the fixture's node did not actually refuse"
    assert s["firstReason"], "the first failure recorded no reason"
    assert s["afterRetry"] > 0, "the push was never retried"
    assert s["ok"] is True


def test_the_retry_does_not_loop(steps):
    """Exactly one. A node that is off tends to stay off, and a push that keeps
    looping spends a phone's battery on a node that is not coming back — while
    the local copy is already correct and the next mutation carries it.

    Counted as sync *passes*, not writes: one pass writes a body per playlist
    plus the manifest, so counting writes would say nothing about how many
    times the push was attempted.
    """
    assert steps["a retry does not loop"]["passes"] == 2, (
        "one attempt plus one retry, and no more")


def test_closing_the_page_sends_what_is_pending(steps):
    """A push waits a second and a half to coalesce, and closing a laptop
    inside that window is not rare. `pagehide` and a hidden tab flush it."""
    s = steps["flush sends a pending push"]
    assert s["beforeFlush"] == 0
    assert s["afterFlush"] > 0


def test_a_device_that_already_has_playlists_still_pulls_at_sign_in(steps):
    """The correction to the correction.

    Bounding the sign-in pull to an empty device left out the ordinary case: a
    phone holding nine playlists and missing the tenth would not have gone
    looking either, and would have waited for a group's Music tab to be opened
    — which is not where anybody looks for a playlist.
    """
    s = steps["a device that already has playlists pulls too"]
    assert s["held"] > 0, "the fixture device was empty, so this checks nothing"
    assert s["result"]["ok"] is True
    assert s["result"]["pulled"] > 0
    assert "Faite ailleurs" in s["found"]


def test_a_node_holding_something_unreadable_does_not_wedge_the_sync(steps):
    """The defect behind the report from a phone.

    `open()` throwing on the node's manifest was treated as a *fetch* failure
    and returned before the push. The first write landed because the node was
    empty, so nothing was fetched; every sync after it took that path and
    pushed nothing, for ever, while the interface showed the playlists happily
    from IndexedDB. The node's audit log was the only thing that said so.

    A blob this account cannot open with this passphrase is not an older copy
    of anything. It is an absence — the client is the authority (§6.4) — and it
    gets overwritten.
    """
    s = steps["an unreadable manifest does not wedge the sync"]
    assert s["result"]["ok"] is True
    assert s["result"]["unreadable"] is True, "the failure was not even noticed"
    assert s["result"]["pushed"] > 0, "the sync returned without pushing again"
    assert s["overwritten"] is True, "the unreadable blob is still there"
    assert "Depuis le menu" in s["names"]


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