""" 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 def test_what_a_playlist_costs_sealed_is_measured_not_quoted(steps): """The ceiling the UI promises comes from here, not from the design doc. §4 measured ~270 bytes a track *raw* and ~60 *sealed*; the raw figure was read for the sealed one and "about 225 tracks" written down, which is five times too strict. So it is measured where it is used. """ sizes = steps["what a playlist costs sealed"]["sizes"] per_track = (sizes["1000"] - sizes["100"]) / 900 assert 30 < per_track < 90, f"a track now costs {per_track:.0f} sealed bytes" assert sizes["1000"] < 62 * 1024, ( f"a thousand ordinary tracks no longer fit in one frame: {sizes['1000']}") def test_the_promised_count_holds_for_a_library_that_never_repeats(steps): """The number in the message has to survive the worst compression, not the friendly one. Everything deflate saves here comes from repetition — the same artist, the same album, the same folder, over and over. A library that never repeats costs nearly twice as much a track, and the two ceilings are 1200 and 660. A reader told to split at 1000 would hit the wall again at 660; the message says 500, and this is what keeps it true. """ diverse = steps["what a playlist costs sealed"]["diverse"] per_track = (diverse["1000"] - diverse["100"]) / 900 assert per_track > ( (steps["what a playlist costs sealed"]["sizes"]["1000"] - steps["what a playlist costs sealed"]["sizes"]["100"]) / 900), ( "the harsh fixture compresses as well as the ordinary one — " "it is not measuring the worst case any more") assert diverse["500"] < 62 * 1024, ( f"500 tracks of wholly unrepeating metadata no longer fit: " f"{diverse['500']} bytes, at {per_track:.0f} a track") def test_a_playlist_too_large_for_a_frame_is_named(steps): # Not "a sync failed": which playlist, so the reader can act. A bare # `catch {}` per body is what let this stop leaving the browser in silence. s = steps["a playlist too large for one frame"] assert s["result"]["tooLarge"] == ["Trop longue"] kinds = [e["kind"] for e in s["stored"]] assert s["bigKind"] not in kinds, "the oversized body was handed to send() anyway" def test_one_oversized_playlist_does_not_break_the_rest_of_the_sync(steps): s = steps["a playlist too large for one frame"] assert s["result"]["ok"] is True assert s["result"]["failed"] == [] kinds = [e["kind"] for e in s["stored"]] # The manifest last, and the other bodies before it: one playlist that # cannot be sent must not hold back the four that can. assert "playlists" in kinds assert len([k for k in kinds if k.startswith("playlist:")]) >= 4