diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-16 11:52:03 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-16 11:52:03 +0200 |
| commit | 2e973795383b71f63ae9e3bef0e5dfc7930b4c90 (patch) | |
| tree | 4e653033df1f4a28d8de60d838d92d25b63cb8a7 /packages/meshbay-hub/tests/harness | |
| parent | ad8a08c713dea0e46800ea0aa6bfcf185a69e70e (diff) | |
| download | meshbay-2e973795383b71f63ae9e3bef0e5dfc7930b4c90.tar.gz | |
playlists: the store, and putting it on nodes
playlists.js is IndexedDB, WebCrypto and a transport, and node has no
IndexedDB — so it is driven in Chrome against a node stubbed to record
what it was handed, which is also how what leaves the browser is
checked to be sealed.
Sync asks the node what it holds (user_blob_list) rather than comparing
against the merged watermark, which says nothing about that node: the
first version pushed every body on every sync. A tombstoned playlist's
body is deleted as each node is reached, or the quota fills with graves.
The database version and its stores stay in hub-client.js — two modules
opening one database at versions of their own is a VersionError thrown
at whichever runs second.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/harness')
| -rwxr-xr-x | packages/meshbay-hub/tests/harness/playlist_store_probe.py | 296 |
1 files changed, 296 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/harness/playlist_store_probe.py b/packages/meshbay-hub/tests/harness/playlist_store_probe.py new file mode 100755 index 0000000..8dfa4b1 --- /dev/null +++ b/packages/meshbay-hub/tests/harness/playlist_store_probe.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +""" +The playlist store, driven in a real browser against a fake node. + +`playlist-merge.js` and `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 this runs the shipped module in +Chrome, with a node stubbed to record what it was handed — which is also the +only way to check that what leaves the browser is sealed. + + playlist_store_probe.py + +Prints JSON: one entry per step. +""" +import http.server +import json +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" +PORT = 8753 +RECORDS = [] +socketserver.TCPServer.allow_reuse_address = True + +FRAME = r"""<!doctype html><html><head><meta charset=utf-8></head><body> +<div id="root"></div> +<script type="module"> +import { session } from '/hub-client.js'; +import * as P from '/playlists.js'; +import { open, seal, derivePlaylistKey } from '/playlist-crypto.js'; +import { MANIFEST_KIND, bodyKind } from '/playlist-merge.js'; + +const LOGS = []; +addEventListener('error', (e) => LOGS.push('error: ' + (e.message || e))); +addEventListener('unhandledrejection', + (e) => LOGS.push('rejection: ' + ((e.reason && (e.reason.stack || e.reason.message)) || e.reason))); + +const USER = 'user-1'; +const steps = []; + +// A track as the views hand it over, live junk and all: the Search page +// attaches a transport and a CryptoKey to every entry it renders. +const track = (n, group) => ({ + id: 'hash-' + n, name: `${n} - Titre.flac`, display_title: `Titre ${n}`, + path: 'Artiste/Album', size: 1000 + n, type: 'audio', duration: 200 + n, + artist: 'Artiste', album: 'Album', track_no: n, hash_version: 1, + groupId: group || 'g1', + _tRef: { live: 'transport' }, _gRef: { key: true }, _origPath: 'elsewhere', +}); + +// The node: records everything, answers with whatever it has been given. +function fakeNode() { + const rows = new Map(); + return { + connected: true, + rows, + stored: [], + async fetchUserBlob(kind) { + const r = rows.get(kind); + return r ? { rev: r.rev, blob_enc: r.blob } : { rev: null, blob_enc: null }; + }, + async storeUserBlob(kind, rev, blob) { + rows.set(kind, { rev, blob }); + this.stored.push({ kind, rev, bytes: blob.length }); + return { type: 'ack' }; + }, + async listUserBlobs() { + return [...rows.entries()].map(([kind, r]) => ({ kind, rev: r.rev })); + }, + async deleteUserBlob(kind) { rows.delete(kind); return { type: 'ack' }; }, + }; +} + +(async () => { + const fail = (why) => parent.postMessage({ error: why, logs: LOGS.slice(0, 10) }, '*'); + try { + // A real HKDF handle over fixed bytes, as `deriveBundleKeys` would produce + // — the point is that playlists.js gets its key the way it really does. + const raw = new Uint8Array(32).fill(5); + session.bundleKey = { + v2: await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, + ['encrypt', 'decrypt']), + v2hkdf: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']), + }; + const key = await derivePlaylistKey(session.bundleKey.v2hkdf); + + // ── local editing ────────────────────────────────────────────────────── + const eveningId = await P.createPlaylist(USER, 'Soirée'); + await P.addTracks(USER, eveningId, [track(1), track(2)], 'g1'); + await P.addTracks(USER, P.FAVORITES_ID, [track(9)], 'g1', 'Favoris'); + + steps.push({ step: 'after editing', + list: (await P.listPlaylists(USER)).map((p) => ({ id: p.id, name: p.name, count: p.count })) }); + + steps.push({ step: 'tracks read back', + tracks: (await P.getPlaylistTracks(USER, eveningId)).map((t) => ({ + id: t.id, name: t.name, size: t.size, groupId: t.groupId, + title: t.display_title })) }); + + // Favourites is a toggle, not an append. + const again = await P.addTracks(USER, P.FAVORITES_ID, [track(9)], 'g1'); + const favTwice = await P.getPlaylistTracks(USER, P.FAVORITES_ID); + steps.push({ step: 'favourites is idempotent', added: again, count: favTwice.length }); + + // An ordinary playlist takes the same track twice, because real ones do. + await P.addTracks(USER, eveningId, [track(1)], 'g1'); + steps.push({ step: 'an ordinary playlist takes duplicates', + count: (await P.getPlaylistTracks(USER, eveningId)).length }); + + let dup = null; + try { await P.createPlaylist(USER, 'soiree'); } catch (e) { dup = e.message; } + steps.push({ step: 'a folded duplicate name is refused', why: dup }); + + let fav = null; + try { await P.deletePlaylist(USER, P.FAVORITES_ID); } catch (e) { fav = e.message; } + steps.push({ step: 'favourites cannot be deleted', why: fav }); + + // ── sync ─────────────────────────────────────────────────────────────── + const node = fakeNode(); + const r1 = await P.syncWith(node, USER); + steps.push({ step: 'first sync', result: r1, + kinds: node.stored.map((s) => s.kind).sort() }); + + // What actually left the browser: sealed, and openable only with the key. + const manifestRow = node.rows.get(MANIFEST_KIND); + const hay = new TextDecoder('latin1').decode(manifestRow.blob); + const opened = await open(manifestRow.blob, MANIFEST_KIND, USER, key); + steps.push({ + step: 'what the node holds', + leaks: ['Soirée', 'Favoris', 'playlists', 'Titre'].filter((s) => hay.includes(s)), + names: Object.values(opened.playlists).map((p) => p.name).sort(), + }); + + // Syncing again with nothing changed must not rewrite anything. + node.stored.length = 0; + const r2 = await P.syncWith(node, USER); + steps.push({ step: 'second sync is quiet', result: r2, + wrote: node.stored.length }); + + // ── a node that went away and came back with an older copy ───────────── + // + // The rollback case: the local copy is one of the merge inputs, so a stale + // node can only lose. It must never lower what this browser holds. + const stale = fakeNode(); + const oldManifest = { v: 1, rev: 1, playlists: { + [eveningId]: { name: 'Soirée', rev: 1, body_rev: 1, count: 1, + device: 'other', updated_at: 99, deleted: false } } }; + stale.rows.set(MANIFEST_KIND, { + rev: 1, blob: await seal(oldManifest, MANIFEST_KIND, USER, key) }); + await P.syncWith(stale, USER); + steps.push({ + step: 'a stale node cannot lower anything', + list: (await P.listPlaylists(USER)).map((p) => ({ id: p.id, count: p.count })), + tracks: (await P.getPlaylistTracks(USER, eveningId)).length, + }); + + // ── a node with an edit this browser has not seen ────────────────────── + const ahead = fakeNode(); + const driveId = 'drive-from-elsewhere'; + const newer = { v: 1, rev: 9, playlists: { + ...JSON.parse(JSON.stringify(oldManifest.playlists)), + [driveId]: { name: 'Route', rev: 4, body_rev: 2, count: 3, + device: 'aaa-other-device', updated_at: 5, deleted: false } } }; + ahead.rows.set(MANIFEST_KIND, { + rev: 9, blob: await seal(newer, MANIFEST_KIND, USER, key) }); + ahead.rows.set(bodyKind(driveId), { rev: 2, blob: await seal( + { v: 1, id: driveId, rev: 2, device: 'aaa-other-device', + tracks: [{ id: 'r1', g: 'g2', hv: 1, n: 'a.flac', s: 5, p: 'X', + t: 'Route 1', a: 'A', b: 'B', d: 100, tn: 1 }] }, + bodyKind(driveId), USER, key) }); + await P.syncWith(ahead, USER); + steps.push({ + step: 'an edit made elsewhere arrives', + list: (await P.listPlaylists(USER)).map((p) => p.name).sort(), + routeTracks: (await P.getPlaylistTracks(USER, driveId)).map((t) => t.display_title), + }); + + // ── deletion survives a node that still has it ───────────────────────── + await P.deletePlaylist(USER, eveningId); + const resurrector = fakeNode(); + resurrector.rows.set(MANIFEST_KIND, { + rev: 2, blob: await seal(oldManifest, MANIFEST_KIND, USER, key) }); + await P.syncWith(resurrector, USER); + steps.push({ + step: 'a deletion is not resurrected', + list: (await P.listPlaylists(USER)).map((p) => p.name).sort(), + }); + + // And the body it left behind is reclaimed, on every node as it is + // reached — otherwise the account's quota fills up with graves. + const holder = fakeNode(); + holder.rows.set(bodyKind(eveningId), { rev: 3, blob: new Uint8Array([1, 2, 3]) }); + holder.rows.set(MANIFEST_KIND, { + rev: 1, blob: await seal({ v: 1, rev: 1, playlists: {} }, MANIFEST_KIND, USER, key) }); + await P.syncWith(holder, USER); + steps.push({ + step: 'a deleted body is reclaimed', + stillThere: holder.rows.has(bodyKind(eveningId)), + }); + + // ── a session with no HKDF handle degrades rather than failing ───────── + P.forgetPlaylistKey(); + session.bundleKey = { v2: session.bundleKey.v2 }; // pre-change session + const r3 = await P.syncWith(fakeNode(), USER); + steps.push({ step: 'a session from before the HKDF handle', result: r3 }); + + parent.postMessage({ steps, logs: LOGS.slice(0, 8) }, '*'); + } catch (err) { + fail(String((err && err.stack) || err)); + } +})(); +</script></body></html>""" + +PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head> +<body style="margin:0"><div id="frames"></div><script> +addEventListener('message', (e) => { + fetch('/log', { method: 'POST', body: JSON.stringify(e.data) }); +}); +const f = document.createElement('iframe'); +f.src = '/case'; +f.style.cssText = 'width:900px;height:600px;border:0;display:block'; +document.getElementById('frames').appendChild(f); +</script></body></html>""" + + +class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + if self.path == "/log": + RECORDS.append(json.loads(self.rfile.read(length).decode())) + else: + self.rfile.read(length) + self.send_response(204) + self.end_headers() + + def _send(self, body: bytes, ctype: str) -> None: + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?")[0] + if path == "/": + self._send(PAGE.encode(), "text/html; charset=utf-8") + elif path == "/case": + self._send(FRAME.encode(), "text/html; charset=utf-8") + else: + asset = (STATIC / path.lstrip("/")).resolve() + if not str(asset).startswith(str(STATIC)) or not asset.is_file(): + self.send_response(404) + self.end_headers() + return + self._send(asset.read_bytes(), + "text/css" if asset.suffix == ".css" + else "text/javascript" if asset.suffix == ".js" + else "application/octet-stream") + + +def main() -> int: + with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv: + threading.Thread(target=srv.serve_forever, daemon=True).start() + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile: + proc = subprocess.Popen( + ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + f"--user-data-dir={profile}", "--window-size=900,700", + f"http://127.0.0.1:{PORT}/"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(400): + if RECORDS: + break + time.sleep(0.1) + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + if not RECORDS: + print(json.dumps({"error": "no measurement"}), file=sys.stderr) + return 1 + print(json.dumps(RECORDS[0], indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) |