diff options
Diffstat (limited to 'packages/meshbay-hub/tests/harness')
10 files changed, 847 insertions, 17 deletions
diff --git a/packages/meshbay-hub/tests/harness/chat_send_probe.py b/packages/meshbay-hub/tests/harness/chat_send_probe.py index 7569628..e7f1dae 100644 --- a/packages/meshbay-hub/tests/harness/chat_send_probe.py +++ b/packages/meshbay-hub/tests/harness/chat_send_probe.py @@ -171,7 +171,10 @@ function makeTransport(name, chatReply) { tp._groupId = '__GROUP_ID__'; tp._gekRaw = hex('__GEK_HEX__'); tp.chatEpoch = 1; - tp._sessionKeys = { skEdB64: SK_ED_B64 }; + tp._identity = { pkEdB64: DEVICE_PK_B64, + signAs: (kind, fields) => window.MeshBayKeys.signBytes(SK_ED_B64, + window.MeshBayCrypto.transcriptFor(kind, fields, + { pkEdB64: DEVICE_PK_B64 })) }; tp.devicePk = DEVICE_PK_B64; tp._send = (obj) => { log.push('sent ' + obj.type); @@ -314,7 +317,7 @@ async function runScenario(name, chatReply, duringSession) { // connect() drops it before it touches the network. Nothing about this // step is simulated, and the clear is not poked in by the test. await tp.connect('node-1', 'token', tp._groupId, tp._gekRaw, - tp._sessionKeys, null, 'me', 'user-me').then( + tp._identity, null, 'me', 'user-me').then( () => log.push('reconnect: connect() unexpectedly succeeded'), (e) => log.push('reconnect: connect() stopped at signaling, as expected: ' + (e && e.message || e))); diff --git a/packages/meshbay-hub/tests/harness/group_hosts_probe.py b/packages/meshbay-hub/tests/harness/group_hosts_probe.py new file mode 100644 index 0000000..0611e3d --- /dev/null +++ b/packages/meshbay-hub/tests/harness/group_hosts_probe.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +""" +A group owner's settings: who was invited, and which other nodes asked to host. + +Renders the shipped `GroupSettingsPanel` with `fetch` stubbed: one member, one +unanswered invitation, one node asking to host and one already approved. Then +clicks Approve on the request and reports what reached the hub. + + group_hosts_probe.py [--engine chrome|firefox] + +Prints JSON. +""" + +import argparse +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 = 8773 +RECORDS = [] +FINISHED = threading.Event() +socketserver.TCPServer.allow_reuse_address = True + +PAGE = r"""<!doctype html><html><head><meta charset=utf-8> +<link rel="stylesheet" href="/style.css"></head><body> +<div id="root"></div> +<script type="module"> +import { html, render } from '/vendor/htm-preact.js'; +import { initLocale } from '/i18n.js'; +import { GroupSettingsPanel } from '/group-settings.js'; + +const realFetch = window.fetch.bind(window); +const calls = []; +const json = (body) => ({ ok: true, status: 200, statusText: '', headers: new Headers(), + json: async () => body, text: async () => JSON.stringify(body) }); +window.fetch = async (url, init = {}) => { + const u = String(url); + calls.push(`${init.method || 'GET'} ${u.replace(/^https?:\/\/[^/]+/, '')}`); + if (u.endsWith('/v1/groups/g1/members')) return json({ + group_id: 'g1', admin_id: 'u1', + members: [{ user_id: 'u1', username: 'the-owner' }], + invited: [{ user_id: 'u9', username: 'someone_asked' }] }); + if (u.endsWith('/v1/groups/g1/hosts')) return json({ hosts: [ + { node_id: 'n-asking', pk_node: 'AAAA', username: 'a-member', status: 'pending', + online: true }, + { node_id: 'n-ok', pk_node: 'BBBB', username: 'another-member', status: 'approved', + online: false }] }); + return json({}); +}; + +await initLocale(); +render(html`<${GroupSettingsPanel} groupId="g1" token="t" userId="u1" + group=${{ id: 'g1', name: 'a group', owner_username: 'the-owner', is_admin: true }} + transportRef=${{ current: null }} gekRef=${{ current: null }} + isNodeAdmin=${false} operatorPaired=${false} connected=${false} + enabledApps=${[]} entries=${[]} nodeDirs=${[]} />`, document.getElementById('root')); + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +const out = {}; +try { + await wait(1200); + const rows = [...document.querySelectorAll('.admin-table tr')].map((r) => r.innerText); + out.invited_row = rows.some((r) => r.includes('someone_asked')); + out.host_rows = rows.filter((r) => r.includes('AAAA') || r.includes('BBBB')).length; + const asking = [...document.querySelectorAll('.admin-table tr')] + .find((r) => r.innerText.includes('AAAA')); + out.buttons_on_request = asking ? asking.querySelectorAll('button').length : -1; + const approved = [...document.querySelectorAll('.admin-table tr')] + .find((r) => r.innerText.includes('BBBB')); + out.buttons_on_approved = approved ? approved.querySelectorAll('button').length : -1; + if (asking) { asking.querySelector('button').click(); await wait(800); } + out.decision = calls.filter((c) => c.includes('/hosts/')); +} catch (e) { + out.error = String(e && e.stack || e); +} +realFetch('/log', { method: 'POST', body: JSON.stringify(out) }); +</script>__HOLD__</body></html>""" + +HOLD_TAG = '<img src="/hold" style="position:fixed;left:-4px;top:-4px;width:1px">' +HOLD = "" + + +class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) + if self.path == "/log": + RECORDS.append(json.loads(body.decode())) + FINISHED.set() + 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 == "/hold": + FINISHED.wait(60) + self._send(b"", "image/gif") + elif path == "/": + self._send(PAGE.replace("__HOLD__", HOLD).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") + + +# Launchers and profile rule: see sticky_header_probe.py. +ENGINES = { + "chrome": lambda profile: [ + "google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + f"--user-data-dir={profile}", "--window-size=1100,900"], + "firefox": lambda profile: [ + "firefox", "--headless", "--profile", profile, + "--screenshot", str(Path(profile) / "shot.png"), "--window-size", "1100,900"], +} + + +def main() -> int: + global HOLD + ap = argparse.ArgumentParser() + ap.add_argument("--engine", choices=sorted(ENGINES), default="chrome") + args = ap.parse_args() + HOLD = HOLD_TAG if args.engine == "firefox" else "" + parent = None + if args.engine == "firefox": + snap = Path.home() / "snap" / "firefox" / "common" + parent = str(snap if snap.is_dir() else Path.home()) + with socketserver.ThreadingTCPServer(("127.0.0.1", PORT), H) as srv: + threading.Thread(target=srv.serve_forever, daemon=True).start() + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True, + prefix="meshbay-probe-", dir=parent) as profile: + proc = subprocess.Popen(ENGINES[args.engine](profile) + + [f"http://127.0.0.1:{PORT}/"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(300): + 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(dict(RECORDS[0], engine=args.engine), indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/meshbay-hub/tests/harness/group_tab_probe.py b/packages/meshbay-hub/tests/harness/group_tab_probe.py index 730ba99..e23a946 100644 --- a/packages/meshbay-hub/tests/harness/group_tab_probe.py +++ b/packages/meshbay-hub/tests/harness/group_tab_probe.py @@ -73,6 +73,8 @@ window.MeshBayTransport = class { // unmount; a stub without this throws inside connect() and the page // renders its error state instead of a tab bar. addReconnectListener() { return () => {}; } + // What the node should hold of our identity, settled after connecting. + async settleNodeBundle() {} close() {} }; </script> diff --git a/packages/meshbay-hub/tests/harness/invitation_probe.py b/packages/meshbay-hub/tests/harness/invitation_probe.py new file mode 100644 index 0000000..29133ae --- /dev/null +++ b/packages/meshbay-hub/tests/harness/invitation_probe.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +An invitation waiting on the home page, answered in the real application. + +Being added to a group is an invitation until it is accepted (AV33). What only +the running application shows is that the home page lists it, that Accept and +Decline reach the hub, and that an accepted group then appears among the +reader's groups — while a declined one does not. + +Loads the shipped `app.js` with `fetch` stubbed, once per case: + + accept — the invitation is listed, Accept is clicked + decline — the invitation is listed, Decline is clicked + + invitation_probe.py [--engine chrome|firefox] + +Prints JSON: one object per case. +""" + +import argparse +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 = 8772 +RECORDS = [] +FINISHED = threading.Event() +socketserver.TCPServer.allow_reuse_address = True + +GROUP = "0f8fad5b-d9cb-469f-a165-70867728950e" + +PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head><body> +<div id="app"></div> +<script type="module"> +const CASE = new URLSearchParams(location.search).get('case'); +const realFetch = window.fetch.bind(window); +const post = (o) => realFetch('/log', { method: 'POST', body: JSON.stringify(o) }); +const calls = []; +let accepted = false; +const json = (body, status = 200) => ({ + ok: status < 400, status, statusText: '', headers: new Headers(), + json: async () => body, text: async () => JSON.stringify(body), +}); +const GROUP_ROW = { id: '__GROUP__', name: 'Some Group', owner_username: 'the-owner', + visibility: 'private', join_policy: 'invite', is_admin: false, muted: false, + created_at: '2026-09-30T00:00:00+00:00', last_activity_at: '2026-09-30T00:00:00+00:00', + hosted: true, node_online: false, description: '' }; +window.fetch = async (url, init = {}) => { + const u = String(url); + calls.push({ url: u, method: init.method || 'GET' }); + if (u.includes('/v1/users/me/preferences')) return json({}); + if (u.includes('/v1/users/me')) return json({ user_id: 'u-1', role: 'user' }); + if (u.includes('/v1/groups/mine')) return json({ groups: accepted ? [GROUP_ROW] : [] }); + if (u.includes('/v1/groups/invitations')) return json({ invitations: [{ + group_id: '__GROUP__', name: 'Some Group', owner_username: 'the-owner', + invited_by: 'the-owner', created_at: '2026-09-30T00:00:00+00:00' }] }); + if (u.includes('/invitation/accept')) { accepted = true; return json({ status: 'joined' }); } + if (u.includes('/invitation/decline')) return json({ status: 'declined' }); + if (u.includes('/v1/notifications')) return json({ notifications: [], unread_count: 0 }); + return json({}); +}; +localStorage.setItem('mb_auth', JSON.stringify({ + username: 'invitee-account', userId: 'u-1', token: 'tok', refreshToken: 'ref', + role: 'user' })); +history.replaceState(null, '', '/?case=' + CASE + '#/'); + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +const text = () => document.getElementById('app').innerText; +(async () => { + const out = { case: CASE }; + try { + await import('/app.js'); + await wait(1500); + out.listed = text().includes('Some Group'); + // By position, not by label: the browser's language picks the label. + const buttons = [...document.querySelectorAll('.invite-links li button')]; + out.buttons = buttons.length; + const button = buttons[CASE === 'accept' ? 0 : 1]; + if (button) { button.click(); await wait(1200); } + out.answer_call = calls.filter((c) => c.url.includes('/invitation/')) + .map((c) => `${c.method} ${c.url.replace(/^https?:\/\/[^/]+/, '')}`); + out.mine_refetched = calls.filter((c) => c.url.includes('/v1/groups/mine')).length; + out.invitation_still_shown = document.querySelectorAll('.invite-links li').length > 0; + out.group_card = [...document.querySelectorAll('a.group-card')] + .some((a) => a.getAttribute('href') === '#/group/__GROUP__'); + } catch (e) { + out.error = String(e && e.stack || e); + } + post(out); +})(); +</script>__HOLD__</body></html> +""".replace("__GROUP__", GROUP) + +HOLD_TAG = '<img src="/hold" style="position:fixed;left:-4px;top:-4px;width:1px">' +HOLD = "" + + +class H(http.server.SimpleHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) + if self.path == "/log": + RECORDS.append(json.loads(body.decode())) + FINISHED.set() + 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 == "/hold": + FINISHED.wait(60) + self._send(b"", "image/gif") + return + if path == "/": + self._send(PAGE.replace("__HOLD__", HOLD).encode(), "text/html; charset=utf-8") + return + 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 + ctype = "text/javascript" if asset.suffix in (".js", ".mjs") else ( + "application/wasm" if asset.suffix == ".wasm" else "application/octet-stream") + self._send(asset.read_bytes(), ctype) + + +# Same launchers and the same profile rule as sticky_header_probe.py, which +# explains both: Firefox is a snap here, and needs a profile under $HOME. +ENGINES = { + "chrome": lambda profile: [ + "google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + f"--user-data-dir={profile}"], + "firefox": lambda profile: [ + "firefox", "--headless", "--profile", profile, + "--screenshot", str(Path(profile) / "shot.png")], +} + + +def _profile_parent(engine: str) -> str | None: + if engine != "firefox": + return None + snap = Path.home() / "snap" / "firefox" / "common" + return str(snap if snap.is_dir() else Path.home()) + + +def _run(engine: str, case: str) -> dict | None: + before = len(RECORDS) + FINISHED.clear() + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True, prefix="meshbay-probe-", + dir=_profile_parent(engine)) as profile: + proc = subprocess.Popen( + ENGINES[engine](profile) + [f"http://127.0.0.1:{PORT}/?case={case}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(300): + if len(RECORDS) > before: + break + time.sleep(0.1) + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + return RECORDS[before] if len(RECORDS) > before else None + + +def main() -> int: + global HOLD + ap = argparse.ArgumentParser() + ap.add_argument("--engine", choices=sorted(ENGINES), default="chrome") + args = ap.parse_args() + HOLD = HOLD_TAG if args.engine == "firefox" else "" + with socketserver.ThreadingTCPServer(("127.0.0.1", PORT), H) as srv: + threading.Thread(target=srv.serve_forever, daemon=True).start() + results = [_run(args.engine, "accept"), _run(args.engine, "decline")] + if not all(results): + print(json.dumps({"error": "no measurement", "got": results}), file=sys.stderr) + return 1 + print(json.dumps([dict(r, engine=args.engine) for r in results], indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/meshbay-hub/tests/harness/layout_probe.py b/packages/meshbay-hub/tests/harness/layout_probe.py index 65b3083..0abbda6 100644 --- a/packages/meshbay-hub/tests/harness/layout_probe.py +++ b/packages/meshbay-hub/tests/harness/layout_probe.py @@ -81,7 +81,7 @@ RECORDS = [] def main() -> int: widths = [int(w) for w in sys.argv[1].split(",")] - fragment = Path(sys.argv[2]).read_text() + fragment = Path(sys.argv[2]).read_text(encoding="utf-8") selectors = sys.argv[3:] class H(http.server.BaseHTTPRequestHandler): diff --git a/packages/meshbay-hub/tests/harness/lazy_failure_probe.py b/packages/meshbay-hub/tests/harness/lazy_failure_probe.py new file mode 100644 index 0000000..357529a --- /dev/null +++ b/packages/meshbay-hub/tests/harness/lazy_failure_probe.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +""" +What `lazy()` shows when the module it asks for answers 404. + +The shape found live: a tab opened before a hub deploy asks for its not-yet- +loaded modules under the previous `/a/<hash>/` prefix, which the new hub no +longer serves. Every lazily loaded view — Search, Videos, Music, Photos, the +video player — sat on its spinner for good, with an empty console. This +renders the shipped `lazy.js` against a module that does not exist, and one +that does, and reads back what is on the page. + + lazy_failure_probe.py +""" + +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 = 8763 +RECORDS = [] +socketserver.TCPServer.allow_reuse_address = True + +FRAME = r"""<!doctype html><html><head><meta charset=utf-8> +<link rel="stylesheet" href="/style.css"></head><body> +<div id="plain"></div><div id="framed"></div><div id="fine"></div> +<script type="module"> +import { html, render } from '/vendor/htm-preact.js'; +import { initLocale } from '/i18n.js'; +import { lazy } from '/lazy.js'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const errors = []; +const origError = console.error; +console.error = (...a) => { errors.push(a.map(String).join(' ')); origError(...a); }; + +const read = (id) => { + const root = document.getElementById(id); + return { + spinner: !!root.querySelector('.spinner'), + notice: (root.querySelector('.lazy-failed p') || {}).textContent || null, + buttons: [...root.querySelectorAll('.lazy-failed button')].map((b) => b.textContent.trim()), + overlay: !!root.querySelector('.video-player-overlay .lazy-failed'), + text: root.textContent.trim(), + }; +}; + +(async () => { + try { + await initLocale(); + // The stale tab's request: a module under a prefix nobody serves. + const Gone = lazy(() => import('/a/0000000000/gone.js'), 'Gone'); + const frame = (c) => html`<div class="video-overlay video-player-overlay">${c}</div>`; + const GoneOverlay = lazy(() => import('/a/0000000000/player.js'), 'Player', + frame(html`<p class="page-message"><span class="spinner"></span></p>`), frame); + // A module that exists still renders as itself. + const Fine = lazy(() => import('/icon.js'), 'Icon'); + + let closed = 0; + render(html`<${Gone} />`, document.getElementById('plain')); + render(html`<${GoneOverlay} onClose=${() => { closed += 1; }} />`, + document.getElementById('framed')); + render(html`<${Fine} name="close" />`, document.getElementById('fine')); + await sleep(1500); + + const out = { plain: read('plain'), framed: read('framed'), errors, + fine: !!document.querySelector('#fine svg, #fine .icon') }; + const btns = document.querySelectorAll('#framed .lazy-failed button'); + if (btns[1]) btns[1].click(); + out.closed = closed; + parent.postMessage(out, '*'); + } catch (err) { + parent.postMessage({ error: String((err && err.stack) || err) }, '*'); + } +})(); +</script></body></html>""" + +PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head> +<body style="margin:0"><iframe src="/case" style="width:900px;height:700px;border:0"></iframe> +<script> +addEventListener('message', (e) => fetch('/log', { method: 'POST', body: JSON.stringify(e.data) })); +</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) + deadline = time.time() + 60 + while not RECORDS and time.time() < deadline: + time.sleep(0.2) + proc.terminate() + proc.wait(timeout=20) + if not RECORDS: + print("the page never reported", file=sys.stderr) + return 1 + print(json.dumps(RECORDS[0])) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/meshbay-hub/tests/harness/playlist_store_probe.py b/packages/meshbay-hub/tests/harness/playlist_store_probe.py index 9a54a0d..857cf1e 100755 --- a/packages/meshbay-hub/tests/harness/playlist_store_probe.py +++ b/packages/meshbay-hub/tests/harness/playlist_store_probe.py @@ -80,15 +80,15 @@ function fakeNode() { (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. + // A real master key over fixed bytes, the shape `deriveBundleSessionKey` + // produces — 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']), + v3: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey', 'deriveBits']), + pepperVersion: 1, }; - const key = await derivePlaylistKey(session.bundleKey.v2hkdf); + const key = await derivePlaylistKey(session.bundleKey.v3); // ── local editing ────────────────────────────────────────────────────── const eveningId = await P.createPlaylist(USER, 'Soirée'); @@ -364,6 +364,39 @@ function fakeNode() { names: readBack ? Object.values(readBack.playlists).map((p) => p.name).sort() : [], }); + // ── a node sealed under the previous playlist key ───────────────────── + // + // The key changed, not the playlists: every row on such a node carries the + // revision the local copy has, so "push only when the node is behind" + // would leave all of it unreadable for ever. Built from what a clean sync + // stores, then every row resealed under another key, plus a body for a + // playlist this browser has never heard of. + const clean = fakeNode(); + await P.syncWith(clean, USER); + const oldKeyNode = fakeNode(); + for (const [kind, r] of clean.rows) { + oldKeyNode.rows.set(kind, { rev: r.rev, blob: await seal( + kind === MANIFEST_KIND ? { v: 1, rev: r.rev, playlists: {} } + : { id: kind, rev: r.rev, tracks: [] }, + kind, USER, junkKey) }); + } + oldKeyNode.rows.set(bodyKind('never-seen-here'), { rev: 3, blob: await seal( + { id: 'never-seen-here', rev: 3, tracks: [] }, bodyKind('never-seen-here'), USER, junkKey) }); + const rekeyResult = await P.syncWith(oldKeyNode, USER); + const readable = []; + for (const [kind, r] of oldKeyNode.rows) { + try { await open(r.blob, kind, USER, key); readable.push(kind); } catch { /* not */ } + } + steps.push({ + step: 'a node sealed under the previous key', + result: rekeyResult, + kinds: [...clean.rows.keys()].sort(), + readable: readable.sort(), + remaining: [...oldKeyNode.rows.keys()].sort(), + revsNotLowered: [...clean.rows].every(([k, r]) => + (oldKeyNode.rows.get(k) || { rev: -1 }).rev >= r.rev), + }); + // ── what a playlist costs, sealed ───────────────────────────────────── // // The cap below is in bytes, but the only number a reader can act on is a @@ -449,12 +482,12 @@ function fakeNode() { // playlist is not a broken sync. stored: bigNode.stored.map((e) => ({ kind: e.kind, bytes: e.bytes })) }); - // ── a session with no HKDF handle degrades rather than failing ───────── + // ── a session with no bundle key degrades rather than failing ────────── P.setPlaylistTransport(null); P.forgetPlaylistKey(); - session.bundleKey = { v2: session.bundleKey.v2 }; // pre-change session + session.bundleKey = { v2: 'a key from before the pepper' }; // pre-change session const r3 = await P.syncWith(fakeNode(), USER); - steps.push({ step: 'a session from before the HKDF handle', result: r3 }); + steps.push({ step: 'a session from before the pepper', result: r3 }); parent.postMessage({ steps, logs: LOGS.slice(0, 8) }, '*'); } catch (err) { diff --git a/packages/meshbay-hub/tests/harness/playlist_ui_probe.py b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py index c705244..6e3be1e 100644 --- a/packages/meshbay-hub/tests/harness/playlist_ui_probe.py +++ b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py @@ -173,12 +173,11 @@ const clickMenu = async (i) => { try { await initLocale(); - // A real HKDF handle, so the store derives its key the way it really does. + // A real master key, so the store derives its key the way it really does. const raw = new Uint8Array(32).fill(3); 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']), + v3: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey', 'deriveBits']), + pepperVersion: 1, }; // Deleting a playlist asks, in the page (ask.js) — so the probe answers the // dialog the way a person would, by clicking its OK button. diff --git a/packages/meshbay-hub/tests/harness/scroll_probe.py b/packages/meshbay-hub/tests/harness/scroll_probe.py index ae407b8..55d5b2b 100644 --- a/packages/meshbay-hub/tests/harness/scroll_probe.py +++ b/packages/meshbay-hub/tests/harness/scroll_probe.py @@ -32,7 +32,7 @@ STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" # group-page refactor. APP = STATIC / "chat-app.js" PORT = 8736 -FRAG = Path(sys.argv[1]).read_text() +FRAG = Path(sys.argv[1]).read_text(encoding="utf-8") HEIGHTS = ([int(h) for h in sys.argv[2].split(",")] if len(sys.argv) > 2 else [700, 900, 1200]) diff --git a/packages/meshbay-hub/tests/harness/video_series_probe.py b/packages/meshbay-hub/tests/harness/video_series_probe.py new file mode 100644 index 0000000..f161c30 --- /dev/null +++ b/packages/meshbay-hub/tests/harness/video_series_probe.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +""" +What is on screen after watching one episode of a show, and closing the player. + +Playing an episode used to close the show's detail modal, so the next episode +meant opening the show again and picking the season again, every time. The +modal now stays open under the player. That is a claim about three components +at once — `PosterGrid` deciding whether to close it, `GroupPage` mounting the +player beside it, and the stylesheet deciding which of two `.video-overlay`s is +on top — so this renders the shipped `GroupPage` against a stub node and walks +it as a reader would, reading back what is on the page after each step. + +A film goes through the same modal and must still close it: it has nothing left +to pick from. + + video_series_probe.py +""" + +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 = 8761 +RECORDS = [] +socketserver.TCPServer.allow_reuse_address = True + +FRAME = r"""<!doctype html><html><head><meta charset=utf-8> +<link rel="stylesheet" href="/style.css"></head><body> +<nav class="nav"><div class="nav-left"><a class="nav-brand" href="#/">MeshBay</a></div></nav> +<div class="layout"><main class="main"><div id="root"></div></main></div> +<script> +const ENTRIES = []; +let n = 0; +// Two seasons of three episodes, so there is a season to pick and a +// "next episode" after the one played. No thumbnails: a card with no frame to +// fetch is ready at once. +for (let s = 1; s <= 2; s++) { + for (let e = 1; e <= 3; e++) { + ENTRIES.push({ id: 'ep' + s + e, name: 'Some.Show.S0' + s + 'E0' + e + '.mkv', + display_title: 'Some Show', path: 'videos/Some Show/Season ' + s, + type: 'video', season: s, episode: e, duration: 2600, size: 1024, + added_at: 1750000000 + (++n) }); + } +} +ENTRIES.push({ id: 'film', name: 'A.Film.mkv', display_title: 'A Film', + path: 'videos/films', type: 'video', duration: 6000, size: 1024, + added_at: 1750000000 + (++n) }); + +const ACK = { + is_node_admin: false, + enabled_apps: ['video'], + tmdb_enabled: true, tmdb_language: 'en-US', + video_directories: ['videos'], music_directories: [], photo_directories: [], +}; + +window.MeshBayTransport = function () { + const self = { + connected: false, memberRole: 'member', supportsAppOps: true, + sessionKeys: null, gekRaw: null, + newNodeBundle: null, newNodeBundleRecovery: null, + async connect() { self.connected = true; return ACK; }, + async fetchIndex() { + return { entries: ENTRIES, dirs: ['videos'], + roots: [{ name: 'videos', available: true, writable: false, + removable: false }] }; + }, + // Unmatched: the modal still opens for both, and nothing here depends on + // what TMDB would have said. + async fetchMediaMeta() { return { confidence: 0 }; }, + addReconnectListener() { return () => {}; }, + close() {}, + }; + // Everything else the player asks for never answers: it sits on its + // spinner, which is all a stacking and a close need. + return new Proxy(self, { + get(target, prop) { + if (prop in target) return target[prop]; + if (typeof prop === 'string' && prop.startsWith('on')) return undefined; + if (typeof prop === 'symbol') return undefined; + return () => new Promise(() => {}); + }, + set(target, prop, value) { target[prop] = value; return true; }, + }); +}; +</script> +<script type="module"> +import { html, render } from '/vendor/htm-preact.js'; +import { initLocale } from '/i18n.js'; +import { GroupPage } from '/group-page.js'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const $ = (sel) => document.querySelector(sel); +const $$ = (sel) => [...document.querySelectorAll(sel)]; +async function until(pred, what) { + for (let i = 0; i < 100; i++) { if (pred()) return; await sleep(50); } + throw new Error('timed out waiting for ' + what); +} + +try { localStorage.removeItem('meshbay_video_view_mode'); } catch {} + +const player = () => $('.video-player-overlay'); +// Which overlay a click in the middle of the window would reach. +const topmost = () => { + const el = document.elementFromPoint(innerWidth / 2, innerHeight / 2); + if (!el) return null; + if (el.closest('.video-player-overlay')) return 'player'; + if (el.closest('.video-detail') || el.closest('.video-overlay')) return 'detail'; + return 'page'; +}; +const state = () => ({ + detail: !!$('.video-detail'), + player: !!player(), + topmost: topmost(), + season: ($('.video-season-current') || {}).textContent?.trim() || null, + marked: $$('.video-episode-row.last-played').map( + (r) => r.querySelector('.video-episode-label').textContent.replace(/\s+/g, ' ').trim()), + rows: $$('.video-episode-row').map( + (r) => r.querySelector('.video-episode-label').textContent.replace(/\s+/g, ' ').trim()), +}); + +(async () => { + const out = {}; + try { + await initLocale(); + render(html`<${GroupPage} groupId="g1" token="t" username="me" userId="u1" + group=${{ id: 'g1', name: 'a group', owner_username: 'me', is_admin: false }} + userPrefs=${{ default_tab: 'video', media_page_size: '50' }} />`, + document.getElementById('root')); + + await until(() => $$('.video-card-title').length === 2, 'two cards'); + const card = (title) => $$('.video-card').find( + (c) => c.querySelector('.video-card-title').textContent.trim().startsWith(title)); + + // The show: open it, go to season 2, play its second episode. + card('Some Show').click(); + await until(() => $('.video-season-trigger'), 'the show modal'); + $('.video-season-trigger').click(); + await until(() => $$('.video-season-option').length === 2, 'the season menu'); + $$('.video-season-option')[1].click(); + await sleep(100); + $$('.video-episode-row')[1].click(); + await until(() => player() && player().querySelector('.video-top-bar'), 'the player'); + await sleep(100); + out.playing = state(); + + // Esc is how most people leave a player. + dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await until(() => !player(), 'the player to close on Esc'); + await sleep(100); + out.afterEscape = state(); + + // Next episode, straight from the modal, then the player's own close button. + $$('.video-episode-row')[2].click(); + await until(() => player() && player().querySelector('.video-top-bar .video-close'), + 'the player again'); + const closes = player().querySelectorAll('.video-top-bar .video-close'); + closes[closes.length - 1].click(); + await until(() => !player(), 'the player to close on its button'); + await sleep(100); + out.afterClose = state(); + + // Closing the modal itself still closes it. + $('.video-detail .video-top-bar .video-close').click(); + await sleep(100); + out.afterModalClose = state(); + + // Reopening the show starts afresh: no mark carried over from last time. + card('Some Show').click(); + await until(() => $('.video-detail'), 'the show modal again'); + await sleep(100); + out.reopened = state(); + $('.video-detail .video-top-bar .video-close').click(); + await sleep(100); + + // A film: its modal closes when it starts, as it always did. + card('A Film').click(); + await until(() => $('.video-detail .admin-btn'), 'the film modal'); + $('.video-detail .admin-btn').click(); + await until(() => player(), 'the film player'); + await sleep(100); + out.film = state(); + + parent.postMessage(out, '*'); + } catch (err) { + parent.postMessage({ ...out, error: String((err && err.stack) || err) }, '*'); + } +})(); +</script></body></html>""" + +PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head> +<body style="margin:0"><iframe src="/case" style="width:1100px;height:800px;border:0"></iframe> +<script> +addEventListener('message', (e) => fetch('/log', { method: 'POST', body: JSON.stringify(e.data) })); +</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") + elif path == "/v1/groups/g1/nodes": + self._send(b'{"nodes": [{"node_id": "n1"}]}', "application/json") + 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=1100,900", + f"http://127.0.0.1:{PORT}/"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + deadline = time.time() + 90 + while not RECORDS and time.time() < deadline: + time.sleep(0.2) + proc.terminate() + proc.wait(timeout=20) + if not RECORDS: + print("the page never reported", file=sys.stderr) + return 1 + print(json.dumps(RECORDS[0])) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) |