""" 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