diff options
Diffstat (limited to 'packages/meshbay-hub/tests/test_playlist_store.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_playlist_store.py | 155 |
1 files changed, 155 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_playlist_store.py b/packages/meshbay-hub/tests/test_playlist_store.py new file mode 100644 index 0000000..203033c --- /dev/null +++ b/packages/meshbay-hub/tests/test_playlist_store.py @@ -0,0 +1,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 |