diff options
Diffstat (limited to 'packages/meshbay-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/test_playlist_crypto.py | 255 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_playlist_key.py | 221 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_playlist_merge.py | 398 |
3 files changed, 874 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_playlist_crypto.py b/packages/meshbay-hub/tests/test_playlist_crypto.py new file mode 100644 index 0000000..4ff3080 --- /dev/null +++ b/packages/meshbay-hub/tests/test_playlist_crypto.py @@ -0,0 +1,255 @@ +""" +Sealing a playlist, and the three properties that make it worth doing. + +`playlist-crypto.js` is executed here against node's own WebCrypto — the real +AES-GCM, the real deflate — because a crypto layer that cannot be executed is a +crypto layer nobody has checked. + +What is asserted is not "it round-trips". That is the easy part and it would +still pass with the compression, the padding and the AAD all removed. What is +asserted is that each of the three is actually doing its job: + + - **compression**, because without it a realistic Favourites list does not fit + under any cap worth setting (docs/playlists.md §4.2); + - **padding**, because the ciphertext length otherwise counts somebody's + tracks for the operator; + - **the AAD**, because it names the *kind*, and without that one playlist's + body can be served in place of another's. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +SRC = STATIC / "playlist-crypto.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not SRC.exists(), + reason="node or the SPA sources are not available") + +PRELUDE = """ +const KEY = await crypto.subtle.importKey( + 'raw', new Uint8Array(32).fill(7), { name: 'AES-GCM' }, false, + ['encrypt', 'decrypt']); +const OTHER = await crypto.subtle.importKey( + 'raw', new Uint8Array(32).fill(9), { name: 'AES-GCM' }, false, + ['encrypt', 'decrypt']); + +// A playlist the shape people actually have. The repetition is real — a few +// groups, albums of a dozen tracks, artists and paths that recur — but **every +// `id` is a distinct random hex string**, because a blake3 hash is, and 64 +// random hex characters per entry do not compress at all. +// +// The first version of this fixture repeated one id on every track and +// measured deflate at 23x. That number was a property of the fixture, not of a +// playlist, and sizing the caps on it would have sized them on nothing. +const hex = (n) => Array.from( + crypto.getRandomValues(new Uint8Array(n / 2)), + (b) => b.toString(16).padStart(2, '0')).join(''); +const GROUPS = Array.from({ length: 4 }, () => hex(32)); +const bigBody = (n) => ({ + v: 1, id: 'favorites', rev: 3, device: 'dev-a', + tracks: Array.from({ length: n }, (_, i) => { + const al = Math.floor(i / 12); + return { + id: hex(64), g: GROUPS[al % GROUPS.length], hv: 1, + n: `${String((i % 12) + 1).padStart(2, '0')} - Un titre de morceau ${i}.flac`, + s: 30000000 + i, + p: `Quelque Artiste ${al % 40}/Un Album Assez Long ${al} (2019)`, + t: `Un titre de morceau ${i}`, a: `Quelque Artiste ${al % 40}`, + b: `Un Album Assez Long ${al}`, d: 180 + (i % 200), tn: (i % 12) + 1, + }; + }), +}); +""" + + +def _run(tmp_path, body): + src = SRC.read_text().replace("export {", "const _unused_export = {") + script = tmp_path / "case.mjs" + script.write_text(f"{src}\n{PRELUDE}\n{body}\n") + out = subprocess.run(["node", str(script)], + capture_output=True, text=True, timeout=60) + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +def test_a_playlist_round_trips(tmp_path): + out = _run(tmp_path, """ + const body = bigBody(12); + const sealed = await seal(body, 'playlist:favorites', 'u1', KEY); + const back = await open(sealed, 'playlist:favorites', 'u1', KEY); + console.log(JSON.stringify({ + same: JSON.stringify(back) === JSON.stringify(body), + tracks: back.tracks.length, + title: back.tracks[3].t, + })); + """) + assert out["same"] and out["tracks"] == 12 + assert out["title"] == "Un titre de morceau 3" + + +def test_compression_is_worth_the_factor_the_caps_assume(tmp_path): + """The caps are sized on deflate being worth about three on this shape. If + it stopped being applied — or the payload stopped being compressible — a + realistic Favourites list would silently stop fitting.""" + out = _run(tmp_path, """ + const body = bigBody(2000); + const raw = new TextEncoder().encode(JSON.stringify(body)).length; + const sealed = (await seal(body, 'playlist:favorites', 'u1', KEY)).length; + console.log(JSON.stringify({ raw, sealed, ratio: raw / sealed })); + """) + # Measured at about 4.5x on this shape (~270 bytes a track raw, ~60 sealed). + # The caps in webrtc_server.py are sized on three, so this is the margin. + assert out["ratio"] > 3, ( + f"deflate is only worth {out['ratio']:.1f}x here; the caps in " + f"webrtc_server.py assume about three") + # 2000 tracks is a realistic Favourites list and must fit the 1 MB body cap + # with room to spare — at this ratio the cap is reached around 17000. + assert out["sealed"] < 1024 * 1024 / 4 + + +def test_the_sealed_length_is_padded_and_so_counts_nothing(tmp_path): + """Two playlists whose sizes differ by hundreds of tracks may share a + ciphertext length; one that differs by one track always does. What the + operator can read off the row is a 4 KB bucket, not a track count.""" + out = _run(tmp_path, """ + const lens = {}; + for (const n of [1, 2, 10, 30]) { + lens[n] = (await seal(bigBody(n), 'playlist:x', 'u1', KEY)).length; + } + console.log(JSON.stringify(lens)); + """) + lengths = {int(k): v for k, v in out.items()} + # Every length is the padding granularity plus the nonce and the tag. + for n, size in lengths.items(): + assert (size - 12 - 16) % 4096 == 0, f"{n} tracks sealed to {size}" + assert lengths[1] == lengths[2] == lengths[10], ( + "small playlists must be indistinguishable by length") + + +def test_the_same_playlist_sealed_twice_is_two_different_blobs(tmp_path): + """A random nonce, never a counter: two devices of one account derive the + *same* key, so a counter would repeat — and a repeated nonce under one key + is the one thing GCM does not survive.""" + out = _run(tmp_path, """ + const body = bigBody(5); + const a = await seal(body, 'playlist:x', 'u1', KEY); + const b = await seal(body, 'playlist:x', 'u1', KEY); + const nonceA = Array.from(a.slice(0, 12)).join(','); + const nonceB = Array.from(b.slice(0, 12)).join(','); + console.log(JSON.stringify({ + sameNonce: nonceA === nonceB, + sameBytes: Array.from(a).join(',') === Array.from(b).join(','), + allZero: nonceA === new Array(12).fill(0).join(','), + })); + """) + assert out["sameNonce"] is False + assert out["sameBytes"] is False + assert out["allZero"] is False + + +def test_the_plaintext_is_not_in_the_sealed_bytes(tmp_path): + """The claim, checked rather than assumed.""" + out = _run(tmp_path, """ + const body = bigBody(20); + const sealed = await seal(body, 'playlist:x', 'u1', KEY); + const hay = new TextDecoder('latin1').decode(sealed); + console.log(JSON.stringify({ + leaks: ['Quelque Artiste', 'Un Album Assez Long', 'tracks', 'favorites'] + .filter((s) => hay.includes(s)), + })); + """) + assert out["leaks"] == [] + + +def test_another_key_cannot_open_it(tmp_path): + out = _run(tmp_path, """ + const sealed = await seal(bigBody(3), 'playlist:x', 'u1', KEY); + let opened = true; + try { await open(sealed, 'playlist:x', 'u1', OTHER); } catch { opened = false; } + console.log(JSON.stringify({ opened })); + """) + assert out["opened"] is False + + +def test_one_playlists_body_cannot_be_served_as_another(tmp_path): + """Why the AAD names the kind and not just "playlists". The moment there + was more than one row, a bare kind let a node hand back the wrong body — + authenticated, and wrong.""" + out = _run(tmp_path, """ + const sealed = await seal(bigBody(3), 'playlist:evening', 'u1', KEY); + let asOther = true; + try { await open(sealed, 'playlist:drive', 'u1', KEY); } catch { asOther = false; } + let asManifest = true; + try { await open(sealed, 'playlists', 'u1', KEY); } catch { asManifest = false; } + console.log(JSON.stringify({ asOther, asManifest })); + """) + assert out["asOther"] is False + assert out["asManifest"] is False + + +def test_another_account_is_named_in_the_aad_too(tmp_path): + out = _run(tmp_path, """ + const sealed = await seal(bigBody(3), 'playlist:x', 'alice', KEY); + let opened = true; + try { await open(sealed, 'playlist:x', 'bob', KEY); } catch { opened = false; } + console.log(JSON.stringify({ opened })); + """) + assert out["opened"] is False + + +def test_a_flipped_byte_is_refused_rather_than_returned(tmp_path): + out = _run(tmp_path, """ + const sealed = await seal(bigBody(3), 'playlist:x', 'u1', KEY); + sealed[40] ^= 0xff; + let opened = true; + try { await open(sealed, 'playlist:x', 'u1', KEY); } catch { opened = false; } + console.log(JSON.stringify({ opened })); + """) + assert out["opened"] is False + + +def test_an_unreadable_blob_throws_rather_than_reading_as_empty(tmp_path): + """A wrong key, a tampered row and a kind served in place of another must + not be quietly indistinguishable from "this account has no playlists yet" — + which is exactly what returning null would make them.""" + out = _run(tmp_path, """ + const cases = {}; + for (const [name, bytes] of Object.entries({ + empty: new Uint8Array(0), + short: new Uint8Array(8), + garbage: crypto.getRandomValues(new Uint8Array(200)), + })) { + try { await open(bytes, 'playlists', 'u1', KEY); cases[name] = 'returned'; } + catch { cases[name] = 'threw'; } + } + console.log(JSON.stringify(cases)); + """) + assert out == {"empty": "threw", "short": "threw", "garbage": "threw"} + + +def test_a_blob_from_a_future_format_is_refused_by_name(tmp_path): + """The framing byte exists so a later change to the compression or the + padding can be told from a blob written before it, rather than mis-parsed + into nonsense.""" + out = _run(tmp_path, """ + // A well-formed blob whose framing byte says a version this build does + // not know: sealed correctly, so it is the framing check that refuses it. + const padded = new Uint8Array(PAD_TO); + padded[0] = 99; + const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES)); + const ct = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: nonce, additionalData: associatedData('playlists', 'u1') }, + KEY, padded); + const blob = new Uint8Array(NONCE_BYTES + ct.byteLength); + blob.set(nonce); blob.set(new Uint8Array(ct), NONCE_BYTES); + let why = null; + try { await open(blob, 'playlists', 'u1', KEY); } catch (e) { why = e.message; } + console.log(JSON.stringify({ why })); + """) + assert out["why"] and "format 99" in out["why"] diff --git a/packages/meshbay-hub/tests/test_playlist_key.py b/packages/meshbay-hub/tests/test_playlist_key.py new file mode 100644 index 0000000..dbed8ae --- /dev/null +++ b/packages/meshbay-hub/tests/test_playlist_key.py @@ -0,0 +1,221 @@ +""" +The playlist key: one Argon2 run, two handles, one subkey. + +Identity keys are per node, so a blob encrypted under one is unreadable from +every other node — the precise opposite of what a playlist needs. The only +secret an account holds *everywhere* is the bundle key, so the playlist key is +derived from it with HKDF (docs/playlists.md §3.4). + +Three things have to hold, and getting any of them wrong is quiet: + + - **One Argon2id run per sign-in.** The budget is the ~650 ms already on that + path. A second call is mathematically pointless and doubles it, and nothing + on screen would say so. + - **The HKDF handle is a second import of the same bytes**, not a derivation + from the AES one — that is imported non-extractably with + `['encrypt','decrypt']`, from which nothing can be derived at all. + - **A purpose-separated subkey**, not the bundle key with a different AAD. + `groupbox.py` writes that rule down for chunk keys; it is the same rule. + +Node's WebCrypto is the real implementation here; only Argon2 is stubbed, and +stubbed precisely so the calls can be counted. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +KEYDERIVE = STATIC / "keyderive.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not KEYDERIVE.exists(), + reason="node or the SPA sources are not available") + +# keyderive.js assigns `window.MeshBayKeys` and reads `window.argon2`; node has +# neither, and a counted stub is the whole point. +PRELUDE = """ +globalThis.window = globalThis; +let argonCalls = 0; +globalThis.argon2 = { + ArgonType: { Argon2id: 2 }, + async hash(opts) { + argonCalls++; + // Deterministic, and a function of what was actually passed, so a changed + // salt domain or cost parameter shows up as different bytes rather than + // silently agreeing. + const seed = new TextEncoder().encode( + opts.pass + ':' + Array.from(opts.salt).join(',') + ':' + opts.time); + const digest = new Uint8Array( + await crypto.subtle.digest('SHA-256', seed)); + return { hash: digest }; + }, +}; +""" + + +def _run(tmp_path, body): + src = KEYDERIVE.read_text() + script = tmp_path / "case.mjs" + script.write_text(f"{PRELUDE}\n{src}\n{body}\n") + out = subprocess.run(["node", str(script)], + capture_output=True, text=True, timeout=60) + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +def test_a_sign_in_runs_argon2_exactly_once(tmp_path): + """The budget is the 650 ms already on the sign-in path. Two handles over + one run is the whole point of `deriveBundleKeys`.""" + out = _run(tmp_path, """ + argonCalls = 0; + const keys = await deriveBundleKeys('passphrase', 'someone'); + console.log(JSON.stringify({ + calls: argonCalls, + aes: keys.aes.algorithm.name, + hkdf: keys.hkdf.algorithm.name, + })); + """) + assert out["calls"] == 1, "a second Argon2id run doubles the sign-in cost" + assert out["aes"] == "AES-GCM" + assert out["hkdf"] == "HKDF" + + +def test_the_bundle_key_fields_a_session_carries_are_built_in_one_run(tmp_path): + """Both places that build a `session.bundleKey` go through this, so + `v2hkdf` cannot be the field one sign-in path forgot.""" + out = _run(tmp_path, """ + argonCalls = 0; + const fields = await window.MeshBayKeys.bundleKeyPairFields('p', 'someone'); + console.log(JSON.stringify({ + calls: argonCalls, + keys: Object.keys(fields).sort(), + v2: fields.v2.algorithm.name, + v2hkdf: fields.v2hkdf.algorithm.name, + })); + """) + assert out["calls"] == 1 + assert out["keys"] == ["v2", "v2hkdf"] + assert out["v2"] == "AES-GCM" and out["v2hkdf"] == "HKDF" + + +def test_the_aes_handle_is_unchanged_by_the_hkdf_one(tmp_path): + """`deriveEncryptionKey` still returns exactly what it always did — every + identity bundle already written is opened with it.""" + out = _run(tmp_path, """ + const legacy = await deriveEncryptionKey('p', 'someone'); + const paired = (await deriveBundleKeys('p', 'someone')).aes; + const data = new TextEncoder().encode('a keypair bundle'); + const iv = new Uint8Array(12); + const ct = await crypto.subtle.encrypt({name:'AES-GCM', iv}, legacy, data); + const back = await crypto.subtle.decrypt({name:'AES-GCM', iv}, paired, ct); + console.log(JSON.stringify({ + same: new TextDecoder().decode(back) === 'a keypair bundle', + extractable: legacy.extractable, + })); + """) + assert out["same"], "the paired AES handle is not the same key as before" + assert out["extractable"] is False + + +def test_the_playlist_key_is_a_subkey_and_not_the_bundle_key(tmp_path): + """Derived under its own `info`, so what opens a playlist opens nothing + else — and cannot be produced from the AES handle at all.""" + out = _run(tmp_path, """ + const { aes, hkdf } = await deriveBundleKeys('p', 'someone'); + const playlistKey = await crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), + info: new TextEncoder().encode('meshbay:playlists:v1') }, + hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + + const iv = new Uint8Array(12); + const ct = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, playlistKey, new TextEncoder().encode('tracks')); + + // The bundle key must not open what the playlist key sealed. + let bundleOpens = true; + try { await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, aes, ct); } + catch { bundleOpens = false; } + + // And nothing can be derived from the AES handle, which is why the HKDF + // one has to be a second import rather than a derivation. + let derivable = true; + try { + await crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), + info: new Uint8Array(0) }, + aes, { name: 'AES-GCM', length: 256 }, false, ['encrypt']); + } catch { derivable = false; } + + console.log(JSON.stringify({ bundleOpens, derivable })); + """) + assert out["bundleOpens"] is False, ( + "the playlist key is the bundle key — purpose separation is gone") + assert out["derivable"] is False, ( + "if the AES handle were derivable the second import would be needless; " + "it is not, which is exactly why deriveBundleKeys imports twice") + + +def test_a_different_info_gives_a_different_key(tmp_path): + """What makes it a *purpose*-separated subkey rather than a rename.""" + out = _run(tmp_path, """ + const { hkdf } = await deriveBundleKeys('p', 'someone'); + const mk = (info) => crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), + info: new TextEncoder().encode(info) }, + hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + const a = await mk('meshbay:playlists:v1'); + const b = await mk('meshbay:something-else:v1'); + const iv = new Uint8Array(12); + const ct = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, a, new TextEncoder().encode('x')); + let opens = true; + try { await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, b, ct); } + catch { opens = false; } + console.log(JSON.stringify({ opens })); + """) + assert out["opens"] is False + + +def test_two_devices_of_one_account_derive_the_same_playlist_key(tmp_path): + """The whole point, and the reason the nonce must be random rather than a + counter: two devices derive the *same* key, so a counter would repeat.""" + out = _run(tmp_path, """ + const mk = async () => { + const { hkdf } = await deriveBundleKeys('same passphrase', 'someone'); + return crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), + info: new TextEncoder().encode('meshbay:playlists:v1') }, + hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + }; + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, await mk(), new TextEncoder().encode('Evening')); + const back = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, await mk(), ct); + console.log(JSON.stringify({ text: new TextDecoder().decode(back) })); + """) + assert out["text"] == "Evening" + + +def test_a_different_account_derives_a_different_key(tmp_path): + """The salt is domain-separated per user; this is what that buys.""" + out = _run(tmp_path, """ + const mk = async (user) => { + const { hkdf } = await deriveBundleKeys('p', user); + return crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), + info: new TextEncoder().encode('meshbay:playlists:v1') }, + hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + }; + const iv = new Uint8Array(12); + const ct = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, await mk('alice'), new TextEncoder().encode('x')); + let opens = true; + try { await crypto.subtle.decrypt({ name:'AES-GCM', iv }, await mk('bob'), ct); } + catch { opens = false; } + console.log(JSON.stringify({ opens })); + """) + assert out["opens"] is False diff --git a/packages/meshbay-hub/tests/test_playlist_merge.py b/packages/meshbay-hub/tests/test_playlist_merge.py new file mode 100644 index 0000000..2c7b612 --- /dev/null +++ b/packages/meshbay-hub/tests/test_playlist_merge.py @@ -0,0 +1,398 @@ +""" +Reconciling N copies of a playlist when nodes are ON and OFF. + +This is the one part of the playlist design that can be properly tested, so +`playlist-merge.js` is kept free of imports and the whole module is executed +here — a copy of the merge rules in a test would keep agreeing with the +original right up until one of them changed. + +The stated fear is well founded for a single blob under last-writer-wins: node +A is off while an edit is made, node B is off while the next one is, and one +edit disappears with nothing to show for it. Four rules remove it, and each one +has its own case below. The tombstone rule has two, because dropping it does +not break anything that looks broken: a node rehomed after three weeks quietly +resurrects every deleted playlist, and it reads as a sync working correctly +right up until it doesn't. + +See docs/playlists.md §5, §6. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +SRC = STATIC / "playlist-merge.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not SRC.exists(), + reason="node or the SPA sources are not available") + +IMPORT = re.compile(r"^\s*import\b", re.M) +# The export list spans several lines here; source-merge.js's fits on one. +EXPORT = re.compile(r"^export \{[^}]*\};?\s*$", re.M | re.S) + + +@pytest.fixture(scope="module") +def module_source(): + text = SRC.read_text() + assert not IMPORT.search(text), ( + "playlist-merge.js has gained an import. It is executed standalone " + "here, and the merge is untested from the moment it cannot be — keep " + "the module free of imports, or this test needs a bundler") + stripped, n = EXPORT.subn("", text) + assert n == 1, ( + "playlist-merge.js no longer ends in a single export statement — the " + "test can no longer strip it to run the module") + return stripped + + +def _run(tmp_path, module_source, body): + script = tmp_path / "case.js" + script.write_text(f"{module_source}\n{body}\n") + out = subprocess.run( + ["node", str(script)], capture_output=True, text=True, timeout=30) + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +def _eval(tmp_path, module_source, expr): + return _run(tmp_path, module_source, + f"console.log(JSON.stringify({expr}));") + + +def _entry(name, rev, device, *, body_rev=0, count=0, deleted=False): + return {"name": name, "rev": rev, "device": device, + "body_rev": body_rev, "count": count, "deleted": deleted, + # Carried for display and never read by the merge. Deliberately + # set backwards from `rev` in several cases below: a merge that + # reads the clock gives the wrong answer for all of them. + "updated_at": 1_000_000 - rev} + + +def _manifest(**playlists): + return {"v": 1, "playlists": playlists} + + +def _merge(tmp_path, src, a, b): + return _eval(tmp_path, src, + f"mergeManifests({json.dumps(a)}, {json.dumps(b)})") + + +# ── the unit is one playlist, not the collection ───────────────────────────── + +def test_two_playlists_edited_on_two_devices_both_survive(tmp_path, module_source): + """The overwhelmingly common case for one person with two devices, and the + whole reason the blob is a map rather than a list.""" + a = _manifest(evening=_entry("Evening", 4, "dev-a"), + drive=_entry("Drive", 1, "dev-a")) + b = _manifest(evening=_entry("Evening", 1, "dev-b"), + drive=_entry("Drive", 7, "dev-b")) + out = _merge(tmp_path, module_source, a, b)["playlists"] + assert out["evening"]["rev"] == 4 and out["evening"]["device"] == "dev-a" + assert out["drive"]["rev"] == 7 and out["drive"]["device"] == "dev-b" + + +def test_a_playlist_only_one_side_has_is_kept(tmp_path, module_source): + """A node that has never seen a playlist must not delete it by omission — + which is the same rule as the tombstone one, from the other end.""" + a = _manifest(evening=_entry("Evening", 2, "dev-a")) + b = _manifest(drive=_entry("Drive", 1, "dev-b")) + out = _merge(tmp_path, module_source, a, b)["playlists"] + assert set(out) == {"evening", "drive"} + + +# ── revisions, never the wall clock ────────────────────────────────────────── + +def test_the_higher_revision_wins_even_with_an_older_timestamp( + tmp_path, module_source): + """`updated_at` is set backwards from `rev` throughout this file. A merge + that reads the clock fails here, and clocks across devices are exactly as + trustworthy as this test assumes.""" + a = _manifest(x=_entry("new name", 9, "dev-a")) + b = _manifest(x=_entry("old name", 2, "dev-b")) + out = _merge(tmp_path, module_source, a, b)["playlists"]["x"] + assert out["name"] == "new name" + assert a["playlists"]["x"]["updated_at"] < b["playlists"]["x"]["updated_at"], ( + "the fixture must actually have the older clock on the winning side") + + +def test_a_revision_tie_is_broken_the_same_way_on_every_device( + tmp_path, module_source): + """Two devices, no conversation between them, one answer. Merged in both + orders because a tie-break that depends on argument order is not one.""" + a = _manifest(x=_entry("from A", 5, "dev-a")) + b = _manifest(x=_entry("from B", 5, "dev-b")) + forward = _merge(tmp_path, module_source, a, b)["playlists"]["x"] + backward = _merge(tmp_path, module_source, b, a)["playlists"]["x"] + assert forward == backward + assert forward["device"] == "dev-a" + + +# ── a deletion is a tombstone, never an absence ────────────────────────────── + +def test_a_node_rehomed_after_three_weeks_does_not_resurrect_a_deletion( + tmp_path, module_source): + """The single most likely defect in the whole design. + + The client deleted "Evening" (rev 5, tombstoned). A node that went offline + at rev 4 comes back still holding it, alive. Absence on that node must not + win, and the tombstone must not be dropped just because the other side has + a live entry. + """ + local = _manifest(evening=_entry("Evening", 5, "dev-a", deleted=True)) + stale_node = _manifest(evening=_entry("Evening", 4, "dev-a")) + out = _merge(tmp_path, module_source, local, stale_node)["playlists"] + assert out["evening"]["deleted"] is True, "the deleted playlist came back" + + +def test_a_deletion_loses_to_a_later_edit(tmp_path, module_source): + """A tombstone is not special-cased into always winning: it is one more + revision. Someone who deletes a playlist and then, from another device that + had not seen the deletion, renames it at a higher revision, gets the + rename — which is last-writer-wins doing exactly what it says.""" + deleted = _manifest(x=_entry("X", 5, "dev-a", deleted=True)) + later = _manifest(x=_entry("X renamed", 6, "dev-b")) + out = _merge(tmp_path, module_source, deleted, later)["playlists"]["x"] + assert out["deleted"] is False and out["name"] == "X renamed" + + +def test_a_tombstone_is_not_shown_to_the_reader(tmp_path, module_source): + m = _manifest(gone=_entry("Gone", 3, "dev-a", deleted=True), + kept=_entry("Kept", 1, "dev-a")) + live = _eval(tmp_path, module_source, f"livePlaylists({json.dumps(m)})") + assert [p["id"] for p in live] == ["kept"] + + +# ── the two counters ───────────────────────────────────────────────────────── + +def test_a_rename_and_a_track_added_elsewhere_both_survive( + tmp_path, module_source): + """Why there are two counters rather than one. + + Device A renames the playlist; device B adds a track to it. Neither edit + touches the other, but with a single revision counter both write n+1 and + one of them is lost. `rev` carries the name, `body_rev` is a watermark for + the tracks, and they move independently. + """ + renamed = _manifest(x=_entry("New name", 8, "dev-a", body_rev=3, count=10)) + tracked = _manifest(x=_entry("Old name", 7, "dev-b", body_rev=4, count=11)) + out = _merge(tmp_path, module_source, renamed, tracked)["playlists"]["x"] + assert out["name"] == "New name", "the rename was lost" + assert out["body_rev"] == 4 and out["count"] == 11, "the added track was lost" + + +def test_the_body_watermark_never_goes_backwards(tmp_path, module_source): + """A stale node reporting body_rev 2 against a local 9 must not lower it — + the local copy is one of the inputs, and that is what stops a node that + serves an old copy from being able to undo anything.""" + local = _manifest(x=_entry("X", 4, "dev-a", body_rev=9, count=40)) + stale = _manifest(x=_entry("X", 4, "dev-a", body_rev=2, count=5)) + out = _merge(tmp_path, module_source, local, stale)["playlists"]["x"] + assert out["body_rev"] == 9 and out["count"] == 40 + + +# ── the client is the authority ────────────────────────────────────────────── + +def test_the_offline_node_dance_loses_nothing(tmp_path, module_source): + """The scenario the fear is actually about, played out. + + Device 1 renames Evening while node B is off; device 2 renames Drive while + node A is off. Each node holds one of the two edits. The client folds its + own copy together with both and keeps both edits — which it can only do + because its own copy is one of the inputs. + """ + body = """ + const base = {v:1, playlists: { + evening: {name:'Evening', rev:1, device:'dev-a', body_rev:1, count:2, deleted:false}, + drive: {name:'Drive', rev:1, device:'dev-a', body_rev:1, count:2, deleted:false}}}; + const nodeA = JSON.parse(JSON.stringify(base)); + nodeA.playlists.evening = {...nodeA.playlists.evening, name:'Soirée', rev:2, device:'dev-1'}; + const nodeB = JSON.parse(JSON.stringify(base)); + nodeB.playlists.drive = {...nodeB.playlists.drive, name:'Route', rev:2, device:'dev-2'}; + const merged = mergeAllManifests([base, nodeA, nodeB]); + console.log(JSON.stringify({ + evening: merged.playlists.evening.name, + drive: merged.playlists.drive.name})); + """ + out = _run(tmp_path, module_source, body) + assert out == {"evening": "Soirée", "drive": "Route"} + + +def test_folding_in_any_order_gives_the_same_answer(tmp_path, module_source): + """Nodes answer in whatever order they answer in; the merged state cannot + depend on that.""" + body = """ + const c = [ + {v:1, playlists:{x:{name:'a', rev:1, device:'d1', body_rev:1, count:1, deleted:false}}}, + {v:1, playlists:{x:{name:'b', rev:3, device:'d2', body_rev:5, count:9, deleted:false}}}, + {v:1, playlists:{x:{name:'c', rev:2, device:'d3', body_rev:2, count:4, deleted:false}}}, + ]; + const one = mergeAllManifests(c); + const two = mergeAllManifests([c[2], c[0], c[1]]); + const three = mergeAllManifests([c[1], c[2], c[0]]); + console.log(JSON.stringify([one, two, three])); + """ + one, two, three = _run(tmp_path, module_source, body) + assert one == two == three + assert one["playlists"]["x"]["name"] == "b" + + +# ── bodies ─────────────────────────────────────────────────────────────────── + +def test_a_body_merge_takes_the_higher_revision(tmp_path, module_source): + a = {"v": 1, "id": "x", "rev": 4, "device": "d1", "tracks": [{"id": "t1"}]} + b = {"v": 1, "id": "x", "rev": 6, "device": "d2", + "tracks": [{"id": "t1"}, {"id": "t2"}]} + out = _eval(tmp_path, module_source, + f"mergeBodies({json.dumps(a)}, {json.dumps(b)})") + assert out["rev"] == 6 and len(out["tracks"]) == 2 + + +def test_a_body_a_node_has_never_seen_is_not_an_absence(tmp_path, module_source): + a = {"v": 1, "id": "x", "rev": 3, "device": "d1", "tracks": [{"id": "t1"}]} + assert _eval(tmp_path, module_source, + f"mergeBodies({json.dumps(a)}, null)")["rev"] == 3 + assert _eval(tmp_path, module_source, + f"mergeBodies(null, {json.dumps(a)})")["rev"] == 3 + + +# ── what is stored, and what must not be ───────────────────────────────────── + +def test_a_stored_track_carries_the_fields_the_player_cannot_work_without( + tmp_path, module_source): + """`s` and `n` are the two the first draft of the design left out. The + player computes its chunk count from the size and reads the name both for + the MIME type and to decide whether the file needs converting first, so + without them a playlist entry cannot be downloaded at all.""" + entry = {"id": "abc", "name": "03 - A Track.flac", "size": 41238711, + "path": "Artist/Album", "type": "audio", "duration": 214, + "display_title": "A Track", "artist": "Artist", "album": "Album", + "track_no": 3, "hash_version": 2} + stored = _eval(tmp_path, module_source, + f"toStored({json.dumps(entry)}, 'g1')") + assert stored["s"] == 41238711 + assert stored["n"] == "03 - A Track.flac" + assert stored["g"] == "g1" and stored["hv"] == 2 + + +def test_live_objects_are_never_stored(tmp_path, module_source): + """The entries the views hand over carry a transport and a CryptoKey, + attached by the Search page's merge. Storing one is at best unserialisable + and at worst a dead connection read back a week later.""" + body = """ + const entry = { id:'abc', name:'x.flac', size:1, path:'p', type:'audio', + groupId:'g9', _tRef:{live:'transport'}, _gRef:{key:true}, + _origPath:'elsewhere', _sources:[{groupId:'g9'}] }; + const stored = toStored(entry, 'g1'); + console.log(JSON.stringify({ keys: Object.keys(stored).sort(), + g: stored.g, json: JSON.stringify(stored) })); + """ + out = _run(tmp_path, module_source, body) + assert out["keys"] == sorted(["id", "g", "hv", "n", "s", "p", "t", "a", "b", "d", "tn"]) + assert out["g"] == "g9", "the entry's own group must win over the caller's" + for leak in ("_tRef", "_gRef", "_origPath", "_sources", "transport"): + assert leak not in out["json"] + + +def test_a_stored_track_round_trips_to_what_the_player_consumes( + tmp_path, module_source): + body = """ + const entry = { id:'abc', name:'03 - A Track.flac', size:99, path:'A/B', + type:'audio', duration:214, display_title:'A Track', + artist:'Artist', album:'Album', track_no:3, + hash_version:1, groupId:'g1' }; + console.log(JSON.stringify(fromStored(toStored(entry, 'g1')))); + """ + out = _run(tmp_path, module_source, body) + for field in ("id", "name", "size", "path", "duration", "display_title", + "artist", "album", "track_no", "hash_version"): + assert out[field] == {**{"display_title": "A Track"}, + **{"id": "abc", "name": "03 - A Track.flac", + "size": 99, "path": "A/B", "duration": 214, + "artist": "Artist", "album": "Album", + "track_no": 3, "hash_version": 1}}[field] + assert out["groupId"] == "g1" + + +# ── repair ─────────────────────────────────────────────────────────────────── + +def test_a_moved_file_is_found_by_its_hash_and_its_path_rewritten( + tmp_path, module_source): + """Content addressing survives a move.""" + body = """ + const index = indexTracks([ + {id:'t1', type:'audio', path:'New/Place', name:'a.flac'}]); + const { tracks, repaired } = repairTracks( + [{id:'t1', g:'g1', p:'Old/Place', n:'a.flac'}], 'g1', index); + console.log(JSON.stringify({tracks, repaired})); + """ + out = _run(tmp_path, module_source, body) + assert out["repaired"] == 1 + assert out["tracks"][0]["p"] == "New/Place" + + +def test_a_re_encoded_file_is_found_by_its_path_and_its_hash_rewritten( + tmp_path, module_source): + """A path survives a re-encode. Either field can repair the other, which is + the whole reason both are stored.""" + body = """ + const index = indexTracks([ + {id:'t1-new', type:'audio', path:'A/B', name:'a.flac'}]); + const { tracks, repaired } = repairTracks( + [{id:'t1-old', g:'g1', p:'A/B', n:'a.flac'}], 'g1', index); + console.log(JSON.stringify({tracks, repaired})); + """ + out = _run(tmp_path, module_source, body) + assert out["repaired"] == 1 + assert out["tracks"][0]["id"] == "t1-new" + + +def test_entries_from_other_groups_are_left_alone(tmp_path, module_source): + """"Not in the index I happen to be holding" is not evidence that a track + is gone — it is evidence about a different group.""" + body = """ + const index = indexTracks([{id:'x', type:'audio', path:'A', name:'a.flac'}]); + const { tracks, repaired } = repairTracks( + [{id:'t1', g:'g2', p:'A', n:'a.flac'}], 'g1', index); + console.log(JSON.stringify({tracks, repaired})); + """ + out = _run(tmp_path, module_source, body) + assert out["repaired"] == 0 + assert out["tracks"][0]["id"] == "t1" + + +def test_an_entry_that_is_simply_gone_is_left_as_it_is(tmp_path, module_source): + """Greyed at play time, not deleted from the playlist: a file that is + missing today may be a disk that is unplugged today.""" + body = """ + const index = indexTracks([{id:'other', type:'audio', path:'Z', name:'z.flac'}]); + const { tracks, repaired } = repairTracks( + [{id:'t1', g:'g1', p:'A', n:'a.flac'}], 'g1', index); + console.log(JSON.stringify({tracks, repaired})); + """ + out = _run(tmp_path, module_source, body) + assert out["repaired"] == 0 and out["tracks"][0]["id"] == "t1" + + +# ── favourites ─────────────────────────────────────────────────────────────── + +def test_favourites_is_first_whatever_it_is_called(tmp_path, module_source): + """The menus promise it first, and the reserved id is what is stored — the + localised name is never written, or an account that switches language + grows a second favourites list.""" + m = _manifest(zzz=_entry("Zzz", 1, "d"), + aaa=_entry("Aaa", 1, "d"), + favorites=_entry("Favoris", 1, "d")) + live = _eval(tmp_path, module_source, f"livePlaylists({json.dumps(m)})") + assert [p["id"] for p in live] == ["favorites", "aaa", "zzz"] + + +def test_the_reserved_id_is_a_constant_not_a_literal(tmp_path, module_source): + assert _eval(tmp_path, module_source, "FAVORITES_ID") == "favorites" + assert _eval(tmp_path, module_source, "bodyKind('favorites')") == "playlist:favorites" + assert _eval(tmp_path, module_source, "MANIFEST_KIND") == "playlists" |