summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_user_blob_store.py
blob: d1bd41e360c2ba728a5adee40009336cdd247c95 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
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"