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
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
|
"""
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
# ── every write leaves the browser ───────────────────────────────────────────
#
# The defect these exist for, reported from a phone: signing in with the same
# account showed no playlists at all. `syncWith` was called from exactly one
# place in the interface — after adding tracks from a cover — so creating a
# playlist, deleting one, removing a track and saving the queue all wrote to
# IndexedDB and stopped there.
#
# Nothing caught it because every test drove `syncWith` directly. The node's own
# audit log did: two events, ever. The store pushes itself now, so a new
# mutation cannot forget to.
@pytest.mark.parametrize("step, expect_body", [
("create pushes", True),
("add pushes", True),
("remove pushes", True),
("save-queue pushes", True),
("delete pushes", False),
])
def test_every_mutation_reaches_the_node(steps, step, expect_body):
kinds = steps[step]["kinds"]
assert "playlists" in kinds, f"{step}: the manifest never left the browser"
bodies = [k for k in kinds if k.startswith("playlist:")]
if expect_body:
assert bodies, f"{step}: no playlist body was pushed"
else:
assert not bodies, f"{step}: a deletion pushed a body"
def test_deleting_a_playlist_takes_its_body_off_the_node(steps):
assert steps["delete pushes"]["bodyGone"] is True
def test_a_burst_of_writes_is_one_push(steps):
"""Five stars in a row is one manifest and one body, not five of each. The
push rides a connection borrowed from something else and must not spend it
once per keystroke."""
assert steps["a burst is coalesced"]["writes"] == 2
def test_a_device_that_has_nothing_fetches_once(steps):
"""The report, exactly: a phone signing in with the same account.
§7 said "on sign-in, nothing", on the grounds that the local copy is
authoritative and complete — true of a device that has been used before and
false of a new one, which is the case the whole feature exists for. Nothing
went looking until a group's Music tab happened to be opened.
"""
s = steps["a fresh device pulls once"]
assert s["before"] > 0 and s["emptied"] == 0, (
"the fixture did not actually become a fresh device")
assert s["result"]["ok"] is True
assert s["result"]["pulled"] > 0
assert s["lists"], "a fresh device found no playlists — this is the bug"
assert "Depuis le menu" in s["lists"]
# ── a push that does not land the first time ─────────────────────────────────
def test_a_failed_push_is_retried(steps):
"""A node that is busy, or a connection that drops between the write and
the push, used to mean waiting for the next Music tab to be opened. One
retry catches the common case: the node came back."""
s = steps["a failed push is retried once"]
assert s["afterFirstTry"] == 0, "the fixture's node did not actually refuse"
assert s["firstReason"], "the first failure recorded no reason"
assert s["afterRetry"] > 0, "the push was never retried"
assert s["ok"] is True
def test_the_retry_does_not_loop(steps):
"""Exactly one. A node that is off tends to stay off, and a push that keeps
looping spends a phone's battery on a node that is not coming back — while
the local copy is already correct and the next mutation carries it.
Counted as sync *passes*, not writes: one pass writes a body per playlist
plus the manifest, so counting writes would say nothing about how many
times the push was attempted.
"""
assert steps["a retry does not loop"]["passes"] == 2, (
"one attempt plus one retry, and no more")
def test_closing_the_page_sends_what_is_pending(steps):
"""A push waits a second and a half to coalesce, and closing a laptop
inside that window is not rare. `pagehide` and a hidden tab flush it."""
s = steps["flush sends a pending push"]
assert s["beforeFlush"] == 0
assert s["afterFlush"] > 0
def test_a_device_that_already_has_playlists_still_pulls_at_sign_in(steps):
"""The correction to the correction.
Bounding the sign-in pull to an empty device left out the ordinary case: a
phone holding nine playlists and missing the tenth would not have gone
looking either, and would have waited for a group's Music tab to be opened
— which is not where anybody looks for a playlist.
"""
s = steps["a device that already has playlists pulls too"]
assert s["held"] > 0, "the fixture device was empty, so this checks nothing"
assert s["result"]["ok"] is True
assert s["result"]["pulled"] > 0
assert "Faite ailleurs" in s["found"]
def test_a_node_holding_something_unreadable_does_not_wedge_the_sync(steps):
"""The defect behind the report from a phone.
`open()` throwing on the node's manifest was treated as a *fetch* failure
and returned before the push. The first write landed because the node was
empty, so nothing was fetched; every sync after it took that path and
pushed nothing, for ever, while the interface showed the playlists happily
from IndexedDB. The node's audit log was the only thing that said so.
A blob this account cannot open with this passphrase is not an older copy
of anything. It is an absence — the client is the authority (§6.4) — and it
gets overwritten.
"""
s = steps["an unreadable manifest does not wedge the sync"]
assert s["result"]["ok"] is True
assert s["result"]["unreadable"] is True, "the failure was not even noticed"
assert s["result"]["pushed"] > 0, "the sync returned without pushing again"
assert s["overwritten"] is True, "the unreadable blob is still there"
assert "Depuis le menu" in s["names"]
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
def test_what_a_playlist_costs_sealed_is_measured_not_quoted(steps):
"""The ceiling the UI promises comes from here, not from the design doc.
§4 measured ~270 bytes a track *raw* and ~60 *sealed*; the raw figure was
read for the sealed one and "about 225 tracks" written down, which is five
times too strict. So it is measured where it is used.
"""
sizes = steps["what a playlist costs sealed"]["sizes"]
per_track = (sizes["1000"] - sizes["100"]) / 900
assert 30 < per_track < 90, f"a track now costs {per_track:.0f} sealed bytes"
assert sizes["1000"] < 62 * 1024, (
f"a thousand ordinary tracks no longer fit in one frame: {sizes['1000']}")
def test_the_promised_count_holds_for_a_library_that_never_repeats(steps):
"""The number in the message has to survive the worst compression, not the
friendly one.
Everything deflate saves here comes from repetition — the same artist, the
same album, the same folder, over and over. A library that never repeats
costs nearly twice as much a track, and the two ceilings are 1200 and 660.
A reader told to split at 1000 would hit the wall again at 660; the message
says 500, and this is what keeps it true.
"""
diverse = steps["what a playlist costs sealed"]["diverse"]
per_track = (diverse["1000"] - diverse["100"]) / 900
assert per_track > (
(steps["what a playlist costs sealed"]["sizes"]["1000"]
- steps["what a playlist costs sealed"]["sizes"]["100"]) / 900), (
"the harsh fixture compresses as well as the ordinary one — "
"it is not measuring the worst case any more")
assert diverse["500"] < 62 * 1024, (
f"500 tracks of wholly unrepeating metadata no longer fit: "
f"{diverse['500']} bytes, at {per_track:.0f} a track")
def test_a_playlist_too_large_for_a_frame_is_named(steps):
# Not "a sync failed": which playlist, so the reader can act. A bare
# `catch {}` per body is what let this stop leaving the browser in silence.
s = steps["a playlist too large for one frame"]
assert s["result"]["tooLarge"] == ["Trop longue"]
kinds = [e["kind"] for e in s["stored"]]
assert s["bigKind"] not in kinds, "the oversized body was handed to send() anyway"
def test_one_oversized_playlist_does_not_break_the_rest_of_the_sync(steps):
s = steps["a playlist too large for one frame"]
assert s["result"]["ok"] is True
assert s["result"]["failed"] == []
kinds = [e["kind"] for e in s["stored"]]
# The manifest last, and the other bodies before it: one playlist that
# cannot be sent must not hold back the four that can.
assert "playlists" in kinds
assert len([k for k in kinds if k.startswith("playlist:")]) >= 4
|