diff options
Diffstat (limited to 'packages/meshbay-node/tests/test_user_blob_store.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_user_blob_store.py | 165 |
1 files changed, 165 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_user_blob_store.py b/packages/meshbay-node/tests/test_user_blob_store.py new file mode 100644 index 0000000..d1bd41e --- /dev/null +++ b/packages/meshbay-node/tests/test_user_blob_store.py @@ -0,0 +1,165 @@ +""" +Per-account blobs on the node — the playlist store (docs/playlists.md §3.3, §8.2). + +The node holds bytes it cannot read, one row per playlist plus a manifest, and +hands them back to the account that wrote them. Three things have to hold, and +only the first is obvious: + + - a blob round-trips through the process, byte for byte, as **bytes** — the + column is a BLOB rather than base64 TEXT because these run to hundreds of + kilobytes and base64 is a third of every write; + - the plaintext is never in the file, which is the whole claim; and + - every cap **refuses** rather than truncating. A truncating cap silently + loses tracks, which is the failure the whole design exists to prevent. +""" + +import pytest +from meshbay_node.bundle_store import BundleStore + + +@pytest.mark.asyncio +async def test_a_blob_round_trips_as_bytes(tmp_path): + store = BundleStore(db_path=tmp_path / "bundles.db") + await store.open() + + # Every byte value, so a text column or an encoding step anywhere in the + # path shows up as a difference rather than surviving by luck. + sealed = bytes(range(256)) * 8 + await store.store_user_blob("u1", "playlists", 3, sealed) + + row = await store.fetch_user_blob("u1", "playlists") + assert row == {"rev": 3, "blob_enc": sealed} + assert isinstance(row["blob_enc"], bytes) + await store.close() + + +@pytest.mark.asyncio +async def test_a_blob_survives_the_process(tmp_path): + """Reopened from disk, not read back out of the same connection.""" + db = tmp_path / "bundles.db" + sealed = b"\x00\x01sealed-body\xff" + + store = BundleStore(db_path=db) + await store.open() + await store.store_user_blob("u1", "playlist:abc-123", 41, sealed) + await store.close() + + again = BundleStore(db_path=db) + await again.open() + assert (await again.fetch_user_blob("u1", "playlist:abc-123"))["blob_enc"] == sealed + await again.close() + + +@pytest.mark.asyncio +async def test_one_playlist_is_one_row(tmp_path): + """The point of the split: rewriting Favourites must not touch the rest.""" + store = BundleStore(db_path=tmp_path / "bundles.db") + await store.open() + + await store.store_user_blob("u1", "playlists", 1, b"manifest") + await store.store_user_blob("u1", "playlist:favorites", 1, b"fav-v1") + await store.store_user_blob("u1", "playlist:evening", 1, b"evening-v1") + + await store.store_user_blob("u1", "playlist:favorites", 2, b"fav-v2") + + assert (await store.fetch_user_blob("u1", "playlist:favorites"))["rev"] == 2 + assert (await store.fetch_user_blob("u1", "playlist:evening"))["blob_enc"] == b"evening-v1" + assert (await store.fetch_user_blob("u1", "playlists"))["blob_enc"] == b"manifest" + await store.close() + + +@pytest.mark.asyncio +async def test_an_unwritten_kind_is_absent_not_an_error(tmp_path): + """"No playlist here yet" is the ordinary state of a fresh node.""" + store = BundleStore(db_path=tmp_path / "bundles.db") + await store.open() + assert await store.fetch_user_blob("u1", "playlists") is None + assert await store.list_user_blobs("u1") == [] + await store.close() + + +@pytest.mark.asyncio +async def test_listing_reports_kinds_and_revisions_and_no_payload(tmp_path): + """What a client that lost its local state needs, and nothing more: the + kinds carry client-generated UUIDs and cannot be guessed.""" + store = BundleStore(db_path=tmp_path / "bundles.db") + await store.open() + await store.store_user_blob("u1", "playlists", 7, b"m") + await store.store_user_blob("u1", "playlist:aaa", 2, b"secret-body") + + listing = await store.list_user_blobs("u1") + assert listing == [{"kind": "playlist:aaa", "rev": 2}, + {"kind": "playlists", "rev": 7}] + assert "blob_enc" not in listing[0] + await store.close() + + +@pytest.mark.asyncio +async def test_one_account_never_sees_another(tmp_path): + """`user_id` comes from the authenticated session; this is what that buys.""" + store = BundleStore(db_path=tmp_path / "bundles.db") + await store.open() + await store.store_user_blob("alice", "playlists", 1, b"alice") + await store.store_user_blob("bob", "playlists", 1, b"bob") + + assert (await store.fetch_user_blob("alice", "playlists"))["blob_enc"] == b"alice" + assert await store.list_user_blobs("bob") == [{"kind": "playlists", "rev": 1}] + + await store.delete_user_blob("alice", "playlists") + assert await store.fetch_user_blob("alice", "playlists") is None + assert await store.fetch_user_blob("bob", "playlists") is not None + await store.close() + + +@pytest.mark.asyncio +async def test_deleting_something_absent_says_so_rather_than_raising(tmp_path): + store = BundleStore(db_path=tmp_path / "bundles.db") + await store.open() + assert await store.delete_user_blob("u1", "playlist:gone") is False + await store.store_user_blob("u1", "playlist:gone", 1, b"x") + assert await store.delete_user_blob("u1", "playlist:gone") is True + await store.close() + + +@pytest.mark.asyncio +async def test_the_account_total_is_what_the_cap_is_checked_against(tmp_path): + """The per-blob caps bound one playlist; only this bounds the account, and + an unbounded write primitive pointed at somebody else's disk needs it.""" + store = BundleStore(db_path=tmp_path / "bundles.db") + await store.open() + assert await store.user_blob_total_bytes("u1") == 0 + + await store.store_user_blob("u1", "playlists", 1, b"x" * 100) + await store.store_user_blob("u1", "playlist:a", 1, b"y" * 250) + assert await store.user_blob_total_bytes("u1") == 350 + + # Replacing a blob replaces its contribution rather than adding to it. + await store.store_user_blob("u1", "playlist:a", 2, b"y" * 50) + assert await store.user_blob_total_bytes("u1") == 150 + + await store.delete_user_blob("u1", "playlist:a") + assert await store.user_blob_total_bytes("u1") == 100 + await store.close() + + +@pytest.mark.asyncio +async def test_the_plaintext_is_not_in_the_file(tmp_path): + """The same check test_chat_key_storage.py makes for epoch keys, for the + same reason: a plaintext column beside the sealed one is the obvious thing + to write and would collapse the whole claim, silently.""" + db = tmp_path / "bundles.db" + store = BundleStore(db_path=db) + await store.open() + await store.store_user_blob( + "u1", "playlist:abc", 1, b"SEALED-CIPHERTEXT-ONLY") + await store.close() + + raw = db.read_bytes() + assert b"SEALED-CIPHERTEXT-ONLY" in raw, ( + "the sealed bytes should be there — this test is only meaningful if it " + "is actually reading the right file") + # What must never be: a track title, an artist, a path. The store is handed + # ciphertext and stores exactly that; anything readable here would mean the + # client sealed nothing or the node unwrapped it. + for leak in (b"Un titre", b"tracks", b"artist", b"favorites"): + assert leak not in raw, f"{leak!r} is in bundles.db in clear" |