aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/playlist_store_probe.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-16 11:52:03 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-16 11:52:03 +0200
commit2e973795383b71f63ae9e3bef0e5dfc7930b4c90 (patch)
tree4e653033df1f4a28d8de60d838d92d25b63cb8a7 /packages/meshbay-hub/tests/harness/playlist_store_probe.py
parentad8a08c713dea0e46800ea0aa6bfcf185a69e70e (diff)
downloadmeshbay-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/playlist_store_probe.py')
-rwxr-xr-xpackages/meshbay-hub/tests/harness/playlist_store_probe.py296
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())