aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_user_blob_store.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-16 11:17:46 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-16 11:17:46 +0200
commitcde57423e04812fe2c939fdf983e3b45a77fd82d (patch)
tree7fe44bc9d83cf72382394ebf9b5177c207618be2 /packages/meshbay-node/tests/test_user_blob_store.py
parent20706fb9a4ec646816b44a10842aa8f58ea0fd75 (diff)
downloadmeshbay-cde57423e04812fe2c939fdf983e3b45a77fd82d.tar.gz
mnp 3.1: per-account blobs the node cannot read
One row per playlist plus a manifest, so starring a track rewrites that playlist rather than the whole collection. blob_enc is a BLOB, not base64 TEXT: these run to hundreds of kilobytes. user_id comes from the session and never from the message; kind is validated against a pattern; every cap refuses with a stated reason rather than truncating. Additive, so MNP_MIN_SUPPORTED does not move — a 3.0 node answers "unknown message type" and the client writes to the next one it reaches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/tests/test_user_blob_store.py')
-rw-r--r--packages/meshbay-node/tests/test_user_blob_store.py165
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"