diff options
Diffstat (limited to 'packages/meshbay-hub/tests')
84 files changed, 1764 insertions, 260 deletions
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/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()) diff --git a/packages/meshbay-hub/tests/test_account_pinning.py b/packages/meshbay-hub/tests/test_account_pinning.py index 529ebd5..ebd29bd 100644 --- a/packages/meshbay-hub/tests/test_account_pinning.py +++ b/packages/meshbay-hub/tests/test_account_pinning.py @@ -102,11 +102,11 @@ def _entry(user, pk_ed, pk_x="cGtY", *, added_by="", sk_signer=None, def _verify(devices, node_pk=NODE_PK): with tempfile.TemporaryDirectory() as tmp: h = Path(tmp) / "h.js" - h.write_text(_HARNESS) + h.write_text(_HARNESS, encoding="utf-8") payload = Path(tmp) / "in.json" payload.write_text(json.dumps( {"payload": {"devices": devices, "node_pk": node_pk}, - "node_pk": node_pk})) + "node_pk": node_pk}), encoding="utf-8") run = subprocess.run( ["node", str(h), str(CRYPTO), transport_argv(), str(payload)], capture_output=True, timeout=60) diff --git a/packages/meshbay-hub/tests/test_argon2_off_loop.py b/packages/meshbay-hub/tests/test_argon2_off_loop.py index 5c25a8e..ae6cc08 100644 --- a/packages/meshbay-hub/tests/test_argon2_off_loop.py +++ b/packages/meshbay-hub/tests/test_argon2_off_loop.py @@ -28,7 +28,7 @@ def test_nothing_derives_argon2_on_the_event_loop(): for path in SRC.rglob("*.py"): if path.name == "auth.py": continue - for n, line in enumerate(path.read_text().splitlines(), 1): + for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): if direct.search(line): offenders.append(f"{path.relative_to(SRC)}:{n}: {line.strip()}") assert not offenders, ( diff --git a/packages/meshbay-hub/tests/test_asset_versioning.py b/packages/meshbay-hub/tests/test_asset_versioning.py index aa2dc6d..7057311 100644 --- a/packages/meshbay-hub/tests/test_asset_versioning.py +++ b/packages/meshbay-hub/tests/test_asset_versioning.py @@ -135,7 +135,7 @@ def test_a_new_file_moves_the_fingerprint(): before = _asset_version() extra = STATIC_DIR / "locales" / "zz-test-only.js" try: - extra.write_text("export default {};\n") + extra.write_text("export default {};\n", encoding="utf-8") assert _asset_version() != before finally: extra.unlink() diff --git a/packages/meshbay-hub/tests/test_availability_between_members.py b/packages/meshbay-hub/tests/test_availability_between_members.py index 4f5cbfb..24d85a0 100644 --- a/packages/meshbay-hub/tests/test_availability_between_members.py +++ b/packages/meshbay-hub/tests/test_availability_between_members.py @@ -482,13 +482,14 @@ async def test_changing_your_address_cannot_mail_strangers_at_will( user = await _make_user(client, "av_mailer") headers = {"Authorization": f"Bearer {user['token']}"} + ak = base64.b64encode(b"k" * 32).decode() # the auth_key _make_user signs up with r = await client.patch("/v1/users/me", headers=headers, - json={"email": "a-stranger@example.test"}) + json={"email": "a-stranger@example.test", "auth_key": ak}) assert r.status_code == 200, r.text assert len(sent) == 1 r = await client.patch("/v1/users/me", headers=headers, - json={"email": "another-stranger@example.test"}) + json={"email": "another-stranger@example.test", "auth_key": ak}) assert r.status_code == 429, r.text assert len(sent) == 1, "the hub mailed a second stranger on demand" @@ -536,7 +537,7 @@ def test_no_mail_is_sent_from_the_event_loop(): for path in root.rglob("*.py"): if path.name == "mail.py": continue - for n, line in enumerate(path.read_text().splitlines(), 1): + for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): if direct_call.search(line): offenders.append(f"{path.name}:{n}: {line.strip()}") assert not offenders, ( diff --git a/packages/meshbay-hub/tests/test_browser_idle_signout.py b/packages/meshbay-hub/tests/test_browser_idle_signout.py index 50f7f04..454874a 100644 --- a/packages/meshbay-hub/tests/test_browser_idle_signout.py +++ b/packages/meshbay-hub/tests/test_browser_idle_signout.py @@ -84,7 +84,7 @@ def outcome(): pytest.skip("node is not available") proc = subprocess.run( [NODE, "--input-type=module", "--eval", HARNESS, IDLE.as_uri()], - capture_output=True, text=True, timeout=30) + capture_output=True, text=True, encoding="utf-8", timeout=30) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout.strip().splitlines()[-1]) diff --git a/packages/meshbay-hub/tests/test_bundle_kdf_parity.py b/packages/meshbay-hub/tests/test_bundle_kdf_parity.py index 27e10d4..c62aace 100644 --- a/packages/meshbay-hub/tests/test_bundle_kdf_parity.py +++ b/packages/meshbay-hub/tests/test_bundle_kdf_parity.py @@ -77,19 +77,19 @@ const argon2 = require(process.argv[3]); def js_hashes(tmp_path_factory): d = tmp_path_factory.mktemp("kdf") harness = d / "harness.cjs" - harness.write_text(_HARNESS) + harness.write_text(_HARNESS, encoding="utf-8") vectors = [ {"username": u, "password": p, "mem": MEM_KIB, "time": TIME_COST, "lanes": LANES} for u in CASES for p in PASSWORDS ] payload = d / "vectors.json" - payload.write_text(json.dumps(vectors)) + payload.write_text(json.dumps(vectors), encoding="utf-8") proc = subprocess.run( ["node", str(harness), str(VENDOR / "argon2.wasm"), str(VENDOR / "argon2.min.js"), str(payload)], - capture_output=True, text=True, timeout=300, + capture_output=True, text=True, encoding="utf-8", timeout=300, ) if proc.returncode != 0: pytest.fail(f"node harness failed:\n{proc.stderr[-2000:]}") @@ -124,7 +124,7 @@ def test_parameters_still_match_the_client(): The numbers live in keyderive.js; this test is the second copy. Tuning one without the other orphans every bundle already written, so make it fail. """ - source = (STATIC / "keyderive.js").read_text() + source = (STATIC / "keyderive.js").read_text(encoding="utf-8") assert f"ARGON2_MEM_KIB = {MEM_KIB}" in source assert f"ARGON2_TIME = {TIME_COST}" in source assert f"ARGON2_LANES = {LANES}" in source diff --git a/packages/meshbay-hub/tests/test_captcha_host_check.py b/packages/meshbay-hub/tests/test_captcha_host_check.py index 778009f..a0ff509 100644 --- a/packages/meshbay-hub/tests/test_captcha_host_check.py +++ b/packages/meshbay-hub/tests/test_captcha_host_check.py @@ -208,7 +208,7 @@ def test_hub_toml_carries_the_allowed_hosts(tmp_path): '[captcha]\n' 'site_key = "6Lsite"\n' 'secret_key = "6Lsecret"\n' - 'allowed_hosts = ["meshbay.org", " meshbay ", "", "localhost"]\n') + 'allowed_hosts = ["meshbay.org", " meshbay ", "", "localhost"]\n', encoding="utf-8") cfg = load_config(cfg_file) @@ -222,7 +222,8 @@ def test_a_hub_toml_without_the_key_checks_nothing(tmp_path): """The upgrade path. A hub that never heard of this setting keeps the behaviour it has, with reCAPTCHA doing the origin check.""" cfg_file = tmp_path / "hub.toml" - cfg_file.write_text('[captcha]\nsite_key = "6Lsite"\nsecret_key = "6Lsecret"\n') + cfg_file.write_text('[captcha]\nsite_key = "6Lsite"\nsecret_key = "6Lsecret"\n', + encoding="utf-8") assert load_config(cfg_file).captcha.host_check is None @@ -231,10 +232,10 @@ def test_the_unattributed_flag_comes_from_the_config(tmp_path, monkeypatch): cfg_file = tmp_path / "hub.toml" cfg_file.write_text( '[captcha]\nsite_key = "k"\nsecret_key = "s"\n' - 'allowed_hosts = ["meshbay.org"]\nallow_unattributed_host = true\n') + 'allowed_hosts = ["meshbay.org"]\nallow_unattributed_host = true\n', encoding="utf-8") assert load_config(cfg_file).captcha.allow_unattributed_host is True - cfg_file.write_text('[captcha]\nsite_key = "k"\nsecret_key = "s"\n') + cfg_file.write_text('[captcha]\nsite_key = "k"\nsecret_key = "s"\n', encoding="utf-8") assert load_config(cfg_file).captcha.allow_unattributed_host is False, ( "the default has to stay off — it is the looser of the two") diff --git a/packages/meshbay-hub/tests/test_cast_subtitles.py b/packages/meshbay-hub/tests/test_cast_subtitles.py index fdf7fcc..bdc279b 100644 --- a/packages/meshbay-hub/tests/test_cast_subtitles.py +++ b/packages/meshbay-hub/tests/test_cast_subtitles.py @@ -66,7 +66,7 @@ def _run(tmp_path, body: str, *args: str): + "\n" + body, encoding="utf-8") proc = subprocess.run( ["node", str(script), *args], - capture_output=True, text=True, timeout=30) + capture_output=True, text=True, encoding="utf-8", timeout=30) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) @@ -260,7 +260,7 @@ def served(tmp_path_factory): script.write_text(RELAY_SCRIPT, encoding="utf-8") proc = subprocess.run( ["node", str(script), str(RELAY)], - capture_output=True, text=True, timeout=60) + capture_output=True, text=True, encoding="utf-8", timeout=60) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout.strip().splitlines()[-1]) @@ -363,7 +363,7 @@ def loaded(tmp_path_factory): script.write_text(CHROMECAST_SCRIPT, encoding="utf-8") proc = subprocess.run( ["node", str(script), str(CHROMECAST)], - capture_output=True, text=True, timeout=60) + capture_output=True, text=True, encoding="utf-8", timeout=60) if proc.returncode != 0: pytest.skip(f"cast-chromecast.js is not loadable here: {proc.stderr}") return json.loads(proc.stdout) diff --git a/packages/meshbay-hub/tests/test_challenge_signature_client.py b/packages/meshbay-hub/tests/test_challenge_signature_client.py index e3c33ac..b904698 100644 --- a/packages/meshbay-hub/tests/test_challenge_signature_client.py +++ b/packages/meshbay-hub/tests/test_challenge_signature_client.py @@ -93,11 +93,11 @@ def test_the_browser_holds_the_node_to_its_challenge(tmp_path): "garbage for a signature": (case(sig=b"\x00" * 64), "refused"), } harness = tmp_path / "harness.js" - harness.write_text(_HARNESS) + harness.write_text(_HARNESS, encoding="utf-8") payload = tmp_path / "cases.json" - payload.write_text(json.dumps([c for c, _ in cases.values()])) + payload.write_text(json.dumps([c for c, _ in cases.values()]), encoding="utf-8") proc = subprocess.run(["node", str(harness), str(STATIC), str(payload), transport_argv()], - capture_output=True, text=True, timeout=60) + capture_output=True, text=True, encoding="utf-8", timeout=60) assert proc.returncode == 0, proc.stderr got = dict(zip(cases, json.loads(proc.stdout))) assert got == {name: want for name, (_, want) in cases.items()} diff --git a/packages/meshbay-hub/tests/test_chat_scroll_bottom.py b/packages/meshbay-hub/tests/test_chat_scroll_bottom.py index 7b66c00..42b1055 100644 --- a/packages/meshbay-hub/tests/test_chat_scroll_bottom.py +++ b/packages/meshbay-hub/tests/test_chat_scroll_bottom.py @@ -42,7 +42,7 @@ pytestmark = pytest.mark.skipif(not CHAT.exists(), reason="SPA sources not prese def _chat_panel_source() -> str: - src = CHAT.read_text() + src = CHAT.read_text(encoding="utf-8") start = src.index("\nfunction ChatPanel(") end = src.find("\nfunction ", start + 1) return src[start:end if end != -1 else len(src)] diff --git a/packages/meshbay-hub/tests/test_client_version_gate.py b/packages/meshbay-hub/tests/test_client_version_gate.py index 4dc9b98..91cb99a 100644 --- a/packages/meshbay-hub/tests/test_client_version_gate.py +++ b/packages/meshbay-hub/tests/test_client_version_gate.py @@ -32,7 +32,7 @@ pytestmark = pytest.mark.skipif( def _lift(name: str) -> str: - src = MAIN.read_text() + src = MAIN.read_text(encoding="utf-8") cut = src[src.index(name):] return cut[:cut.index("\n}\n") + 2] @@ -69,8 +69,8 @@ out.compare = [ compareVersions('nonsense', '1.1.0'), ]; console.log(JSON.stringify(out)); -""") - proc = subprocess.run(["node", str(script)], capture_output=True, text=True) +""", encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) @@ -133,7 +133,7 @@ def test_a_first_run_with_no_hub_yet_is_not_stopped(tmp_path): def test_the_gate_runs_before_the_window_is_built(): """A window that opens and then cannot connect is the failure this replaces, so the order is the whole point.""" - src = MAIN.read_text() + src = MAIN.read_text(encoding="utf-8") ready = src[src.index("app.whenReady().then("):] ready = ready[:ready.index("createWindow();")] assert "await refuseIfTooOld()" in ready, ( diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py index 395053b..c8c68a9 100644 --- a/packages/meshbay-hub/tests/test_downloads.py +++ b/packages/meshbay-hub/tests/test_downloads.py @@ -26,7 +26,7 @@ pytestmark = pytest.mark.skipif( def _run(body, tmp_path): module = tmp_path / "downloads.mjs" - module.write_text(DOWNLOADS.read_text()) + module.write_text(DOWNLOADS.read_text(encoding="utf-8"), encoding="utf-8") script = tmp_path / "case.mjs" script.write_text( # A localStorage good enough for a preference, so the module can be @@ -36,12 +36,12 @@ def _run(body, tmp_path): " getItem: k => (store.has(k) ? store.get(k) : null),\n" " setItem: (k, v) => store.set(k, String(v)),\n" "};\n" - f"const M = await import('{module.as_posix()}');\n" + f"const M = await import('{module.as_uri()}');\n" "const out = [];\n" "const say = (...a) => out.push(...a);\n" f"{body}\n" - "console.log(JSON.stringify(out));\n") - proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + "console.log(JSON.stringify(out));\n", encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) @@ -104,7 +104,7 @@ def test_the_open_action_reads_the_file_back(tmp_path): manager either. It is only offered for a file written into a granted folder, since that is the one a page can read back. """ - src = DOWNLOADS.read_text() + src = DOWNLOADS.read_text(encoding="utf-8") target = src[src.index("export async function openTarget"):] assert "getFile()" in target and "window.open(" in target assert "revokeObjectURL" in target, "the blob URL must not be leaked" @@ -122,7 +122,7 @@ def test_the_worker_only_answers_its_own_urls(): service worker that answers more than it should is a cache bug waiting to happen. """ - src = SW.read_text() + src = SW.read_text(encoding="utf-8") assert "startsWith(PREFIX)" in src assert "self.location.origin" in src, "cross-origin requests must fall through" # The API, not the word: the file explains in prose that it caches nothing. @@ -131,7 +131,7 @@ def test_the_worker_only_answers_its_own_urls(): def test_the_download_is_announced_as_an_attachment(): - src = SW.read_text() + src = SW.read_text(encoding="utf-8") assert "Content-Disposition" in src and "attachment" in src assert "filename*=UTF-8''" in src, "a name with accents would be mangled" assert "Content-Length" in src @@ -142,7 +142,7 @@ def test_a_length_is_only_promised_when_it_is_known(tmp_path): An archive is assembled as it goes and is larger than the files in it. Announcing the sum of their sizes would truncate the download at that mark. """ - src = SW.read_text() + src = SW.read_text(encoding="utf-8") assert "if (entry.size > 0)" in src # The zip-directory download started in files-app.js (group-page refactor) @@ -153,7 +153,7 @@ def test_a_length_is_only_promised_when_it_is_known(tmp_path): # became a bare `target = ...` inside a try when _openDownloadTarget gained # the ability to refuse an oversized download (test_memory_ceiling.py). # What this test is about -- the `0` -- did not move. - app = (STATIC / "file-utils.js").read_text() + app = (STATIC / "file-utils.js").read_text(encoding="utf-8") # Anchored on the argument list, not on the function name: the call became # `_openTargetInTurn(suggested, …)` when target openings were serialised. # The `0` this test is about did not move. @@ -169,7 +169,7 @@ def test_backpressure_is_real(tmp_path): is transferred gives `writer.write()` something to wait on; posting chunks to a port would queue them in memory and look identical from here. """ - src = DOWNLOADS.read_text() + src = DOWNLOADS.read_text(encoding="utf-8") fn = src[src.index("export async function openStreamedDownload"):] assert "new TransformStream()" in fn # The transfer list may carry more than the stream — a reply port rides @@ -210,13 +210,13 @@ def test_the_streamed_path_gives_up_rather_than_blocking_for_ever(): So the worker confirms that it actually answered, and this path reports failure instead of returning a sink nobody drains. """ - src = DOWNLOADS.read_text() + src = DOWNLOADS.read_text(encoding="utf-8") fn = src[src.index("export async function openStreamedDownload"):] assert "mbdl-serving" in fn, "the worker has to confirm it served the request" assert "Promise.race" in fn, "the confirmation needs a deadline" assert "writable.abort" in fn, "give up cleanly so the caller can fall back" - sw = (DOWNLOADS.parent / "sw.js").read_text() + sw = (DOWNLOADS.parent / "sw.js").read_text(encoding="utf-8") assert "mbdl-serving" in sw, "and the worker has to send that confirmation" @@ -227,7 +227,7 @@ def test_an_uncontrolled_page_is_not_treated_as_ready(): # became a parameter, and `serviceWorker()` no longer contains the words. # The behaviour itself is executed in test_streamed_download_reliability.py; # this stays as the cheap guard on the module's shape. - src = DOWNLOADS.read_text() + src = DOWNLOADS.read_text(encoding="utf-8") section = src[src.index("// ── Streaming to disk"):] assert "navigator.serviceWorker.controller" in section assert "controllerchange" in section, ( @@ -251,7 +251,7 @@ def test_an_apostrophe_in_a_name_does_not_lose_the_name(tmp_path): The real function is lifted out of sw.js and run — a second copy here would have the same blind spot as the first. """ - src = SW.read_text() + src = SW.read_text(encoding="utf-8") fn = src[src.index("function contentDisposition"):] fn = fn[:fn.index("\n}") + 2] @@ -263,8 +263,8 @@ for (const name of ["S03E02. Queen's Landing.mp4", 'Caf\\u00e9 (2019).mkv', out[name] = contentDisposition(name); } console.log(JSON.stringify(out)); -""") - proc = subprocess.run(["node", str(script)], capture_output=True, text=True) +""", encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr out = json.loads(proc.stdout) @@ -304,7 +304,7 @@ def test_a_sink_that_stops_consuming_fails_instead_of_hanging(tmp_path): wrong. Bounding it does not fix whatever stopped the sink — it turns an unexplainable freeze into a failed transfer that names itself. """ - src = (STATIC / "file-utils.js").read_text() + src = (STATIC / "file-utils.js").read_text(encoding="utf-8") fn = src[src.index("async function _writeOrStall"):] fn = fn[:fn.index("\n}\n") + 2] @@ -328,8 +328,8 @@ const live = { write: async () => {} }; await _writeOrStall(live, new Uint8Array(4), 0); out.liveOk = true; console.log(JSON.stringify(out)); -""") - proc = subprocess.run(["node", str(script)], capture_output=True, text=True) +""", encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr out = json.loads(proc.stdout) assert out["threw"], "a dead sink hung for ever instead of failing" @@ -358,8 +358,8 @@ def test_the_worker_is_kept_alive_while_it_streams(): clock. What it protects is that the ping exists at all, is cleared on both exits, and is answered by the worker. """ - dl = DOWNLOADS.read_text() - sw = SW.read_text() + dl = DOWNLOADS.read_text(encoding="utf-8") + sw = SW.read_text(encoding="utf-8") assert "SW_KEEPALIVE_MS" in dl and "mbdl-ping" in dl, ( "nothing keeps the worker alive; downloads longer than ~30 s will " @@ -389,7 +389,7 @@ def _turn_harness(tmp_path, name, body, *, picker=True, budget_ms=90000): the budget is supplied here, so a case about the budget need not wait a minute and a half for it. """ - src = (STATIC / "file-utils.js").read_text() + src = (STATIC / "file-utils.js").read_text(encoding="utf-8") def lift(decl): cut = src[src.index(decl):] @@ -422,8 +422,8 @@ const TARGET_QUEUE_BUDGET_MS = {budget_ms}; """ + lift("function _openTargetInTurn") + lift("function _waitBriefly") + f""" {body} console.log(JSON.stringify(out)); -""") - proc = subprocess.run(["node", str(script)], capture_output=True, text=True) +""", encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) @@ -518,7 +518,7 @@ def test_a_pause_falls_between_chunks_and_resumes_at_one(tmp_path): rule this repo follows for the video player: model the environment, never the code under test. """ - src = (STATIC / "file-utils.js").read_text() + src = (STATIC / "file-utils.js").read_text(encoding="utf-8") fn = src[src.index("async function pipelinedDownload"):] fn = fn[:fn.index("\n}\n") + 2] @@ -558,8 +558,8 @@ await pipelinedDownload({}, 'k', 'file', 10, () => {}, {}, signal, '', out.resumeFrom); out.writtenAfterResume = written.slice(); console.log(JSON.stringify(out)); -""") - proc = subprocess.run(["node", str(script)], capture_output=True, text=True) +""", encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr out = json.loads(proc.stdout) diff --git a/packages/meshbay-hub/tests/test_email_change_requires_passphrase.py b/packages/meshbay-hub/tests/test_email_change_requires_passphrase.py new file mode 100644 index 0000000..697e6f9 --- /dev/null +++ b/packages/meshbay-hub/tests/test_email_change_requires_passphrase.py @@ -0,0 +1,55 @@ +"""Changing the address on file requires the passphrase, not merely a token. + +A member hands its hub access token to every node it connects to (the MNP +handshake), so a node operator holds a live bearer token for that member. +`PATCH /v1/users/me {email}` used to need only that token, and the confirmation +code goes to the *new* address — so an operator could point the account's e-mail +at their own inbox, confirm it, and then use the passphrase-reset path to take +the account over. The passphrase (as the derived auth_key, which is all the hub +ever sees) is now required, exactly as for a passphrase change or an account +deletion. +""" + +import pytest + + +async def _account(client, username="mail_pass_test", auth_key="k" * 44): + await client.post("/v1/users/register", json={ + "username": username, "email": f"{username}@test.local", "auth_key": auth_key}) + r = await client.post("/v1/users/login", json={ + "username": username, "auth_key": auth_key}) + return r.json()["access_token"] + + +@pytest.mark.asyncio +async def test_email_change_without_passphrase_is_refused(client): + tok = await _account(client) + r = await client.patch("/v1/users/me", headers={"Authorization": f"Bearer {tok}"}, + json={"email": "attacker@evil.invalid"}) + assert r.status_code == 403 + + +@pytest.mark.asyncio +async def test_email_change_with_wrong_passphrase_is_refused(client): + tok = await _account(client) + r = await client.patch("/v1/users/me", headers={"Authorization": f"Bearer {tok}"}, + json={"email": "attacker@evil.invalid", "auth_key": "z" * 44}) + assert r.status_code == 403 + + +@pytest.mark.asyncio +async def test_email_change_with_correct_passphrase_proceeds(client): + tok = await _account(client, username="mail_ok_test", auth_key="k" * 44) + r = await client.patch("/v1/users/me", headers={"Authorization": f"Bearer {tok}"}, + json={"email": "new@real.invalid", "auth_key": "k" * 44}) + assert r.status_code == 200 + assert r.json().get("email_verification_required") is True + + +@pytest.mark.asyncio +async def test_profile_patch_without_email_needs_no_passphrase(client): + """Regression: a PATCH that does not change the address is unaffected.""" + tok = await _account(client, username="mail_noop_test") + r = await client.patch("/v1/users/me", headers={"Authorization": f"Bearer {tok}"}, + json={}) + assert r.status_code == 200 diff --git a/packages/meshbay-hub/tests/test_files_drop_upload.py b/packages/meshbay-hub/tests/test_files_drop_upload.py index fc15c47..aa39f30 100644 --- a/packages/meshbay-hub/tests/test_files_drop_upload.py +++ b/packages/meshbay-hub/tests/test_files_drop_upload.py @@ -41,7 +41,7 @@ def source(): def _run(tmp_path, source, expr): script = tmp_path / "case.js" script.write_text(f"{source}\nconsole.log(JSON.stringify({expr}));", encoding="utf-8") - out = subprocess.run(["node", str(script)], capture_output=True, text=True, check=True) + out = subprocess.run(["node", str(script)], capture_output=True, encoding="utf-8", check=True) return json.loads(out.stdout) diff --git a/packages/meshbay-hub/tests/test_files_sorting.py b/packages/meshbay-hub/tests/test_files_sorting.py index 730d1e8..f3aaf34 100644 --- a/packages/meshbay-hub/tests/test_files_sorting.py +++ b/packages/meshbay-hub/tests/test_files_sorting.py @@ -38,7 +38,7 @@ def _names(tmp_path, source, rows, key, asc): f"{source}\nconsole.log(JSON.stringify(" f"sortRows({json.dumps(rows)}, {json.dumps(key)}, {json.dumps(asc)}).map((r) => r.name)));", encoding="utf-8") - out = subprocess.run(["node", str(script)], capture_output=True, text=True, check=True) + out = subprocess.run(["node", str(script)], capture_output=True, encoding="utf-8", check=True) return json.loads(out.stdout) diff --git a/packages/meshbay-hub/tests/test_group_purge.py b/packages/meshbay-hub/tests/test_group_purge.py index 9c6a664..40d2d54 100644 --- a/packages/meshbay-hub/tests/test_group_purge.py +++ b/packages/meshbay-hub/tests/test_group_purge.py @@ -84,7 +84,7 @@ async def _group_with_everything(client, db, owner_token: str, member: str, name ip_address="192.0.2.1")) owner_id = (await db.execute(select(Group.admin_id).where(Group.id == gid))).scalar_one() db.add(GroupInviteLink(group_id=gid, created_by=owner_id, ticket_hash=gid[:8] * 8, - email_hash="1" * 64, email_masked="m***@e***.com", + email_masked="m***@e***.com", expires_at=datetime.now(UTC) + timedelta(days=1), redeemed_by=member_id, redeemed_at=datetime.now(UTC))) await db.commit() diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py index d5ffcfe..0172127 100644 --- a/packages/meshbay-hub/tests/test_hook_ordering.py +++ b/packages/meshbay-hub/tests/test_hook_ordering.py @@ -67,7 +67,7 @@ DEPS = re.compile(r"^ \}, \[([^\]]*)\]\);", re.M) @pytest.fixture(scope="module") def app(): - return APP.read_text() + return APP.read_text(encoding="utf-8") def _components(src: str): @@ -84,7 +84,7 @@ def _all_components(): path = STATIC / name if not path.exists(): continue - for cname, body in _components(path.read_text()): + for cname, body in _components(path.read_text(encoding="utf-8")): yield f"{name}:{cname}", body @@ -147,7 +147,7 @@ def test_the_mse_harness_reads_functions_in_source_order(): running it, which is the one class of defect it would otherwise be well placed to catch. """ - harness = (Path(__file__).parent / "harness" / "mse_harness.mjs").read_text() + harness = (Path(__file__).parent / "harness" / "mse_harness.mjs").read_text(encoding="utf-8") assert "sort" in harness and "indexOf" in harness, ( "the harness still extracts the player functions in a hardcoded order, " "so it cannot see one declared before its own dependency") diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py index 2afd27b..162b074 100644 --- a/packages/meshbay-hub/tests/test_hub_api.py +++ b/packages/meshbay-hub/tests/test_hub_api.py @@ -3,6 +3,7 @@ Integration tests for the Hub API. Uses SQLite in-memory + httpx.AsyncClient — no PostgreSQL, no network. """ +from meshbay_common.tokens import HUB_API_AUD from datetime import UTC import pytest @@ -142,7 +143,7 @@ async def test_jwt_offline_verify(client, hub_key_path): r_pk = await client.get("/v1/hub/pubkey") hub_pk_pem = r_pk.json()["pk_hub_pem"].encode() - decoded = pyjwt.decode(token, hub_pk_pem, algorithms=["EdDSA"]) + decoded = pyjwt.decode(token, hub_pk_pem, algorithms=["EdDSA"], audience=HUB_API_AUD) assert "jti" in decoded # mandatory # The token carries no user key. It used to, and the node recorded it as the # uploader's identity — so whoever issued tokens decided who could delete a @@ -339,7 +340,7 @@ async def test_jwt_contains_groups_claim(client): token_pre = r.json()["access_token"] r_pk = await client.get("/v1/hub/pubkey") hub_pk = r_pk.json()["pk_hub_pem"].encode() - decoded_pre = pyjwt.decode(token_pre, hub_pk, algorithms=["EdDSA"]) + decoded_pre = pyjwt.decode(token_pre, hub_pk, algorithms=["EdDSA"], audience=HUB_API_AUD) assert decoded_pre["groups"] == [] # Alice creates a group and adds Bob @@ -359,13 +360,13 @@ async def test_jwt_contains_groups_claim(client): r = await client.post("/v1/users/login", json={ "username": "grp_bob_test", "password": "bobpass99"}) token_post = r.json()["access_token"] - decoded_post = pyjwt.decode(token_post, hub_pk, algorithms=["EdDSA"]) + decoded_post = pyjwt.decode(token_post, hub_pk, algorithms=["EdDSA"], audience=HUB_API_AUD) assert group_id in decoded_post["groups"] # Alice (admin) should also have the group in her JWT r = await client.post("/v1/users/login", json={ "username": "grp_alice", "password": "alicepass99"}) - decoded_alice = pyjwt.decode(r.json()["access_token"], hub_pk, algorithms=["EdDSA"]) + decoded_alice = pyjwt.decode(r.json()["access_token"], hub_pk, algorithms=["EdDSA"], audience=HUB_API_AUD) assert group_id in decoded_alice["groups"] diff --git a/packages/meshbay-hub/tests/test_incoming_membership.py b/packages/meshbay-hub/tests/test_incoming_membership.py new file mode 100644 index 0000000..708e327 --- /dev/null +++ b/packages/meshbay-hub/tests/test_incoming_membership.py @@ -0,0 +1,76 @@ +"""The NAT-punch signal is not a liveness oracle, and only a member reaches it. + +`POST /v1/nodes/{id}/incoming` used to check nothing but the caller's own +address, then reveal whether the node was connected (404 vs 504) and, with QUIC +on, make it punch. Any authenticated account could poll it for a node's liveness +and make a stranger's node emit a UDP probe. It now requires a shared active +group with the node first — the same gate the offer relay uses — checked before +anything depends on the node's connection state, so a non-member gets one uniform +403 whether the node is connected or not. +""" + +import pytest +from test_availability_between_members import ( + _add_member, + _announce_node, + _make_group, + _make_user, +) + + +def _incoming(client, node_id, user, peer_ip="1.2.3.4", peer_port=5000): + return client.post(f"/v1/nodes/{node_id}/incoming", + json={"peer_ip": peer_ip, "peer_port": peer_port}, + headers={"Authorization": f"Bearer {user['token']}"}) + + +@pytest.mark.asyncio +async def test_a_non_member_is_refused_whether_the_node_is_connected_or_not(client): + from meshbay_hub.api import revocation as rev + + owner = await _make_user(client, "inc_owner") + stranger = await _make_user(client, "inc_stranger") + group_id = await _make_group(client, owner, "inc-group") + node_id = await _announce_node(client, owner) + + # Node NOT in the connected registry — the stranger gets the membership 403 + # (not the connection 404), so the answer says nothing about whether the node + # is up. The detail is what distinguishes it from the peer_ip refusal that a + # request without the gate would give. + r_off = await _incoming(client, node_id, stranger) + assert r_off.status_code == 403 + assert "member" in r_off.json()["detail"] + + # Node connected and serving the group — the stranger, not a member, still gets + # the membership 403, and never reaches the punch or the connection-state answer. + rev._connected_nodes[node_id] = object() + rev._node_groups[node_id] = [group_id] + try: + r_on = await _incoming(client, node_id, stranger) + assert r_on.status_code == 403 + assert "member" in r_on.json()["detail"] + finally: + rev._connected_nodes.pop(node_id, None) + rev._node_groups.pop(node_id, None) + + +@pytest.mark.asyncio +async def test_a_member_passes_the_membership_gate(client): + """A member is not turned away by the gate. (It then reaches the connection + check — 404 here, since no real node socket is registered — never 403.)""" + from meshbay_hub.api import revocation as rev + + owner = await _make_user(client, "inc2_owner") + member = await _make_user(client, "inc2_member") + group_id = await _make_group(client, owner, "inc2-group") + await _add_member(client, owner, group_id, member) + node_id = await _announce_node(client, owner) + + rev._node_groups[node_id] = [group_id] # registered/hosted, but no live socket + try: + r = await _incoming(client, node_id, member) + # Past the membership gate: the refusal, if any, is about the connection + # or the peer address, never "not a member of any group on this node". + assert r.status_code != 403 or "member" not in r.json().get("detail", "") + finally: + rev._node_groups.pop(node_id, None) diff --git a/packages/meshbay-hub/tests/test_index_seal_client.py b/packages/meshbay-hub/tests/test_index_seal_client.py index 20f89d1..f12d791 100644 --- a/packages/meshbay-hub/tests/test_index_seal_client.py +++ b/packages/meshbay-hub/tests/test_index_seal_client.py @@ -69,10 +69,10 @@ def _run(frames: list[str], gek: bytes = GEK) -> dict: with tempfile.TemporaryDirectory() as tmp: vectors = Path(tmp) / "vectors.json" vectors.write_text(json.dumps( - {"gek": gek.hex(), "group_id": GROUP, "frames": frames})) + {"gek": gek.hex(), "group_id": GROUP, "frames": frames}), encoding="utf-8") proc = subprocess.run( ["node", str(PROBE), str(STATIC), str(vectors), transport_argv()], - capture_output=True, text=True, timeout=120) + capture_output=True, text=True, encoding="utf-8", timeout=120) if proc.returncode != 0: pytest.fail(f"probe failed:\n{proc.stderr}") return json.loads(proc.stdout) diff --git a/packages/meshbay-hub/tests/test_indexing_dock.py b/packages/meshbay-hub/tests/test_indexing_dock.py index d69564d..93803a0 100644 --- a/packages/meshbay-hub/tests/test_indexing_dock.py +++ b/packages/meshbay-hub/tests/test_indexing_dock.py @@ -33,11 +33,11 @@ needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node is no def _run(tmp_path, body: str): - (tmp_path / "package.json").write_text('{"type":"module"}') - (tmp_path / "model.js").write_text(MODEL.read_text(encoding="utf-8")) + (tmp_path / "package.json").write_text('{"type":"module"}', encoding="utf-8") + (tmp_path / "model.js").write_text(MODEL.read_text(encoding="utf-8"), encoding="utf-8") script = tmp_path / "case.js" - script.write_text("import * as m from './model.js';\n" + body) - proc = subprocess.run(["node", str(script)], capture_output=True, text=True, + script.write_text("import * as m from './model.js';\n" + body, encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8", cwd=str(tmp_path)) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) diff --git a/packages/meshbay-hub/tests/test_invite_email_choice.py b/packages/meshbay-hub/tests/test_invite_email_choice.py index e04c31f..de9410d 100644 --- a/packages/meshbay-hub/tests/test_invite_email_choice.py +++ b/packages/meshbay-hub/tests/test_invite_email_choice.py @@ -3,8 +3,9 @@ Whether the hub mails an invitation is the inviter's choice, and it is remembere Mailing it hands the hub the code — `invite-notify` writes it into the message — which is exactly what §3.4 says the code is for not doing. So the Members tab -offers it as a box, checked by default, and an unchecked box must mean the hub -is never asked. The choice lives in an account preference; a key the hub does +offers it as a box, unchecked by default (mailing the code is opt-in), and an +unchecked box must mean the hub is never asked. The choice lives in an account +preference, remembered once set; a key the hub does not list is refused, and the box would snap back on every click with nothing on screen to say why. """ diff --git a/packages/meshbay-hub/tests/test_invite_link_client.py b/packages/meshbay-hub/tests/test_invite_link_client.py index 2223ad8..aa8c194 100644 --- a/packages/meshbay-hub/tests/test_invite_link_client.py +++ b/packages/meshbay-hub/tests/test_invite_link_client.py @@ -54,8 +54,8 @@ def _module_body() -> str: def _run(tmp_path, script: str): harness = tmp_path / "h.js" - harness.write_text(script) - out = subprocess.run(["node", str(harness)], capture_output=True, text=True, timeout=60) + harness.write_text(script, encoding="utf-8") + out = subprocess.run(["node", str(harness)], capture_output=True, encoding="utf-8", timeout=60) assert out.returncode == 0, out.stderr return json.loads(out.stdout) @@ -188,7 +188,9 @@ def test_the_members_tab_sends_the_code_only_for_the_mail(): sends = [m.start() for m in re.finditer(r"code: node\.code", settings)] assert len(sends) == 1 before = settings[settings.rfind("\n", 0, sends[0] - 200):sends[0]] - assert "inviteByEmail ?" in before, "the code reaches the hub only when the box asks" + assert "mailIt ?" in before, "the code reaches the hub only when the box asks" + assert "const mailIt = inviteByEmail && Boolean(email);" in settings, ( + "and the box asks only when there is an address to mail") def test_signing_out_forgets_the_invitation(): diff --git a/packages/meshbay-hub/tests/test_invite_links.py b/packages/meshbay-hub/tests/test_invite_links.py index 461cf8a..2217cfe 100644 --- a/packages/meshbay-hub/tests/test_invite_links.py +++ b/packages/meshbay-hub/tests/test_invite_links.py @@ -63,22 +63,16 @@ def sent(monkeypatch): # ── Who gets in ────────────────────────────────────────────────────────────── @pytest.mark.asyncio -async def test_the_addressed_account_joins_and_nobody_else(client, db_session): +async def test_whoever_opens_it_first_joins_and_nobody_after(client, db_session): + # A link travels by any messaging app, so the address the owner typed binds + # nothing: an account registered with another one redeems it. owner = await _account(client, "link_owner") gid = await _group(client, owner) r = await _link(client, owner, gid, email="Invitee@Example.test") assert r.status_code == 201, r.text ticket = r.json()["ticket"] - mallory = await _account(client, "link_mallory") - for route in ("preview", "redeem"): - r = await client.post(f"/v1/invite-links/{route}", json={"ticket": ticket}, - headers=mallory["h"]) - assert r.status_code == 403 and r.json()["detail"] == "invite_other_account" - assert "invitee" not in r.text.lower(), "the refusal must not name the address" - - # Registered with the address the owner typed, case aside. - invitee = await _account(client, "link_invitee", email="invitee@example.test") + invitee = await _account(client, "link_invitee", email="elsewhere@example.test") r = await client.post("/v1/invite-links/preview", json={"ticket": ticket}, headers=invitee["h"]) assert r.status_code == 200, r.text @@ -102,11 +96,30 @@ async def test_the_addressed_account_joins_and_nobody_else(client, db_session): GroupMember.group_id == gid))).scalars().all() assert len(rows) == 2 - # And the one who comes after, even with the right address, gets nothing: - # a twin account cannot exist (addresses are unique), so try the other. - r = await client.post("/v1/invite-links/redeem", json={"ticket": ticket}, - headers=mallory["h"]) - assert r.status_code == 404 + # Whoever comes after, even with the address the owner typed, gets nothing. + late = await _account(client, "link_late", email="invitee@example.test") + for route in ("preview", "redeem"): + r = await client.post(f"/v1/invite-links/{route}", json={"ticket": ticket}, + headers=late["h"]) + assert r.status_code == 404 and r.json()["detail"] == "invite_not_valid" + + +@pytest.mark.asyncio +async def test_a_link_needs_no_address_unless_it_is_mailed(client, db_session, sent): + owner = await _account(client, "noaddr_owner") + gid = await _group(client, owner) + r = await _link(client, owner, gid, email="") + assert r.status_code == 201, r.text + assert r.json()["email_status"] == "not_requested" + row = (await db_session.execute(select(GroupInviteLink))).scalar_one() + assert row.email_masked is None + listed = (await client.get(f"/v1/groups/{gid}/invite-links", + headers=owner["h"])).json()["links"] + assert [link["email"] for link in listed] == [""] + + r = await _link(client, owner, gid, email="", send_email=True, + node_pk=NODE_PK, code=CODE) + assert r.status_code == 422 and sent == [] @pytest.mark.asyncio @@ -115,7 +128,7 @@ async def test_a_ticket_is_stored_only_as_a_hash(client, db_session): gid = await _group(client, owner) ticket = (await _link(client, owner, gid)).json()["ticket"] row = (await db_session.execute(select(GroupInviteLink))).scalar_one() - assert ticket not in (row.ticket_hash, row.email_masked, row.email_hash) + assert ticket not in (row.ticket_hash, row.email_masked) assert row.ticket_hash == invite_links.ticket_hash(ticket) assert "invitee@" not in row.email_masked diff --git a/packages/meshbay-hub/tests/test_layout_measured.py b/packages/meshbay-hub/tests/test_layout_measured.py index 9bf1bde..c5d6280 100644 --- a/packages/meshbay-hub/tests/test_layout_measured.py +++ b/packages/meshbay-hub/tests/test_layout_measured.py @@ -78,7 +78,7 @@ def measured(tmp_path_factory): """One browser for every width, because launching one apiece cost the suite three minutes.""" fragment = tmp_path_factory.mktemp("layout") / "fragment.html" - fragment.write_text(NAV) + fragment.write_text(NAV, encoding="utf-8") proc = subprocess.run( ["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS), str(fragment), *SELECTORS], @@ -211,7 +211,7 @@ GROUPED_SELECTORS = [".transfer-panel", ".transfer-head", ".transfer-head-summar @pytest.fixture(scope="module") def grouped(tmp_path_factory): fragment = tmp_path_factory.mktemp("grouped") / "fragment.html" - fragment.write_text(GROUPED) + fragment.write_text(GROUPED, encoding="utf-8") proc = subprocess.run( ["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS), str(fragment), *GROUPED_SELECTORS], diff --git a/packages/meshbay-hub/tests/test_layout_responsive.py b/packages/meshbay-hub/tests/test_layout_responsive.py index 4ee07d0..bb73557 100644 --- a/packages/meshbay-hub/tests/test_layout_responsive.py +++ b/packages/meshbay-hub/tests/test_layout_responsive.py @@ -38,7 +38,7 @@ pytestmark = pytest.mark.skipif( @pytest.fixture(scope="module") def css(): - return CSS.read_text() + return CSS.read_text(encoding="utf-8") def _rule(css: str, selector: str) -> str: @@ -119,7 +119,7 @@ APP = STATIC / "chat-app.js" @pytest.fixture(scope="module") def app(): - return APP.read_text() + return APP.read_text(encoding="utf-8") def test_the_chat_panel_is_measured_not_guessed(app): diff --git a/packages/meshbay-hub/tests/test_lazy_load_failure.py b/packages/meshbay-hub/tests/test_lazy_load_failure.py new file mode 100644 index 0000000..00c2030 --- /dev/null +++ b/packages/meshbay-hub/tests/test_lazy_load_failure.py @@ -0,0 +1,60 @@ +""" +A view whose module cannot be fetched says so, instead of spinning for good. + +Found live after a hub deploy: the hub serves the module graph under +`/a/<hash>/` for the current hash only, so a tab opened before the deploy asked +for Search, Videos, Music, Photos and the player under a prefix that now +answers 404. `lazy.js` swallowed the rejection and kept the placeholder, so +every one of them showed a spinner for ever, with an empty console, while +Files and Chat — loaded before the deploy — worked. A reload fixed it, and +nothing on screen said so. + +Measured in a browser: a rejected dynamic import is the one thing no source +reading can produce. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +HARNESS = Path(__file__).parent / "harness" / "lazy_failure_probe.py" +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" + +pytestmark = pytest.mark.skipif( + shutil.which("google-chrome") is None or not (STATIC / "lazy.js").exists(), + reason="Chrome or the SPA sources are not available") + + +@pytest.fixture(scope="module") +def probe(): + run = subprocess.run(["python3", str(HARNESS)], capture_output=True, timeout=120) + assert run.returncode == 0, run.stderr.decode()[-2000:] + out = json.loads(run.stdout.decode()) + assert "error" not in out, out["error"] + return out + + +@pytest.mark.parametrize("view", ["plain", "framed"]) +def test_a_module_that_answers_404_is_not_a_spinner(probe, view): + assert not probe[view]["spinner"], "still spinning over a module that will never come" + assert probe[view]["notice"], "nothing on screen says the view failed to load" + assert probe[view]["buttons"], "no way offered to reload" + + +def test_the_failure_reaches_the_console(probe): + """An empty console is what made this cost a scare instead of a glance.""" + assert len(probe["errors"]) == 2 + assert all("could not load" in e for e in probe["errors"]) + + +def test_an_overlay_fails_inside_its_overlay_and_can_be_closed(probe): + assert probe["framed"]["overlay"], "the player's notice landed outside its overlay" + assert len(probe["framed"]["buttons"]) == 2 + assert probe["closed"] == 1, "the Close button did not reach the caller's onClose" + assert len(probe["plain"]["buttons"]) == 1, "no onClose, so no Close button" + + +def test_a_module_that_exists_still_renders(probe): + assert probe["fine"] diff --git a/packages/meshbay-hub/tests/test_locales.py b/packages/meshbay-hub/tests/test_locales.py index 602d161..05e1115 100644 --- a/packages/meshbay-hub/tests/test_locales.py +++ b/packages/meshbay-hub/tests/test_locales.py @@ -35,19 +35,20 @@ EXPECTED = ["en", "fr", "es", "pt-BR", "zh-CN", "ja", "de", "it", "nl", "pl"] def _sandbox(tmp_path): """A directory node will treat as ESM, holding the real sources.""" - (tmp_path / "package.json").write_text('{"type":"module"}') + (tmp_path / "package.json").write_text('{"type":"module"}', encoding="utf-8") (tmp_path / "locales").mkdir(exist_ok=True) for src in LOCALES.glob("*.js"): - (tmp_path / "locales" / src.name).write_text(src.read_text()) - (tmp_path / "i18n.js").write_text(I18N.read_text()) + (tmp_path / "locales" / src.name).write_text(src.read_text(encoding="utf-8"), + encoding="utf-8") + (tmp_path / "i18n.js").write_text(I18N.read_text(encoding="utf-8"), encoding="utf-8") return tmp_path def _node(tmp_path, body): script = tmp_path / "case.js" - script.write_text(body) + script.write_text(body, encoding="utf-8") proc = subprocess.run( - ["node", str(script)], capture_output=True, text=True, cwd=str(tmp_path)) + ["node", str(script)], capture_output=True, text=True, encoding="utf-8", cwd=str(tmp_path)) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) diff --git a/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py b/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py index 157dbd5..040ff43 100644 --- a/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py +++ b/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py @@ -314,6 +314,9 @@ def test_a_refusal_never_names_the_address(): # ── The doors, driven through the API ──────────────────────────────────────── +_AK = base64.b64encode(b"k" * 32).decode() # the passphrase-derived auth_key these accounts use + + async def _register(client, username: str, email: str, captcha=None): sk_ed, sk_x = Ed25519PrivateKey.generate(), X25519PrivateKey.generate() return await client.post("/v1/users/register", json={ @@ -383,11 +386,11 @@ async def test_an_account_may_point_the_hub_at_one_stranger_then_wait( before = len(wire) r = await client.patch("/v1/users/me", headers=headers, - json={"email": "a-stranger@example.test"}) + json={"auth_key": _AK, "email": "a-stranger@example.test"}) assert r.status_code == 200, r.text r = await client.patch("/v1/users/me", headers=headers, - json={"email": "another-stranger@example.test"}) + json={"auth_key": _AK, "email": "another-stranger@example.test"}) assert r.status_code == 429, r.text assert len(wire) - before == 1, "the hub mailed a second stranger on demand" @@ -408,7 +411,7 @@ async def test_the_address_already_pending_may_be_asked_for_again( "relay_d@example.test") typo = "jean@gmial.test" - r = await client.patch("/v1/users/me", headers=headers, json={"email": typo}) + r = await client.patch("/v1/users/me", headers=headers, json={"auth_key": _AK, "email": typo}) assert r.status_code == 200, r.text # The recipient's own cooldown is not what is under test here. @@ -419,7 +422,7 @@ async def test_the_address_already_pending_may_be_asked_for_again( await db_session.commit() before = len(wire) - r = await client.patch("/v1/users/me", headers=headers, json={"email": typo}) + r = await client.patch("/v1/users/me", headers=headers, json={"auth_key": _AK, "email": typo}) assert r.status_code == 200, ( "a typo locked the account out of correcting it: " + r.text) assert len(wire) - before == 1 @@ -437,7 +440,7 @@ async def test_the_delay_survives_the_verification_row_being_deleted( "relay_c@example.test") r = await client.patch("/v1/users/me", headers=headers, - json={"email": "c-first@example.test"}) + json={"auth_key": _AK, "email": "c-first@example.test"}) assert r.status_code == 200, r.text from meshbay_hub.db.models import EmailVerification @@ -446,7 +449,7 @@ async def test_the_delay_survives_the_verification_row_being_deleted( await db_session.commit() r = await client.patch("/v1/users/me", headers=headers, - json={"email": "c-second@example.test"}) + json={"auth_key": _AK, "email": "c-second@example.test"}) assert r.status_code == 429, ( "the delay was counted from a table the handler empties") diff --git a/packages/meshbay-hub/tests/test_media_pager.py b/packages/meshbay-hub/tests/test_media_pager.py index a8a0569..835ed6f 100644 --- a/packages/meshbay-hub/tests/test_media_pager.py +++ b/packages/meshbay-hub/tests/test_media_pager.py @@ -36,8 +36,8 @@ def source(): def _run(tmp_path, source, expr): script = tmp_path / "case.js" - script.write_text(f"{source}\nconsole.log(JSON.stringify({expr}));") - out = subprocess.run(["node", str(script)], capture_output=True, text=True, check=True) + script.write_text(f"{source}\nconsole.log(JSON.stringify({expr}));", encoding="utf-8") + out = subprocess.run(["node", str(script)], capture_output=True, encoding="utf-8", check=True) return json.loads(out.stdout) diff --git a/packages/meshbay-hub/tests/test_member_removal.py b/packages/meshbay-hub/tests/test_member_removal.py index 7af73a0..5073873 100644 --- a/packages/meshbay-hub/tests/test_member_removal.py +++ b/packages/meshbay-hub/tests/test_member_removal.py @@ -27,7 +27,7 @@ pytestmark = pytest.mark.skipif( def _remove_member_body() -> str: - source = SETTINGS.read_text() + source = SETTINGS.read_text(encoding="utf-8") start = source.find("const removeMember = useCallback(") assert start != -1, "removeMember is gone from group-settings.js" end = source.find("\n }, [", start) diff --git a/packages/meshbay-hub/tests/test_memory_ceiling.py b/packages/meshbay-hub/tests/test_memory_ceiling.py index 5984292..9ea6ad3 100644 --- a/packages/meshbay-hub/tests/test_memory_ceiling.py +++ b/packages/meshbay-hub/tests/test_memory_ceiling.py @@ -51,7 +51,7 @@ def _lift(name, source): @pytest.fixture(scope="module") def target_fn(): """The ceiling, its error and the real function — read, never re-typed.""" - src = FILE_UTILS.read_text() + src = FILE_UTILS.read_text(encoding="utf-8") ceiling = re.search(r"^const MEMORY_CEILING = .*?;$", src, re.M) assert ceiling, "MEMORY_CEILING is gone from file-utils.js" # The test's own CEILING constant must agree with the source's, or every @@ -123,8 +123,8 @@ try {{ outcome = {{ kind: 'refused', name: err.name, message: err.message }}; }} console.log(JSON.stringify(outcome)); -""") - proc = subprocess.run(["node", str(script)], capture_output=True, text=True) +""", encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) @@ -259,7 +259,7 @@ def test_no_unguarded_memory_floor(target_fn): def test_the_guard_is_what_the_preview_uses_too(target_fn): """`FilePreview` decrypts a whole entry with no writable at all, so it needs the same ceiling — and must import it rather than keep a second number.""" - files_app = (STATIC / "files-app.js").read_text() + files_app = (STATIC / "files-app.js").read_text(encoding="utf-8") assert "MEMORY_CEILING" in files_app assert re.search(r"entry\.size\s*>\s*MEMORY_CEILING", files_app), ( "the preview modal must refuse an oversized entry before fetching it") diff --git a/packages/meshbay-hub/tests/test_mnp_token.py b/packages/meshbay-hub/tests/test_mnp_token.py new file mode 100644 index 0000000..3aa3115 --- /dev/null +++ b/packages/meshbay-hub/tests/test_mnp_token.py @@ -0,0 +1,92 @@ +"""The MNP token: a member's credential to a node, useless at the hub API. + +A member hands whatever token it presents to every node it connects to (the MNP +handshake). That must not be the hub session token, which opens the hub API — +otherwise a node operator holds a live credential for the member. `POST +/v1/nodes/mnp-token` mints a short-lived, node-audience token for that purpose; +these tests pin that it authorises to a node and is refused by the hub API. +""" + +import pytest + +from meshbay_common.handshake import HandshakeError, authorize_token +from meshbay_common.tokens import MNP_AUD + + +async def _session_token(client, username="mnp_user_test"): + await client.post("/v1/users/register", json={ + "username": username, "email": f"{username}@test.local", "auth_key": "k" * 44}) + r = await client.post("/v1/users/login", json={ + "username": username, "auth_key": "k" * 44}) + return r.json()["access_token"] + + +@pytest.mark.asyncio +async def test_mnp_token_endpoint_needs_a_session(client): + # No Authorization header at all — FastAPI rejects the required header (422), + # like every other authenticated route; the point is it is not minted anonymously. + r = await client.post("/v1/nodes/mnp-token") + assert r.status_code in (401, 403, 422) + + +@pytest.mark.asyncio +async def test_mnp_token_is_minted_for_a_member(client): + tok = await _session_token(client) + r = await client.post("/v1/nodes/mnp-token", + headers={"Authorization": f"Bearer {tok}"}) + assert r.status_code == 200 + assert r.json().get("mnp_token") + + +@pytest.mark.asyncio +async def test_mnp_token_is_refused_at_the_hub_api(client): + """The whole point: the credential a node receives opens nothing at the hub.""" + tok = await _session_token(client, "mnp_api_test") + mnp = (await client.post("/v1/nodes/mnp-token", + headers={"Authorization": f"Bearer {tok}"})).json()["mnp_token"] + # Presenting it to a hub endpoint fails. + r = await client.get("/v1/users/me", headers={"Authorization": f"Bearer {mnp}"}) + assert r.status_code == 401 + + +@pytest.mark.asyncio +async def test_a_session_token_is_refused_by_a_node_but_the_mnp_token_is_not(client): + """The mirror image, at the node's decode: the session token (what the API + accepts) is refused by `authorize_token`, and the MNP token is accepted.""" + from meshbay_hub.auth import hub_public_key_pem + + # Make the member a member of a group so the MNP token carries it. + tok = await _session_token(client, "mnp_node_test") + H = {"Authorization": f"Bearer {tok}"} + gid = (await client.post("/v1/groups", headers=H, json={ + "name": "g", "visibility": "private", "join_policy": "invite"})).json()["group_id"] + mnp = (await client.post("/v1/nodes/mnp-token", headers=H)).json()["mnp_token"] + pk = hub_public_key_pem() + + # The session token is refused by the node handshake (wrong audience). + with pytest.raises(HandshakeError): + authorize_token(tok, pk, group_id=gid) + # The MNP token authorises the member to the node. + peer = authorize_token(mnp, pk, group_id=gid) + assert peer.group_id == gid + + +@pytest.mark.asyncio +async def test_the_mnp_token_is_bound_to_the_node_it_names(client): + """E10: a token minted for node A is refused by node B, so an operator who + captures a member's token cannot replay it to another of the member's nodes.""" + from meshbay_hub.auth import hub_public_key_pem + + tok = await _session_token(client, "mnp_bind_test") + H = {"Authorization": f"Bearer {tok}"} + gid = (await client.post("/v1/groups", headers=H, json={ + "name": "g", "visibility": "private", "join_policy": "invite"})).json()["group_id"] + # A token bound to node A's key. + mnp = (await client.post("/v1/nodes/mnp-token", headers=H, + json={"node_pk": "node-A-pk"})).json()["mnp_token"] + pk = hub_public_key_pem() + # Node B refuses it; node A accepts it. + with pytest.raises(HandshakeError, match="this node"): + authorize_token(mnp, pk, group_id=gid, node_pk_b64="node-B-pk") + peer = authorize_token(mnp, pk, group_id=gid, node_pk_b64="node-A-pk") + assert peer.group_id == gid diff --git a/packages/meshbay-hub/tests/test_music_queue.py b/packages/meshbay-hub/tests/test_music_queue.py index 5bf9e97..8f3f9bd 100644 --- a/packages/meshbay-hub/tests/test_music_queue.py +++ b/packages/meshbay-hub/tests/test_music_queue.py @@ -123,7 +123,7 @@ def test_an_unreachable_group_is_skipped_whole_rather_than_one_track_at_a_time(s def test_the_music_wrapper_names_the_op_it_forwards(name): """A wrapper that takes two arguments forwards two, and the third is lost without a word. Both of these did exactly that.""" - src = (STATIC / name).read_text() + src = (STATIC / name).read_text(encoding="utf-8") marker = ("const onPlayQueue = useCallback((tracks, startIndex, op)" if name == "group-page.js" else "const handleMusicPlay = useCallback((tracks, startIndex, op)") diff --git a/packages/meshbay-hub/tests/test_node_page_pairing.py b/packages/meshbay-hub/tests/test_node_page_pairing.py new file mode 100644 index 0000000..435488e --- /dev/null +++ b/packages/meshbay-hub/tests/test_node_page_pairing.py @@ -0,0 +1,48 @@ +""" +The Node page's "No operator paired" banner. + +A node publishes its roster only once it has signed in to the hub, and until +then it has no way to know who is paired. It used to answer `false` in that +window and the page took `!operator_paired` for "not paired" -- so a node stuck +waiting for its account told its operator to pair again, about a pairing that +was intact, and sent them after the wrong problem. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +NODE_PAGE = STATIC / "node-page.js" + + +def _source() -> str: + return NODE_PAGE.read_text(encoding="utf-8") + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node is not available") +def test_paired_is_unknown_unless_the_node_says_true_or_false(tmp_path): + m = re.search(r"^export function pairedFrom\(.*?^\}", _source(), re.M | re.S) + assert m, "node-page.js no longer has pairedFrom" + script = tmp_path / "case.js" + cases = [{"operator_paired": True}, {"operator_paired": False}, + {"operator_paired": None}, {}, None, {"operator_paired": "yes"}] + script.write_text(m.group(0).replace("export ", "") + + f"\nconsole.log(JSON.stringify({json.dumps(cases)}.map(pairedFrom)));", + encoding="utf-8") + out = subprocess.run(["node", str(script)], capture_output=True, encoding="utf-8", check=True) + assert json.loads(out.stdout) == [True, False, None, None, None, None] + + +def test_the_banner_needs_an_explicit_false(): + src = _source() + banner = src.index('class="node-pair-banner"') + guard = src.rindex("${", 0, banner) + assert src[guard:banner].startswith("${operatorPaired === false &&"), ( + "the pairing banner must not show for a node that has not read its roster") + assert "useState(null)" in src.split("const [operatorPaired", 1)[1].split("\n", 1)[0] + assert "setOperatorPaired(!!" not in src diff --git a/packages/meshbay-hub/tests/test_node_page_width_measured.py b/packages/meshbay-hub/tests/test_node_page_width_measured.py index bd6a231..ae49641 100644 --- a/packages/meshbay-hub/tests/test_node_page_width_measured.py +++ b/packages/meshbay-hub/tests/test_node_page_width_measured.py @@ -122,7 +122,7 @@ SELECTORS = ["#node.node-page", "#settings", "#node .node-table-scroll", def measured(tmp_path_factory): """One browser for every width — launching one apiece cost three minutes.""" fragment = tmp_path_factory.mktemp("nodewidth") / "fragment.html" - fragment.write_text(FRAGMENT) + fragment.write_text(FRAGMENT, encoding="utf-8") proc = subprocess.run( ["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS), str(fragment), *SELECTORS], diff --git a/packages/meshbay-hub/tests/test_node_scope_not_admin.py b/packages/meshbay-hub/tests/test_node_scope_not_admin.py new file mode 100644 index 0000000..58cabb1 --- /dev/null +++ b/packages/meshbay-hub/tests/test_node_scope_not_admin.py @@ -0,0 +1,159 @@ +"""A node-scoped daemon token must never reach the hub admin/moderator surface. + +A node's authority and a hub role are different notions (docs/MESHBAY_DESIGN.md +§7.1, NS4): what a node may do is decided by its operator's roster pin on the +node and by the deliberately narrow node scope; being an admin or moderator is a +hub role on a *person's* account, exercised from a browser with a user-scoped +token. When the operator's account also holds a hub role — which is the case on +the reference deployment, where the operator is an admin and runs a node — the +`scope:"node"` token the daemon keeps in memory used to pass `require_admin` and +`require_moderator`, because those checked only the role and not the scope. The +scope gate was wired onto `require_user_scope` (group mutation) alone. + +These are refusals: each asserts the node token is turned away with 403, and the +same account's user token is let through, so the guard is proven to bite on the +scope and not on the account. +""" + +import base64 +import time + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from meshbay_hub.api.deps import set_admin_usernames + + +def _gen_ed25519(): + sk = Ed25519PrivateKey.generate() + pk_raw = sk.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw) + return sk, base64.b64encode(pk_raw).decode() + + +async def _register(client, username): + r = await client.post("/v1/users/register", json={ + "username": username, "email": f"{username}@test.local", + "auth_key": "k" * 44}) + assert r.status_code == 201 + + +async def _user_login(client, username): + r = await client.post("/v1/users/login", json={ + "username": username, "auth_key": "k" * 44}) + assert r.status_code == 200 + return r.json()["access_token"] + + +async def _node_token(client, username, user_token): + """Link a node key for `username` and return a scope:"node" token for it.""" + sk_node, pk_node = _gen_ed25519() + r = await client.put("/v1/users/me/node_key", + json={"pk_node_ed25519": pk_node}, + headers={"Authorization": f"Bearer {user_token}"}) + assert r.status_code == 200 + ts = int(time.time()) + sig = sk_node.sign(f"meshbay:node_auth:{username}:{ts}".encode()) + r = await client.post("/v1/nodes/auth", json={ + "username": username, "timestamp": ts, + "signature": base64.b64encode(sig).decode()}) + assert r.status_code == 200 + data = r.json() + # Confirm we really are holding a node-scoped token. + import jwt as _jwt + assert _jwt.decode(data["access_token"], options={"verify_signature": False} + )["scope"] == "node" + return data["access_token"] + + +async def _admin_with_node(client, username="op_admin_test"): + """An admin account that also runs a node — the reference-deployment case. + + Returns (user_token, node_token) for the same account. + """ + await _register(client, username) + set_admin_usernames([username]) + user_token = await _user_login(client, username) + node_token = await _node_token(client, username, user_token) + return user_token, node_token + + +def _bearer(token): + return {"Authorization": f"Bearer {token}"} + + +# ── require_admin ──────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_node_token_cannot_reach_admin_stats(client): + user_token, node_token = await _admin_with_node(client) + assert (await client.get("/v1/admin/stats", headers=_bearer(node_token)) + ).status_code == 403 + # The same account, from a browser, is admin and gets in. + assert (await client.get("/v1/admin/stats", headers=_bearer(user_token)) + ).status_code == 200 + + +@pytest.mark.asyncio +async def test_node_token_cannot_revoke(client): + """The sharpest one: revoke is signed and broadcast to every node.""" + _, node_token = await _admin_with_node(client) + r = await client.post("/v1/admin/revoke", headers=_bearer(node_token), + json={"target": "group", "target_id": "whatever"}) + assert r.status_code == 403 + + +@pytest.mark.asyncio +async def test_node_token_cannot_change_instance_policy(client): + _, node_token = await _admin_with_node(client) + r = await client.patch("/v1/admin/settings", headers=_bearer(node_token), + json={"allow_public_groups": True}) + assert r.status_code == 403 + + +@pytest.mark.asyncio +async def test_node_token_cannot_delete_account(client): + _, node_token = await _admin_with_node(client) + r = await client.delete("/v1/admin/users/some-id", headers=_bearer(node_token)) + assert r.status_code == 403 + + +# ── require_moderator (a wider set of accounts, including the IP audit log) ─── + +@pytest.mark.asyncio +async def test_node_token_cannot_read_ip_audit_log(client): + user_token, node_token = await _admin_with_node(client) + assert (await client.get("/v1/admin/logs", headers=_bearer(node_token)) + ).status_code == 403 + assert (await client.get("/v1/admin/logs", headers=_bearer(user_token)) + ).status_code == 200 + + +@pytest.mark.asyncio +async def test_node_token_cannot_list_nodes(client): + _, node_token = await _admin_with_node(client) + assert (await client.get("/v1/admin/nodes", headers=_bearer(node_token)) + ).status_code == 403 + + +@pytest.mark.asyncio +async def test_a_moderators_node_token_is_also_refused(client): + """A moderator (not admin) who runs a node: the moderation surface is still + the person's, not the machine's.""" + # An admin promotes a second account to moderator, which then links a node. + admin_user_token, _ = await _admin_with_node(client, "boss_admin_test") + await _register(client, "mod_test") + mod_user_token = await _user_login(client, "mod_test") + # promote + import jwt as _jwt + mod_id = _jwt.decode(mod_user_token, options={"verify_signature": False})["sub"] + r = await client.patch(f"/v1/admin/users/{mod_id}", json={"role": "moderator"}, + headers=_bearer(admin_user_token)) + assert r.status_code == 200 + mod_node_token = await _node_token(client, "mod_test", mod_user_token) + # user-scoped moderator token gets in; node-scoped one does not + assert (await client.get("/v1/admin/stats", headers=_bearer(mod_user_token)) + ).status_code == 200 + assert (await client.get("/v1/admin/stats", headers=_bearer(mod_node_token)) + ).status_code == 403 diff --git a/packages/meshbay-hub/tests/test_notification_dismissal.py b/packages/meshbay-hub/tests/test_notification_dismissal.py index d498b4d..0198dee 100644 --- a/packages/meshbay-hub/tests/test_notification_dismissal.py +++ b/packages/meshbay-hub/tests/test_notification_dismissal.py @@ -149,7 +149,7 @@ def test_the_spa_asks_for_unread_only(): exercising it, and here it is the only thing that catches the defect that actually happened. """ - src = APP.read_text() + src = APP.read_text(encoding="utf-8") fetches = re.findall(r"hubFetch\('(/v1/notifications\?[^']*)'", src) assert fetches, "the notification list fetch is no longer where this reads it" for url in fetches: @@ -167,7 +167,7 @@ def test_the_feed_does_not_style_a_state_it_can_no_longer_show(): was dead code that described behaviour the application had abandoned — and reading it is what made the two halves' disagreement visible. """ - src = APP.read_text() + src = APP.read_text(encoding="utf-8") assert "n.read ?" not in src, ( "the feed branches on `read` again; either it is dead code or the " "dismissal contract has changed and this file should say how") diff --git a/packages/meshbay-hub/tests/test_offer_retry.py b/packages/meshbay-hub/tests/test_offer_retry.py index 6389f8e..1239ae0 100644 --- a/packages/meshbay-hub/tests/test_offer_retry.py +++ b/packages/meshbay-hub/tests/test_offer_retry.py @@ -33,7 +33,7 @@ pytestmark = pytest.mark.skipif( def _post(**cfg) -> dict: proc = subprocess.run( ["node", str(HARNESS), transport_argv(), json.dumps(cfg)], - capture_output=True, text=True) + capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) diff --git a/packages/meshbay-hub/tests/test_playlist_crypto.py b/packages/meshbay-hub/tests/test_playlist_crypto.py index 4ff3080..4dcfcb6 100644 --- a/packages/meshbay-hub/tests/test_playlist_crypto.py +++ b/packages/meshbay-hub/tests/test_playlist_crypto.py @@ -69,11 +69,11 @@ const bigBody = (n) => ({ def _run(tmp_path, body): - src = SRC.read_text().replace("export {", "const _unused_export = {") + src = SRC.read_text(encoding="utf-8").replace("export {", "const _unused_export = {") script = tmp_path / "case.mjs" - script.write_text(f"{src}\n{PRELUDE}\n{body}\n") + script.write_text(f"{src}\n{PRELUDE}\n{body}\n", encoding="utf-8") out = subprocess.run(["node", str(script)], - capture_output=True, text=True, timeout=60) + capture_output=True, text=True, encoding="utf-8", timeout=60) assert out.returncode == 0, out.stderr return json.loads(out.stdout) diff --git a/packages/meshbay-hub/tests/test_playlist_key.py b/packages/meshbay-hub/tests/test_playlist_key.py index dbed8ae..261cc32 100644 --- a/packages/meshbay-hub/tests/test_playlist_key.py +++ b/packages/meshbay-hub/tests/test_playlist_key.py @@ -58,11 +58,11 @@ globalThis.argon2 = { def _run(tmp_path, body): - src = KEYDERIVE.read_text() + src = KEYDERIVE.read_text(encoding="utf-8") script = tmp_path / "case.mjs" - script.write_text(f"{PRELUDE}\n{src}\n{body}\n") + script.write_text(f"{PRELUDE}\n{src}\n{body}\n", encoding="utf-8") out = subprocess.run(["node", str(script)], - capture_output=True, text=True, timeout=60) + capture_output=True, text=True, encoding="utf-8", timeout=60) assert out.returncode == 0, out.stderr return json.loads(out.stdout) diff --git a/packages/meshbay-hub/tests/test_playlist_merge.py b/packages/meshbay-hub/tests/test_playlist_merge.py index 2c7b612..dd089e8 100644 --- a/packages/meshbay-hub/tests/test_playlist_merge.py +++ b/packages/meshbay-hub/tests/test_playlist_merge.py @@ -39,7 +39,7 @@ EXPORT = re.compile(r"^export \{[^}]*\};?\s*$", re.M | re.S) @pytest.fixture(scope="module") def module_source(): - text = SRC.read_text() + text = SRC.read_text(encoding="utf-8") 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 " @@ -53,9 +53,9 @@ def module_source(): def _run(tmp_path, module_source, body): script = tmp_path / "case.js" - script.write_text(f"{module_source}\n{body}\n") + script.write_text(f"{module_source}\n{body}\n", encoding="utf-8") out = subprocess.run( - ["node", str(script)], capture_output=True, text=True, timeout=30) + ["node", str(script)], capture_output=True, text=True, encoding="utf-8", timeout=30) assert out.returncode == 0, out.stderr return json.loads(out.stdout) diff --git a/packages/meshbay-hub/tests/test_queue_ops.py b/packages/meshbay-hub/tests/test_queue_ops.py index 64b5ca1..e3853f2 100644 --- a/packages/meshbay-hub/tests/test_queue_ops.py +++ b/packages/meshbay-hub/tests/test_queue_ops.py @@ -38,7 +38,7 @@ EXPORT = re.compile(r"^export \{[^}]*\};?\s*$", re.M) @pytest.fixture(scope="module") def module_source(): - text = SRC.read_text() + text = SRC.read_text(encoding="utf-8") assert not IMPORT.search(text), ( "queue-ops.js has gained an import. It is executed standalone here, " "and the queue is untested from the moment it cannot be — keep the " @@ -52,9 +52,9 @@ def module_source(): def _run(tmp_path, module_source, body): script = tmp_path / "case.js" - script.write_text(f"{module_source}\n{body}\n") + script.write_text(f"{module_source}\n{body}\n", encoding="utf-8") out = subprocess.run( - ["node", str(script)], capture_output=True, text=True, timeout=30) + ["node", str(script)], capture_output=True, text=True, encoding="utf-8", timeout=30) assert out.returncode == 0, out.stderr return json.loads(out.stdout) diff --git a/packages/meshbay-hub/tests/test_rate_limit_key.py b/packages/meshbay-hub/tests/test_rate_limit_key.py new file mode 100644 index 0000000..679f546 --- /dev/null +++ b/packages/meshbay-hub/tests/test_rate_limit_key.py @@ -0,0 +1,48 @@ +""" +Rate limits are per client, not per proxy. + +meshbay.org's hub sits behind Caddy on the same host, so every request's TCP peer +is loopback. The limiter was keyed on that peer (slowapi's get_remote_address) +while `client_ip` -- written "for the audit log and rate limiting" -- went +unused by it, so each limit was one bucket for the whole internet: ten node +sign-ins a minute shared by every node. A node that started while others signed +in got 429, sat in `waiting_for_hub`, and the desktop app took that for no node +at all. Every other test runs with the limiter disabled, which is how it hid. +""" + +import pytest +from meshbay_hub.api.middleware import limiter +from meshbay_hub.api.netutil import client_ip + + +@pytest.fixture +def limits_on(): + limiter.reset() + limiter.enabled = True + yield + limiter.enabled = False + limiter.reset() + + +def _auth(client, ip): + # The ASGI test transport's peer is 127.0.0.1 -- a trusted proxy, as Caddy is. + return client.post("/v1/nodes/auth", + json={"username": "nobody", "timestamp": 0, "signature": "AAAA"}, + headers={"X-Forwarded-For": ip}) + + +async def test_one_client_is_limited(client, limits_on): + for _ in range(10): + assert (await _auth(client, "203.0.113.1")).status_code != 429 + assert (await _auth(client, "203.0.113.1")).status_code == 429 + + +async def test_another_client_behind_the_same_proxy_is_not(client, limits_on): + for _ in range(11): + await _auth(client, "203.0.113.1") + assert (await _auth(client, "203.0.113.2")).status_code != 429, ( + "a second client behind the proxy shared the first one's bucket") + + +def test_the_limiter_resolves_clients_the_way_the_audit_log_does(): + assert limiter._key_func is client_ip diff --git a/packages/meshbay-hub/tests/test_reconnect_refresh.py b/packages/meshbay-hub/tests/test_reconnect_refresh.py index ff6d7fb..cd702d3 100644 --- a/packages/meshbay-hub/tests/test_reconnect_refresh.py +++ b/packages/meshbay-hub/tests/test_reconnect_refresh.py @@ -43,12 +43,12 @@ def transport(): @pytest.fixture(scope="module") def group_page(): - return GROUP_PAGE.read_text() + return GROUP_PAGE.read_text(encoding="utf-8") @pytest.fixture(scope="module") def video_player(): - return VIDEO_PLAYER.read_text() + return VIDEO_PLAYER.read_text(encoding="utf-8") def _reconnect_loop(transport: str) -> str: @@ -93,7 +93,7 @@ def test_one_listener_throwing_does_not_rob_the_next(transport): def test_nothing_assigns_the_old_setter(): """`grep onReconnected =` is what this is, spelled so it cannot rot.""" offenders = [p.name for p in STATIC.glob("*.js") - if re.search(r"\.onReconnected\s*=", p.read_text())] + if re.search(r"\.onReconnected\s*=", p.read_text(encoding="utf-8"))] assert offenders == [], ( f"{offenders} still assign a slot that no longer exists") @@ -159,10 +159,10 @@ def test_every_harness_that_renders_the_group_page_stubs_the_subscription(): """ harness = Path(__file__).parent / "harness" stubs = [p for p in harness.glob("*.py") - if "window.MeshBayTransport" in p.read_text() - and "GroupPage" in p.read_text()] + if "window.MeshBayTransport" in p.read_text(encoding="utf-8") + and "GroupPage" in p.read_text(encoding="utf-8")] assert stubs, "no harness stubs the transport any more — has this moved?" missing = [p.name for p in stubs - if "addReconnectListener" not in p.read_text()] + if "addReconnectListener" not in p.read_text(encoding="utf-8")] assert missing == [], ( f"{missing} render GroupPage against a node that cannot be subscribed to") diff --git a/packages/meshbay-hub/tests/test_recovery_key.py b/packages/meshbay-hub/tests/test_recovery_key.py index 54415f6..e43e6de 100644 --- a/packages/meshbay-hub/tests/test_recovery_key.py +++ b/packages/meshbay-hub/tests/test_recovery_key.py @@ -102,10 +102,10 @@ const fp = async (key) => hex(await webcrypto.subtle.encrypt( def result(tmp_path_factory): d = tmp_path_factory.mktemp("recovery") harness = d / "harness.cjs" - harness.write_text(_HARNESS) + harness.write_text(_HARNESS, encoding="utf-8") proc = subprocess.run( ["node", str(harness), str(KEYDERIVE)], - capture_output=True, text=True, timeout=120, + capture_output=True, text=True, encoding="utf-8", timeout=120, ) if proc.returncode != 0: pytest.fail(f"node harness failed:\n{proc.stderr[-2000:]}") diff --git a/packages/meshbay-hub/tests/test_resume_position.py b/packages/meshbay-hub/tests/test_resume_position.py index 07ac23a..67eb786 100644 --- a/packages/meshbay-hub/tests/test_resume_position.py +++ b/packages/meshbay-hub/tests/test_resume_position.py @@ -85,7 +85,7 @@ def _run(body: str, tmp_path: Path): f"{body}\n" "console.log(JSON.stringify(out));\n", encoding="utf-8") - proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) diff --git a/packages/meshbay-hub/tests/test_revoke_is_one_path.py b/packages/meshbay-hub/tests/test_revoke_is_one_path.py new file mode 100644 index 0000000..2acb46c --- /dev/null +++ b/packages/meshbay-hub/tests/test_revoke_is_one_path.py @@ -0,0 +1,164 @@ +"""Revoke is one door, it is admin-only, and it broadcasts. + +Two coupled gaps used to sit in the moderation surface (docs/MESHBAY_DESIGN.md +§7.5): + + * `admin_patch_group` let a *moderator* set a group to `revoked`, while the + user handler makes revoke admin-only; + * a `revoked` set through either PATCH was **never broadcast** to nodes — + unlike `POST /v1/admin/revoke` and account deletion — so it behaved like + `suspended` on nodes while claiming to be the signed, node-enforced state. + +Revoke now has one path, `POST /v1/admin/revoke` (admin-only, signs and +broadcasts). PATCH refuses `revoked` and refuses to move an entity *out* of +`revoked` unless the caller is an admin. +""" + +import pytest + +from meshbay_hub.api.deps import set_admin_usernames + + +async def _register(client, username): + r = await client.post("/v1/users/register", json={ + "username": username, "email": f"{username}@test.local", "auth_key": "k" * 44}) + assert r.status_code == 201 + return r.json()["user_id"] + + +async def _login(client, username): + r = await client.post("/v1/users/login", json={ + "username": username, "auth_key": "k" * 44}) + assert r.status_code == 200 + return r.json()["access_token"] + + +def _h(token): + return {"Authorization": f"Bearer {token}"} + + +async def _admin(client, name="admin_rev_test"): + await _register(client, name) + set_admin_usernames([name]) + return await _login(client, name) + + +async def _moderator(client, admin_token, name="mod_rev_test"): + uid = await _register(client, name) + r = await client.patch(f"/v1/admin/users/{uid}", json={"role": "moderator"}, + headers=_h(admin_token)) + assert r.status_code == 200 + return uid, await _login(client, name) + + +async def _a_group(client, owner="owner_rev_test"): + await _register(client, owner) + tok = await _login(client, owner) + r = await client.post("/v1/groups", headers=_h(tok), + json={"name": "g", "visibility": "private", "join_policy": "invite"}) + assert r.status_code == 201 + return r.json()["group_id"] + + +async def _group_status(client, admin_token, group_id): + data = (await client.get("/v1/admin/groups?limit=200", headers=_h(admin_token))).json() + return next(g["status"] for g in data["groups"] if g["id"] == group_id) + + +# ── PATCH cannot revoke ────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_moderator_cannot_revoke_a_group_via_patch(client): + admin_token = await _admin(client) + _, mod_token = await _moderator(client, admin_token) + gid = await _a_group(client) + r = await client.patch(f"/v1/admin/groups/{gid}", json={"status": "revoked"}, + headers=_h(mod_token)) + assert r.status_code == 403 + assert await _group_status(client, admin_token, gid) == "active" + + +@pytest.mark.asyncio +async def test_admin_patch_revoked_group_is_redirected_not_silently_applied(client): + admin_token = await _admin(client) + gid = await _a_group(client) + r = await client.patch(f"/v1/admin/groups/{gid}", json={"status": "revoked"}, + headers=_h(admin_token)) + assert r.status_code == 400 + assert "revoke" in r.json()["detail"].lower() + # And it was not quietly applied. + assert await _group_status(client, admin_token, gid) == "active" + + +@pytest.mark.asyncio +async def test_admin_patch_revoked_user_is_redirected(client): + admin_token = await _admin(client) + victim = await _register(client, "vic_rev_test") + r = await client.patch(f"/v1/admin/users/{victim}", json={"status": "revoked"}, + headers=_h(admin_token)) + assert r.status_code == 400 + + +# ── The one door: /v1/admin/revoke, admin-only, and it broadcasts ──────────── + +@pytest.mark.asyncio +async def test_admin_revoke_group_broadcasts_and_sets_status(client): + admin_token = await _admin(client) + gid = await _a_group(client) + r = await client.post("/v1/admin/revoke", headers=_h(admin_token), + json={"target": "group", "target_id": gid}) + assert r.status_code == 200 + body = r.json() + assert body["status"] == "revoked" + assert "nodes_notified" in body # it went through the broadcast path + assert await _group_status(client, admin_token, gid) == "revoked" + + +@pytest.mark.asyncio +async def test_moderator_cannot_reach_the_revoke_endpoint(client): + admin_token = await _admin(client) + _, mod_token = await _moderator(client, admin_token) + gid = await _a_group(client) + r = await client.post("/v1/admin/revoke", headers=_h(mod_token), + json={"target": "group", "target_id": gid}) + assert r.status_code == 403 + + +# ── Leaving `revoked` is an admin's call ───────────────────────────────────── + +@pytest.mark.asyncio +async def test_moderator_cannot_unrevoke_a_group(client): + admin_token = await _admin(client) + _, mod_token = await _moderator(client, admin_token) + gid = await _a_group(client) + await client.post("/v1/admin/revoke", headers=_h(admin_token), + json={"target": "group", "target_id": gid}) + r = await client.patch(f"/v1/admin/groups/{gid}", json={"status": "active"}, + headers=_h(mod_token)) + assert r.status_code == 403 + assert await _group_status(client, admin_token, gid) == "revoked" + + +@pytest.mark.asyncio +async def test_moderator_cannot_unrevoke_a_user(client): + admin_token = await _admin(client) + _, mod_token = await _moderator(client, admin_token) + victim = await _register(client, "vic2_rev_test") + await client.post("/v1/admin/revoke", headers=_h(admin_token), + json={"target": "user", "target_id": victim}) + r = await client.patch(f"/v1/admin/users/{victim}", json={"status": "active"}, + headers=_h(mod_token)) + assert r.status_code == 403 + + +# ── Regression: suspend/unsuspend by a moderator still works ───────────────── + +@pytest.mark.asyncio +async def test_moderator_can_still_suspend_and_restore(client): + admin_token = await _admin(client) + _, mod_token = await _moderator(client, admin_token) + gid = await _a_group(client) + assert (await client.patch(f"/v1/admin/groups/{gid}", json={"status": "suspended"}, + headers=_h(mod_token))).status_code == 200 + assert (await client.patch(f"/v1/admin/groups/{gid}", json={"status": "active"}, + headers=_h(mod_token))).status_code == 200 diff --git a/packages/meshbay-hub/tests/test_rewrap_fanout.py b/packages/meshbay-hub/tests/test_rewrap_fanout.py index 7c0f272..b278d0c 100644 --- a/packages/meshbay-hub/tests/test_rewrap_fanout.py +++ b/packages/meshbay-hub/tests/test_rewrap_fanout.py @@ -154,10 +154,10 @@ const names = (a) => a.map((x) => x.name).sort(); def result(tmp_path_factory): d = tmp_path_factory.mktemp("rewrap") harness = d / "harness.cjs" - harness.write_text(_HARNESS) + harness.write_text(_HARNESS, encoding="utf-8") proc = subprocess.run( ["node", str(harness), transport_argv()], - capture_output=True, text=True, timeout=120, + capture_output=True, text=True, encoding="utf-8", timeout=120, ) if proc.returncode != 0: pytest.fail(f"node harness failed:\n{proc.stderr[-2000:]}") diff --git a/packages/meshbay-hub/tests/test_search_connect_deadline.py b/packages/meshbay-hub/tests/test_search_connect_deadline.py index 2d3db06..953f027 100644 --- a/packages/meshbay-hub/tests/test_search_connect_deadline.py +++ b/packages/meshbay-hub/tests/test_search_connect_deadline.py @@ -49,7 +49,7 @@ CAP_MS = 30000 def _attempt(**cfg) -> dict: proc = subprocess.run( ["node", str(HARNESS), search_argv(), json.dumps(cfg)], - capture_output=True, text=True) + capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) diff --git a/packages/meshbay-hub/tests/test_search_fanout.py b/packages/meshbay-hub/tests/test_search_fanout.py index fca879a..23a8b67 100644 --- a/packages/meshbay-hub/tests/test_search_fanout.py +++ b/packages/meshbay-hub/tests/test_search_fanout.py @@ -67,7 +67,7 @@ DEAD_MS = 10000 # a node that does not, to the connection deadline def _sweep(**cfg) -> dict: proc = subprocess.run( ["node", str(HARNESS), search_argv(), json.dumps(cfg)], - capture_output=True, text=True) + capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) diff --git a/packages/meshbay-hub/tests/test_search_files_unmerged.py b/packages/meshbay-hub/tests/test_search_files_unmerged.py index b84b25d..b6621e5 100644 --- a/packages/meshbay-hub/tests/test_search_files_unmerged.py +++ b/packages/meshbay-hub/tests/test_search_files_unmerged.py @@ -38,7 +38,7 @@ MERGE_CALL = "mergeUnitEntries" def _memo(name): """The body of `const <name> = useMemo(() => { ... }, [...]);`.""" - src = SEARCH_PAGE.read_text() + src = SEARCH_PAGE.read_text(encoding="utf-8") m = re.search( r"^ const " + re.escape(name) + r" = useMemo\(\(\) => \{.*?^ \}, \[.*?\]\);", src, re.M | re.S) diff --git a/packages/meshbay-hub/tests/test_search_media_merge.py b/packages/meshbay-hub/tests/test_search_media_merge.py index 3464902..082de7e 100644 --- a/packages/meshbay-hub/tests/test_search_media_merge.py +++ b/packages/meshbay-hub/tests/test_search_media_merge.py @@ -50,7 +50,7 @@ EXPORT = re.compile(r"^export \{[^}]*\};?\s*$", re.M) def _block(path, header): """One top-level `function name(...) {` ... `}` read out of a module.""" - src = path.read_text() + src = path.read_text(encoding="utf-8") m = re.search(r"^" + re.escape(header) + r".*?^\}", src, re.M | re.S) assert m, ( f"{header} is no longer where this test reads it from in {path.name} — " @@ -60,7 +60,7 @@ def _block(path, header): def _const(name): m = re.search(r"^const " + re.escape(name) + r" = .*?;$", - SEARCH_PAGE.read_text(), re.M) + SEARCH_PAGE.read_text(encoding="utf-8"), re.M) assert m, f"{name} moved — the search-page units cannot be lifted" return m.group(0) @@ -70,7 +70,7 @@ def pipeline(): """Everything the three views need, in one script.""" return "\n".join([ "const t = (k) => k;", - EXPORT.sub("", MERGE.read_text()), + EXPORT.sub("", MERGE.read_text(encoding="utf-8")), _block(VIDEO_APP, "function underVideoRoot(entry, directories) {"), _block(VIDEO_APP, "function buildSeasons(episodes) {"), _block(VIDEO_APP, "function groupVideoEntries(entries, videoDirectories) {"), @@ -90,9 +90,9 @@ def pipeline(): def _node(tmp_path, pipeline, body): script = tmp_path / "case.js" - script.write_text(f"{pipeline}\n{body}\n") + script.write_text(f"{pipeline}\n{body}\n", encoding="utf-8") out = subprocess.run( - ["node", str(script)], capture_output=True, text=True, timeout=30) + ["node", str(script)], capture_output=True, text=True, encoding="utf-8", timeout=30) assert out.returncode == 0, out.stderr return json.loads(out.stdout) diff --git a/packages/meshbay-hub/tests/test_search_pool.py b/packages/meshbay-hub/tests/test_search_pool.py index cf9eeb8..89b46cd 100644 --- a/packages/meshbay-hub/tests/test_search_pool.py +++ b/packages/meshbay-hub/tests/test_search_pool.py @@ -36,7 +36,7 @@ pytestmark = pytest.mark.skipif( def _run(**cfg) -> dict: proc = subprocess.run( ["node", str(HARNESS), search_argv(), json.dumps(cfg)], - capture_output=True, text=True) + capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) diff --git a/packages/meshbay-hub/tests/test_search_source_merge.py b/packages/meshbay-hub/tests/test_search_source_merge.py index b471891..10d10fc 100644 --- a/packages/meshbay-hub/tests/test_search_source_merge.py +++ b/packages/meshbay-hub/tests/test_search_source_merge.py @@ -44,7 +44,7 @@ EXPORT = re.compile(r"^export \{[^}]*\};?\s*$", re.M) @pytest.fixture(scope="module") def module_source(): - text = SRC.read_text() + text = SRC.read_text(encoding="utf-8") assert not IMPORT.search(text), ( "source-merge.js has gained an import. It is executed standalone here, " "and the merge is untested from the moment it cannot be — keep the " @@ -58,9 +58,9 @@ def module_source(): def _run(tmp_path, module_source, body): script = tmp_path / "case.js" - script.write_text(f"{module_source}\n{body}\n") + script.write_text(f"{module_source}\n{body}\n", encoding="utf-8") out = subprocess.run( - ["node", str(script)], capture_output=True, text=True, timeout=30) + ["node", str(script)], capture_output=True, text=True, encoding="utf-8", timeout=30) assert out.returncode == 0, out.stderr return json.loads(out.stdout) diff --git a/packages/meshbay-hub/tests/test_season_panel_placement.py b/packages/meshbay-hub/tests/test_season_panel_placement.py index 8c04ea7..6e5f78b 100644 --- a/packages/meshbay-hub/tests/test_season_panel_placement.py +++ b/packages/meshbay-hub/tests/test_season_panel_placement.py @@ -43,7 +43,7 @@ BLOCK = re.compile( @pytest.fixture(scope="module") def source(): - m = BLOCK.search(APP.read_text()) + m = BLOCK.search(APP.read_text(encoding="utf-8")) assert m, ("placeSeasonPanel is no longer where this test reads it from — " "the season menu's placement is untested until this is fixed") return m.group(0) @@ -58,8 +58,8 @@ def _place(tmp_path, source, *, top, bottom, left=40, width=300, inner_height=74 const el = {{ getBoundingClientRect: () => ({{ top: {top}, bottom: {bottom}, left: {left}, width: {width} }}) }}; console.log(JSON.stringify(placeSeasonPanel(el))); - """) - proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + """, encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) @@ -157,7 +157,7 @@ def test_no_element_means_no_position(tmp_path, source): globalThis.window = {{ innerHeight: 740 }}; {source} console.log(JSON.stringify(placeSeasonPanel(null))); - """) - proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + """, encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr assert json.loads(proc.stdout) is None diff --git a/packages/meshbay-hub/tests/test_session_renewal.py b/packages/meshbay-hub/tests/test_session_renewal.py index 7b8c108..ecf9ac5 100644 --- a/packages/meshbay-hub/tests/test_session_renewal.py +++ b/packages/meshbay-hub/tests/test_session_renewal.py @@ -50,7 +50,7 @@ pytestmark = pytest.mark.skipif( def _run(scenario: str, app: Path = APP) -> dict: proc = subprocess.run( ["node", str(HARNESS), str(app), json.dumps({"scenario": scenario})], - capture_output=True, text=True, timeout=60) + capture_output=True, text=True, encoding="utf-8", timeout=60) assert proc.returncode == 0, f"{proc.stdout}\n{proc.stderr}" return json.loads(proc.stdout.strip().splitlines()[-1]) @@ -59,14 +59,14 @@ def _run(scenario: str, app: Path = APP) -> dict: def broken(tmp_path_factory): """The client as it shipped: the rotated refresh token dropped.""" out = tmp_path_factory.mktemp("session") / "broken.js" - src = APP.read_text() + src = APP.read_text(encoding="utf-8") replaced = src.replace( " refreshToken: data.refresh_token || _auth.refreshToken,", " refreshToken: _auth.refreshToken,") assert replaced != src, ( "could not reconstruct the defect — the line it hinged on has moved, " "and the A/B below would be comparing the fix against itself") - out.write_text(replaced) + out.write_text(replaced, encoding="utf-8") return out @@ -186,13 +186,13 @@ def test_the_session_is_much_longer_than_the_token(): def test_renewal_happens_before_expiry_not_after(): """A margin, so the first click after a long film does not pay for a 401.""" - src = APP.read_text() + src = APP.read_text(encoding="utf-8") import re margin = int(re.search(r"const TOKEN_RENEW_MARGIN_S = (\d+)", src).group(1)) assert margin >= 300, ( f"{margin} s of margin against a one-hour token is thin: a backgrounded " "tab has its timers throttled and may not check for minutes") - assert "visibilitychange" in APP_JS.read_text(), ( + assert "visibilitychange" in APP_JS.read_text(encoding="utf-8"), ( "nothing re-checks when the tab comes back, which is exactly when the " "token is most likely to have aged out unnoticed") @@ -213,7 +213,7 @@ def test_renewing_does_not_tear_down_the_webrtc_connection(): Signing in or out must still re-run it, so the dependency is whether there is a token, not which one. """ - src = GROUP_PAGE.read_text() + src = GROUP_PAGE.read_text(encoding="utf-8") i = src.index("means tearing down the WebRTC connection") deps = src[i:src.index(");", i)] assert "Boolean(token)" in deps, ( @@ -228,7 +228,7 @@ def test_the_connection_signs_its_offer_with_a_live_token(): It signs the offer relayed through the hub, where an expired one is a 401 and no connection at all. """ - src = GROUP_PAGE.read_text() + src = GROUP_PAGE.read_text(encoding="utf-8") connect = src[src.index("const connect = async () => {"):] connect = connect[:connect.index("\n };")] assert "await ensureFreshToken()" in connect, ( diff --git a/packages/meshbay-hub/tests/test_sidebar_node_section.py b/packages/meshbay-hub/tests/test_sidebar_node_section.py new file mode 100644 index 0000000..371a018 --- /dev/null +++ b/packages/meshbay-hub/tests/test_sidebar_node_section.py @@ -0,0 +1,55 @@ +""" +The sidebar's Node section follows the account's node link, and must follow it +when it changes -- not only at sign-in. + +Reported on a real install: after the first click on Create group, the Node +section (Node, Create group) was gone from the sidebar until a reload, while +the group was created and the node ran. `hasNodeKey` was read once per session +change and never again, so a node linked by the wizard stayed out of the +sidebar; and the one read there was swallowed its errors, so a session blip +(a refused renewal, then the desktop app's silent device sign-in) followed by +one failed request left the section hidden for good. + +Read from the source, like the rest of the SPA's wiring tests: the state lives +inside App, and what matters is the seam between two modules. +""" + +import re +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +APP = (STATIC / "app.js").read_text(encoding="utf-8") +WIZARD = (STATIC / "create-group-page.js").read_text(encoding="utf-8") + + +def _body(src: str, start: str, end: str) -> str: + return src.split(start, 1)[1].split(end, 1)[0] + + +def test_the_create_group_page_can_tell_the_app_a_node_was_linked(): + page = _body(APP, "<${LazyCreateGroupPage}", "/>`;") + assert "onNodeLinked=${refreshNodeKey}" in page + created = _body(page, "onCreated=${() => {", "}}") + assert "refreshNodeKey()" in created + + +def test_the_wizard_says_so_after_each_way_it_links_one(): + link = _body(WIZARD, "const linkNodeKey = useCallback(", "}, [") + assert link.index("/v1/users/me/node_key") < link.index("onNodeLinked()"), ( + "the app must be told after the hub has the key, not before") + start = _body(WIZARD, "const startNode = useCallback(", "}, [") + assert start.index("platform.node.start(") < start.index("onNodeLinked()") + + +def test_one_failed_read_does_not_hide_the_section_for_good(): + refresh = _body(APP, "const refreshNodeKey = useCallback(", "}, [user]);") + assert "/pubkeys" in refresh + assert not re.search(r"\.catch\(\(\)\s*=>\s*\{\s*\}\)", refresh), ( + "a swallowed failure leaves hasNodeKey false until a reload") + assert "setTimeout(" in refresh, "a failed read is tried again" + assert "nodeKeyAskedForRef.current === user" in refresh, ( + "a late answer must not land on a session that has changed") + + +def test_the_session_effect_uses_the_same_read(): + assert APP.count("/pubkeys`") == 1, "one place decides hasNodeKey" diff --git a/packages/meshbay-hub/tests/test_site_basics.py b/packages/meshbay-hub/tests/test_site_basics.py new file mode 100644 index 0000000..78cad83 --- /dev/null +++ b/packages/meshbay-hub/tests/test_site_basics.py @@ -0,0 +1,81 @@ +""" +The files every website is expected to have at the root of its origin. + +They live in the hub's static directory, which is mounted at "/", so every hub +serves them — meshbay.org included, because its Caddyfile hands the hub every +path the public site does not name. +""" + +import re +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from meshbay_hub.app import create_app + +CADDYFILE = Path(__file__).resolve().parents[3] / "packaging" / "caddy" / "meshbay.org.Caddyfile" + + +@pytest.fixture(scope="module") +def client(): + return TestClient(create_app()) + + +def test_robots_keeps_crawlers_out_of_the_signed_in_views(client): + r = client.get("/robots.txt") + assert r.status_code == 200 + assert r.headers["content-type"].startswith("text/plain") + rules = re.findall(r"^Disallow:\s*(\S+)", r.text, re.M) + assert "/app/" in rules and "/v1/" in rules + # A crawler rendering the sign-in page needs its scripts and stylesheet. + assert not any(rule in ("/", "/a/", "/app") for rule in rules), rules + + +@pytest.mark.parametrize("path, magic", [ + ("/favicon.ico", b"\x00\x00\x01\x00"), + ("/apple-touch-icon.png", b"\x89PNG"), +]) +def test_the_icons_are_served_from_the_root(client, path, magic): + """Browsers ask for both at the root whether or not a page links them.""" + r = client.get(path) + assert r.status_code == 200 + assert r.content.startswith(magic), f"{path} is not the image it claims to be" + + +@pytest.mark.skipif(not CADDYFILE.exists(), reason="no Caddyfile in this tree") +@pytest.mark.parametrize("path", ["/robots.txt", "/favicon.ico", "/apple-touch-icon.png"]) +def test_meshbay_org_sends_them_to_the_hub(path): + """The public site owns only the paths its matcher names; a file claimed + there would be looked for in /srv/meshbay/site and 404.""" + m = re.search(r"^\s*@site path (.+)$", CADDYFILE.read_text(encoding="utf-8"), re.M) + assert m, "the @site matcher is gone" + assert path not in m.group(1).split() + + +def _og(html: str, prop: str) -> str | None: + m = re.search(rf'<meta property="og:{prop}" content="([^"]*)">', html) + return m and m.group(1) + + +def test_a_link_to_the_hub_previews_with_the_logo(): + """Messengers draw a link from og:title and og:image, and resolve only an + absolute image URL — so it names the hub's public name, and the file is + one the hub serves.""" + from meshbay_hub.config import load_config + cfg = load_config() + cfg.identity.id = "hub.example.org" + client = TestClient(create_app(cfg)) + for path in ("/", "/app"): + html = client.get(path).text + assert _og(html, "title") == "MeshBay" + assert _og(html, "image") == "https://hub.example.org/og-image.jpg", path + image = client.get("/og-image.jpg") + assert image.status_code == 200 and image.content.startswith(b"\xff\xd8") + assert len(image.content) < 300_000, "too heavy for some messengers to fetch" + + +def test_the_preview_description_fits_in_a_preview(): + """A preview shows a line or two and cuts the rest; a cut sentence says + nothing.""" + from meshbay_hub.api.webapp import PREVIEW_DESCRIPTION + assert len(PREVIEW_DESCRIPTION) <= 60 diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py index 087298f..fdedaf8 100644 --- a/packages/meshbay-hub/tests/test_spa_ordering.py +++ b/packages/meshbay-hub/tests/test_spa_ordering.py @@ -132,7 +132,7 @@ COMPONENT_FILES = { def _component(name: str) -> str: """The source of one top-level `function Name(...)`, up to the next one.""" - source = COMPONENT_FILES.get(name, APP).read_text() + source = COMPONENT_FILES.get(name, APP).read_text(encoding="utf-8") start = source.find(f"\nfunction {name}(") if start == -1: start = source.find(f"\nexport function {name}(") @@ -225,7 +225,7 @@ def test_the_uploader_keeps_several_chunks_in_flight(): def test_no_caller_waits_for_one_chunk_at_a_time(): - app = APP.read_text() + app = APP.read_text(encoding="utf-8") assert "uploadChunk(" not in app, ( "a per-chunk await is back in the SPA; use transport.uploadFile()") @@ -248,7 +248,7 @@ def test_leaving_a_group_hands_the_transport_over_rather_than_closing_it(): def test_signing_out_stops_them(): - app = APP.read_text() + app = APP.read_text(encoding="utf-8") logout = app[app.index(" logout: () => {"):] logout = logout[:logout.index("navigate('/login')")] assert "transfers.reset()" in logout, ( @@ -257,7 +257,7 @@ def test_signing_out_stops_them(): def test_the_files_panel_no_longer_carries_its_own_progress_bars(): """They moved next to the bell, where they stay visible across the app.""" - app = APP.read_text() + app = APP.read_text(encoding="utf-8") for gone in ("setUlState", "setDlState", "dl-bar"): assert gone not in app, f"{gone} survived the move to the transfer widget" @@ -270,7 +270,7 @@ def test_a_multi_file_download_waits_for_each_picker(): meant the first opened a dialog and the rest were rejected — two files selected, one file downloaded. """ - app = STATIC.joinpath("files-app.js").read_text() + app = STATIC.joinpath("files-app.js").read_text(encoding="utf-8") # Anchored on the loop rather than on the markup around it: the toolbar # moved from a dropdown to icon buttons and took the old wrapper with it, # while the property under test — one picker at a time — did not change. @@ -289,7 +289,7 @@ def test_links_in_chat_are_built_as_elements_not_markup(): never HTML, and only for http(s) — otherwise javascript: would be one message away from running here. """ - app = STATIC.joinpath("chat-app.js").read_text() + app = STATIC.joinpath("chat-app.js").read_text(encoding="utf-8") fn = app[app.index("function linkify("):] fn = fn[:fn.index("\nfunction ", 1)] assert "innerHTML" not in fn and "dangerouslySetInnerHTML" not in fn diff --git a/packages/meshbay-hub/tests/test_spa_syntax.py b/packages/meshbay-hub/tests/test_spa_syntax.py index 352f84b..aac4010 100644 --- a/packages/meshbay-hub/tests/test_spa_syntax.py +++ b/packages/meshbay-hub/tests/test_spa_syntax.py @@ -47,7 +47,7 @@ def test_every_module_parses(tmp_path): copy = tmp_path / (path.stem + ".mjs") copy.write_text(path.read_text(encoding="utf-8"), encoding="utf-8") proc = subprocess.run(["node", "--check", str(copy)], - capture_output=True, text=True) + capture_output=True, text=True, encoding="utf-8") if proc.returncode != 0: first = (proc.stderr or "").strip().splitlines() detail = next((ln for ln in first if "Error" in ln), first[:1] and first[0] or "") @@ -67,12 +67,12 @@ def test_the_check_would_notice_a_broken_file(tmp_path): as_js = tmp_path / "sample.js" as_js.write_text(bad, encoding="utf-8") lenient = subprocess.run(["node", "--check", str(as_js)], - capture_output=True, text=True) + capture_output=True, text=True, encoding="utf-8") as_mjs = tmp_path / "sample.mjs" as_mjs.write_text(bad, encoding="utf-8") strict = subprocess.run(["node", "--check", str(as_mjs)], - capture_output=True, text=True) + capture_output=True, text=True, encoding="utf-8") assert strict.returncode != 0, ( "the .mjs check no longer reports a module syntax error — this whole " diff --git a/packages/meshbay-hub/tests/test_streamed_download_reliability.py b/packages/meshbay-hub/tests/test_streamed_download_reliability.py index e1b3800..e60e833 100644 --- a/packages/meshbay-hub/tests/test_streamed_download_reliability.py +++ b/packages/meshbay-hub/tests/test_streamed_download_reliability.py @@ -195,8 +195,8 @@ def _run(tmp_path, body, *, control_after_ms=0, active=True, controlled_at_load=False, registered_at_load=False, worker_asleep=False): module = tmp_path / "downloads.mjs" - module.write_text(DOWNLOADS.read_text()) - (tmp_path / "package.json").write_text('{"type":"module"}') + module.write_text(DOWNLOADS.read_text(encoding="utf-8"), encoding="utf-8") + (tmp_path / "package.json").write_text('{"type":"module"}', encoding="utf-8") plan = { "controlAfterMs": control_after_ms, "active": active, @@ -211,12 +211,12 @@ def _run(tmp_path, body, *, control_after_ms=0, active=True, } script = tmp_path / "case.mjs" script.write_text( - (PRELUDE % {"plan": json.dumps(plan), "module": module.as_posix(), + (PRELUDE % {"plan": json.dumps(plan), "module": module.as_uri(), "control": control_budget_ms, "controlled": json.dumps(controlled_at_load)}) + body - + "\nout.log = log;\nconsole.log(JSON.stringify(out));\n") - proc = subprocess.run(["node", str(script)], capture_output=True, text=True, + + "\nout.log = log;\nconsole.log(JSON.stringify(out));\n", encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8", timeout=120) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) @@ -338,7 +338,7 @@ def test_the_worker_is_primed_at_boot_not_at_the_first_click(tmp_path): from a module that actually imports it — `node --check` would not notice a missing import, which is a mistake this repo has already shipped once. """ - app = (STATIC / "app.js").read_text() + app = (STATIC / "app.js").read_text(encoding="utf-8") assert "downloads.primeServiceWorker()" in app, "nothing primes the worker" assert "import * as downloads from './downloads.js'" in app, ( "app.js calls downloads.primeServiceWorker() without importing downloads") @@ -350,7 +350,7 @@ def test_the_worker_is_primed_at_boot_not_at_the_first_click(tmp_path): def test_the_worker_answers_a_re_claim(tmp_path): """The page's last resort before declaring the path unavailable only works if sw.js implements the other half.""" - sw = (STATIC / "sw.js").read_text() + sw = (STATIC / "sw.js").read_text(encoding="utf-8") assert "mbdl-claim" in sw and "clients.claim()" in sw diff --git a/packages/meshbay-hub/tests/test_table_rows_measured.py b/packages/meshbay-hub/tests/test_table_rows_measured.py index f203624..b1f36ea 100644 --- a/packages/meshbay-hub/tests/test_table_rows_measured.py +++ b/packages/meshbay-hub/tests/test_table_rows_measured.py @@ -61,7 +61,7 @@ SELECTORS = [f"tbody tr:nth-child({r}) td:nth-child({c})" @pytest.fixture(scope="module") def measured(tmp_path_factory): fragment = tmp_path_factory.mktemp("table") / "fragment.html" - fragment.write_text(TABLE) + fragment.write_text(TABLE, encoding="utf-8") proc = subprocess.run( ["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS), str(fragment), *SELECTORS], diff --git a/packages/meshbay-hub/tests/test_token_hardening.py b/packages/meshbay-hub/tests/test_token_hardening.py new file mode 100644 index 0000000..f381f69 --- /dev/null +++ b/packages/meshbay-hub/tests/test_token_hardening.py @@ -0,0 +1,128 @@ +"""A hub-signed token is a session only if it says so, and only while it lasts. + +One Ed25519 key signs four kinds of token: user access, node access, revocation +broadcasts and MHP federation tokens. `decode_access_token` used to accept any +of them that carried a valid signature, requiring no `exp` and binding no +purpose — so a token with no expiry was honoured, and the separation between +the four rested only on which fields each consumer happened to read. A +revocation token is even handed back in the body of `POST /v1/admin/revoke` and +pushed to every node, so nodes hold hub-signed tokens. + +These are refusals: `decode_access_token` must require `exp`, `sub` and a known +`scope`, so a token with no expiry, a revocation token, or an MHP token can +never be mistaken for a session — while a real login token still works. +""" + +import time + +import pytest + +from meshbay_common.tokens import HUB_API_AUD +from meshbay_hub import auth + + +def _sk_pem_loaded(): + # The `client` fixture's app runs load_hub_keypair in its lifespan, so the + # module key is loaded by the time a test body runs. + return auth._hub_sk_pem is not None + + +# ── Unit-level: the decode contract ────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_token_without_exp_is_refused(client): + import jwt + assert _sk_pem_loaded() + # A hub-signed token with a valid scope and sub but NO exp. + forged = jwt.encode({"sub": "u", "scope": "user", "aud": HUB_API_AUD}, + auth.hub_private_key_pem(), algorithm="EdDSA") + with pytest.raises(Exception): + auth.decode_access_token(forged) + + +@pytest.mark.asyncio +async def test_expired_token_is_refused(client): + import jwt + forged = jwt.encode({"sub": "u", "scope": "user", "aud": HUB_API_AUD, + "exp": int(time.time()) - 100}, + auth.hub_private_key_pem(), algorithm="EdDSA") + with pytest.raises(jwt.ExpiredSignatureError): + auth.decode_access_token(forged) + + +@pytest.mark.asyncio +async def test_token_without_scope_is_refused(client): + import jwt + forged = jwt.encode({"sub": "u", "exp": int(time.time()) + 3600, "aud": HUB_API_AUD}, + auth.hub_private_key_pem(), algorithm="EdDSA") + with pytest.raises(Exception): + auth.decode_access_token(forged) + + +@pytest.mark.asyncio +async def test_unknown_scope_is_refused(client): + import jwt + forged = jwt.encode({"sub": "u", "scope": "root", "aud": HUB_API_AUD, + "exp": int(time.time()) + 3600}, + auth.hub_private_key_pem(), algorithm="EdDSA") + with pytest.raises(jwt.InvalidTokenError): + auth.decode_access_token(forged) + + +@pytest.mark.asyncio +async def test_an_mnp_token_is_refused_at_the_hub_api(client): + """The MNP token (aud=MNP_AUD) authorises a member to a node; it must not be + a session at the hub. A node operator holds one, and this is what stops them + replaying it against the hub API.""" + from meshbay_hub.auth import issue_mnp_token + mnp = issue_mnp_token("some-user", groups=[]) + with pytest.raises(Exception): + auth.decode_access_token(mnp) + + +@pytest.mark.asyncio +async def test_a_revocation_token_is_not_a_session(client): + """The concrete cross-type case: revocation tokens are hub-signed, carry no + exp/sub/scope, and are handed to admins and pushed to every node.""" + import jwt + from meshbay_hub.api.revocation import _sign_revocation + rev = _sign_revocation("user", "some-id", "policy") + # It is a genuine hub-signed token (signature verifies) ... + jwt.decode(rev, auth.hub_public_key_pem(), algorithms=["EdDSA"]) + # ... but it is not a session. + with pytest.raises(Exception): + auth.decode_access_token(rev) + + +# ── Endpoint-level: a revocation token gets no access ──────────────────────── + +@pytest.mark.asyncio +async def test_revocation_token_gets_no_api_access(client): + from meshbay_hub.api.revocation import _sign_revocation + rev = _sign_revocation("user", "x", "policy") + r = await client.get("/v1/users/me", headers={"Authorization": f"Bearer {rev}"}) + assert r.status_code == 401 + + +# ── The path that must keep working ────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_a_real_login_token_still_works(client): + await client.post("/v1/users/register", json={ + "username": "live_token_test", "email": "l@test.local", "auth_key": "k" * 44}) + tok = (await client.post("/v1/users/login", json={ + "username": "live_token_test", "auth_key": "k" * 44})).json()["access_token"] + dec = auth.decode_access_token(tok) + assert dec["scope"] == "user" and "exp" in dec and "sub" in dec + r = await client.get("/v1/users/me", headers={"Authorization": f"Bearer {tok}"}) + assert r.status_code == 200 + + +@pytest.mark.asyncio +async def test_a_node_token_still_decodes(client): + """Node access tokens carry scope=node/exp/sub and must keep decoding — the + node decodes its own token, and the hub decodes it on the WS.""" + from meshbay_hub.auth import issue_access_token + tok = issue_access_token("nid", ttl=3600, groups=[], scope="node") + dec = auth.decode_access_token(tok) + assert dec["scope"] == "node" diff --git a/packages/meshbay-hub/tests/test_transfers.py b/packages/meshbay-hub/tests/test_transfers.py index b1bac4a..1d0c0f0 100644 --- a/packages/meshbay-hub/tests/test_transfers.py +++ b/packages/meshbay-hub/tests/test_transfers.py @@ -26,15 +26,15 @@ pytestmark = pytest.mark.skipif( def _run(body, tmp_path): module = tmp_path / "transfers.mjs" - module.write_text(TRANSFERS.read_text()) + module.write_text(TRANSFERS.read_text(encoding="utf-8"), encoding="utf-8") script = tmp_path / "case.mjs" script.write_text( - f"import {{ TransferStore, formatSpeed }} from '{module.as_posix()}';\n" + f"import {{ TransferStore, formatSpeed }} from '{module.as_uri()}';\n" "const out = [];\n" "const say = (...a) => out.push(...a);\n" f"{body}\n" - "console.log(JSON.stringify(out));\n") - proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + "console.log(JSON.stringify(out));\n", encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) @@ -388,11 +388,11 @@ def test_asking_for_a_slot_on_a_dead_channel_does_not_throw(tmp_path): # first time it arms its watchdog. start = src.index("const LEASE_WATCHDOG_MS") end = src.index("\nclass MeshBayTransport") - module.write_text(src[start:end] + "\nexport { Lease };\n") + module.write_text(src[start:end] + "\nexport { Lease };\n", encoding="utf-8") script = tmp_path / "case.mjs" script.write_text(f""" -import {{ Lease }} from '{module.as_posix()}'; +import {{ Lease }} from '{module.as_uri()}'; const out = []; const transport = {{ supportsTransferSlots: true, @@ -409,8 +409,8 @@ try {{ lease.release('cancelled'); out.push('release ok'); }} catch (e) {{ out.push('release threw: ' + e.message); }} clearTimeout(lease._watchdog); console.log(JSON.stringify(out)); -""") - proc = subprocess.run(["node", str(script)], capture_output=True, text=True) +""", encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr out = json.loads(proc.stdout) assert out[0] is None, f"asking for a slot threw: {out[0]}" @@ -433,7 +433,7 @@ def test_the_slot_is_asked_for_after_there_is_somewhere_to_write(): Source-reading, because the ordering is the whole property and it has no behaviour of its own to drive: what matters is which call comes first. """ - src = (STATIC / "file-utils.js").read_text() + src = (STATIC / "file-utils.js").read_text(encoding="utf-8") fn = src[src.index("async function downloadEntry"):] fn = fn[:fn.index("\n}\n")] # `_openTargetInTurn` since target openings were serialised — same call, @@ -832,7 +832,7 @@ def test_a_paused_transfer_is_not_filed_under_finished(tmp_path): here: a copy of them in this file would agree with a broken version by construction. """ - src = (STATIC / "app.js").read_text() + src = (STATIC / "app.js").read_text(encoding="utf-8") start = src.index(" const running = items.filter(") block = src[start:src.index("const active =", start)] @@ -851,8 +851,8 @@ const items = [ const seen = { running, waiting, paused, finished }; console.log(JSON.stringify(Object.fromEntries( Object.entries(seen).map(([k, v]) => [k, v.map(i => i.id)])))); -""") - proc = subprocess.run(["node", str(script)], capture_output=True, text=True) +""", encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr groups = json.loads(proc.stdout) @@ -870,7 +870,7 @@ def test_a_paused_transfer_still_counts_as_active(tmp_path): """The badge says how much is going on. A paused transfer is not over — the person means to come back to it — so counting it as nothing would be a panel that says "0" over work that is still there.""" - src = (STATIC / "app.js").read_text() + src = (STATIC / "app.js").read_text(encoding="utf-8") start = src.index(" const running = items.filter(") block = src[start:src.index("\n\n", src.index("const active =", start))] @@ -879,8 +879,8 @@ def test_a_paused_transfer_still_counts_as_active(tmp_path): const items = [{ id: 1, status: 'paused' }, { id: 2, status: 'done' }]; """ + block + """ console.log(JSON.stringify({ active })); -""") - proc = subprocess.run(["node", str(script)], capture_output=True, text=True) +""", encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr assert json.loads(proc.stdout)["active"] == 1 @@ -897,7 +897,7 @@ def test_a_row_that_cannot_pause_says_so_where_the_button_would_be(): Shown only where a folder can actually be chosen: Firefox and Safari have none to choose, and "choose a folder" would be advice that cannot be taken. """ - src = (STATIC / "app.js").read_text() + src = (STATIC / "app.js").read_text(encoding="utf-8") row = src[src.index("function TransferRow"):] row = row[:row.index("\n}\n")] @@ -917,4 +917,4 @@ def test_the_reason_is_translated_everywhere(): """`t()` falls back to the key, so a missing catalogue entry shows `transfers.not_pausable` in a tooltip rather than a sentence.""" for path in sorted((STATIC / "locales").glob("*.js")): - assert "'transfers.not_pausable'" in path.read_text(), path.name + assert "'transfers.not_pausable'" in path.read_text(encoding="utf-8"), path.name diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py index 978c2e5..c1ca36b 100644 --- a/packages/meshbay-hub/tests/test_transport_contracts.py +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -59,22 +59,22 @@ def transport(): @pytest.fixture(scope="module") def app(): - return APP.read_text() + return APP.read_text(encoding="utf-8") @pytest.fixture(scope="module") def chat(): - return CHAT_APP.read_text() + return CHAT_APP.read_text(encoding="utf-8") @pytest.fixture(scope="module") def group_page(): - return GROUP_PAGE.read_text() + return GROUP_PAGE.read_text(encoding="utf-8") @pytest.fixture(scope="module") def create_group(): - return CREATE_GROUP.read_text() + return CREATE_GROUP.read_text(encoding="utf-8") def test_chat_history_pages_backwards(transport): @@ -187,7 +187,7 @@ def test_messages_are_keyed_by_id_not_index(chat): def test_presence_has_three_states_and_a_label_for_each(app): for state in ("online", "offline", "unknown"): - assert f"presence-{state}" in (STATIC / "style.css").read_text() + assert f"presence-{state}" in (STATIC / "style.css").read_text(encoding="utf-8") assert "t('presence.' + state)" in app, ( "red and green are the pair colour-blind readers cannot separate, so " "the dot needs a title and an aria-label, not just a colour") @@ -278,7 +278,7 @@ def test_no_setter_survives_the_state_it_belonged_to(): """ import re for path in SPLIT_FILES: - app = path.read_text() + app = path.read_text(encoding="utf-8") declared = set(re.findall(r"const \[\s*\w+\s*,\s*(set\w+)\s*\]\s*=\s*useState", app)) # Names brought in from another module are defined, just not here. imported = set() diff --git a/packages/meshbay-hub/tests/test_upload_seal_client.py b/packages/meshbay-hub/tests/test_upload_seal_client.py index bcda14c..f62aa68 100644 --- a/packages/meshbay-hub/tests/test_upload_seal_client.py +++ b/packages/meshbay-hub/tests/test_upload_seal_client.py @@ -48,10 +48,10 @@ BODY = bytes(range(256)) * 3 # 768 bytes → 24 chunks of 32 def _run_probe(payload: dict) -> dict: with tempfile.TemporaryDirectory() as d: f = Path(d) / "input.json" - f.write_text(json.dumps(payload)) + f.write_text(json.dumps(payload), encoding="utf-8") proc = subprocess.run( ["node", str(PROBE), str(STATIC), str(f), transport_argv()], - capture_output=True, text=True, timeout=60, + capture_output=True, text=True, encoding="utf-8", timeout=60, ) if proc.returncode != 0 or not proc.stdout: pytest.fail(f"upload probe failed:\n{proc.stderr}") diff --git a/packages/meshbay-hub/tests/test_versions_agree.py b/packages/meshbay-hub/tests/test_versions_agree.py index 4466c93..46887c0 100644 --- a/packages/meshbay-hub/tests/test_versions_agree.py +++ b/packages/meshbay-hub/tests/test_versions_agree.py @@ -27,11 +27,11 @@ PACKAGES = ROOT / "packages" def _python_versions() -> dict[str, str]: found = {} for pyproject in sorted(PACKAGES.glob("*/pyproject.toml")): - m = re.search(r'^version = "([^"]+)"', pyproject.read_text(), re.M) + m = re.search(r'^version = "([^"]+)"', pyproject.read_text(encoding="utf-8"), re.M) if m: found[f"{pyproject.parent.name}/pyproject.toml"] = m.group(1) for init in sorted(PACKAGES.glob("*/src/*/__init__.py")): - m = re.search(r'^__version__ = "([^"]+)"', init.read_text(), re.M) + m = re.search(r'^__version__ = "([^"]+)"', init.read_text(encoding="utf-8"), re.M) if m: found[f"{init.parent.name}/__init__.py"] = m.group(1) return found @@ -41,7 +41,7 @@ def _client_version() -> str | None: pkg = PACKAGES / "meshbay-client" / "package.json" if not pkg.exists(): return None - return json.loads(pkg.read_text()).get("version") + return json.loads(pkg.read_text(encoding="utf-8")).get("version") @pytest.mark.skipif(not PACKAGES.is_dir(), reason="package layout not present") diff --git a/packages/meshbay-hub/tests/test_video_audio_track.py b/packages/meshbay-hub/tests/test_video_audio_track.py index 54beca2..82d6e18 100644 --- a/packages/meshbay-hub/tests/test_video_audio_track.py +++ b/packages/meshbay-hub/tests/test_video_audio_track.py @@ -42,7 +42,7 @@ pytestmark = pytest.mark.skipif( @pytest.fixture(scope="module") def app(): - return APP.read_text() + return APP.read_text(encoding="utf-8") def _player(app: str) -> str: @@ -88,10 +88,10 @@ def _label_cases(tmp_path, app, cases, locale="en"): "const t = (k, p) => `${k}:${p.n}`;\n" + src + "\nconst out = JSON.parse(process.argv[2]).map(audioTrackLabel);\n" - "console.log(JSON.stringify(out));\n") + "console.log(JSON.stringify(out));\n", encoding="utf-8") proc = subprocess.run( ["node", str(script), json.dumps(cases)], - capture_output=True, text=True) + capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) diff --git a/packages/meshbay-hub/tests/test_video_buffer_ceiling.py b/packages/meshbay-hub/tests/test_video_buffer_ceiling.py index a488976..da9766e 100644 --- a/packages/meshbay-hub/tests/test_video_buffer_ceiling.py +++ b/packages/meshbay-hub/tests/test_video_buffer_ceiling.py @@ -67,7 +67,7 @@ pytestmark = pytest.mark.skipif( @pytest.fixture(scope="module") def app(): - return APP.read_text() + return APP.read_text(encoding="utf-8") def _player(app: str) -> str: @@ -95,7 +95,7 @@ HARNESS = Path(__file__).parent / "harness" / "mse_harness.mjs" def _harness(**cfg) -> dict: proc = subprocess.run( ["node", str(HARNESS), str(APP), json.dumps(cfg)], - capture_output=True, text=True) + capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) @@ -437,7 +437,7 @@ def test_credit_is_a_window_and_not_a_debt(app): pump = pump[:pump.index("\n }, [")] assert "STREAM_WINDOW - outstandingRef.current" in pump, ( "pump() no longer tops a window up to what is allowed in flight") - src = APP.read_text() + src = APP.read_text(encoding="utf-8") window = int(re.search(r"const STREAM_WINDOW = (\d+)", src).group(1)) assert 2 <= window <= 16, ( f"a window of {window} segments is either too small to keep the pipe " diff --git a/packages/meshbay-hub/tests/test_video_default_season.py b/packages/meshbay-hub/tests/test_video_default_season.py index 898d3f5..ab1d7c1 100644 --- a/packages/meshbay-hub/tests/test_video_default_season.py +++ b/packages/meshbay-hub/tests/test_video_default_season.py @@ -43,7 +43,7 @@ BLOCK = re.compile(r"^function defaultSeason\(show\) \{.*?^\}", re.M | re.S) @pytest.fixture(scope="module") def source(): - m = BLOCK.search(APP.read_text()) + m = BLOCK.search(APP.read_text(encoding="utf-8")) assert m, ("defaultSeason is no longer where this test reads it from — the " "season a show opens on is untested until this is fixed") return m.group(0) @@ -57,8 +57,8 @@ def _default(tmp_path, source, seasons): script.write_text(f""" {source} console.log(JSON.stringify(defaultSeason({json.dumps(show)}))); - """) - proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + """, encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) diff --git a/packages/meshbay-hub/tests/test_video_detail_measured.py b/packages/meshbay-hub/tests/test_video_detail_measured.py index 1ac8586..4cd5969 100644 --- a/packages/meshbay-hub/tests/test_video_detail_measured.py +++ b/packages/meshbay-hub/tests/test_video_detail_measured.py @@ -182,7 +182,7 @@ SELECTORS = [ def measured(tmp_path_factory): """One browser for every width — launching one apiece cost three minutes.""" fragment = tmp_path_factory.mktemp("videodetail") / "fragment.html" - fragment.write_text(FRAGMENT) + fragment.write_text(FRAGMENT, encoding="utf-8") proc = subprocess.run( ["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS), str(fragment), *SELECTORS], @@ -292,7 +292,7 @@ def test_the_episode_list_reserves_its_scrollbar(): rectangle — `test_layout_responsive.py` says so at length — but it is worth more than a test that cannot fail. """ - css = (STATIC / "style.css").read_text() + css = (STATIC / "style.css").read_text(encoding="utf-8") rule = re.search( r"\.video-detail\.video-detail-steady \.video-season-list \{([^}]*)\}", css) assert rule, "the steady episode-list rule is gone" diff --git a/packages/meshbay-hub/tests/test_video_reconnect.py b/packages/meshbay-hub/tests/test_video_reconnect.py index beeeace..91aa87b 100644 --- a/packages/meshbay-hub/tests/test_video_reconnect.py +++ b/packages/meshbay-hub/tests/test_video_reconnect.py @@ -53,7 +53,7 @@ pytestmark = pytest.mark.skipif( @pytest.fixture(scope="module") def app(): - return APP.read_text() + return APP.read_text(encoding="utf-8") def _player(app: str) -> str: @@ -87,7 +87,7 @@ def _plan(app: str, cases: list[dict]) -> list[dict]: "(c) => reconnectPlan(c.playhead, c.range, c.ended, c.cast))));" ) proc = subprocess.run(["node", "--input-type=module", "-e", script], - capture_output=True, text=True) + capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) diff --git a/packages/meshbay-hub/tests/test_video_seek.py b/packages/meshbay-hub/tests/test_video_seek.py index 9436065..3289eda 100644 --- a/packages/meshbay-hub/tests/test_video_seek.py +++ b/packages/meshbay-hub/tests/test_video_seek.py @@ -46,7 +46,7 @@ pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailabl @pytest.fixture(scope="module") def app(): - return APP.read_text() + return APP.read_text(encoding="utf-8") def _player(app: str) -> str: @@ -164,7 +164,7 @@ def test_the_leak_deadlocks_the_window_and_the_fix_clears_it(): proc = subprocess.run( ["node", str(harness), str(APP), json.dumps({"decrementFirst": decrement_first})], - capture_output=True, text=True) + capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) @@ -269,7 +269,7 @@ def test_seeks_are_debounced(app): """Dragging fires `seeking` continuously; each one we act on costs a spawn.""" player = _player(app) assert "SEEK_DEBOUNCE_MS" in player, "every intermediate drag position seeks" - ms = int(re.search(r"const SEEK_DEBOUNCE_MS = (\d+)", APP.read_text()).group(1)) + ms = int(re.search(r"const SEEK_DEBOUNCE_MS = (\d+)", APP.read_text(encoding="utf-8")).group(1)) assert 150 <= ms <= 1000, ( f"{ms} ms is either short enough to still storm the node or long " "enough to feel broken") @@ -289,7 +289,7 @@ def test_a_seek_inside_the_buffer_does_not_reach_the_node(app): def test_the_position_is_kept_in_this_browser(app): """localStorage: no protocol, no storage for anyone else to keep, and nothing new learns what you watch.""" - src = APP.read_text() + src = APP.read_text(encoding="utf-8") assert "mb:pos:" in src, "no position is stored" read = src[src.index("function readResumePosition"):] read = read[:read.index("\n}")] @@ -299,7 +299,7 @@ def test_the_position_is_kept_in_this_browser(app): def test_a_finished_film_does_not_offer_to_resume(app): - src = APP.read_text() + src = APP.read_text(encoding="utf-8") write = src[src.index("function writeResumePosition"):] write = write[:write.index("\n}")] assert "RESUME_MAX_FRACTION" in write and "removeItem" in write, ( @@ -317,6 +317,6 @@ def test_the_viewer_can_refuse_the_resume(app): @pytest.mark.parametrize("locale", ["en", "fr", "es", "pt-BR", "zh-CN", "ja", "de", "it", "nl", "pl"]) def test_the_resume_strings_exist_everywhere(locale): - text = (STATIC / "locales" / f"{locale}.js").read_text() + text = (STATIC / "locales" / f"{locale}.js").read_text(encoding="utf-8") for key in ("video.resumed_at", "video.from_start"): assert key in text, f"{locale} is missing {key}" diff --git a/packages/meshbay-hub/tests/test_video_series_stays_open.py b/packages/meshbay-hub/tests/test_video_series_stays_open.py new file mode 100644 index 0000000..e0fe3f0 --- /dev/null +++ b/packages/meshbay-hub/tests/test_video_series_stays_open.py @@ -0,0 +1,74 @@ +""" +A show's detail modal stays open under the player. + +Playing an episode closed the modal, so watching the next one meant finding the +show's card again, opening it, and picking the season again — every episode. +The modal now stays where it was, on the same season, with the episode just +started marked, and the player is drawn over it. + +Measured rather than read: whether the reader lands back on the modal depends on +`PosterGrid`, on `GroupPage` mounting the player beside it, and on which of two +`.video-overlay`s the stylesheet puts on top. The probe renders the shipped +`GroupPage` against a stub node and walks it. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +HARNESS = Path(__file__).parent / "harness" / "video_series_probe.py" +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" + +pytestmark = pytest.mark.skipif( + shutil.which("google-chrome") is None or not (STATIC / "video-app.js").exists(), + reason="Chrome or the SPA sources are not available") + + +@pytest.fixture(scope="module") +def walk(): + run = subprocess.run(["python3", str(HARNESS)], capture_output=True, timeout=150) + assert run.returncode == 0, run.stderr.decode()[-2000:] + out = json.loads(run.stdout.decode()) + assert "error" not in out, out["error"] + return out + + +def test_the_player_is_drawn_over_the_modal(walk): + assert walk["playing"]["detail"], "the show's modal closed when an episode started" + assert walk["playing"]["player"] + assert walk["playing"]["topmost"] == "player" + + +@pytest.mark.parametrize("step", ["afterEscape", "afterClose"]) +def test_closing_the_player_lands_back_on_the_season_being_watched(walk, step): + after = walk[step] + assert after["detail"] and not after["player"] + assert after["topmost"] == "detail" + assert after["season"] == walk["playing"]["season"], "the season picked was lost" + assert after["rows"] == walk["playing"]["rows"] + + +def test_the_episode_just_started_is_marked(walk): + rows = walk["playing"]["rows"] + assert walk["playing"]["marked"] == [rows[1]] + assert walk["afterEscape"]["marked"] == [rows[1]] + # Starting the next one from the modal moves the mark with it. + assert walk["afterClose"]["marked"] == [rows[2]] + + +def test_the_modal_still_closes_and_reopens_clean(walk): + assert not walk["afterModalClose"]["detail"] + reopened = walk["reopened"] + assert reopened["detail"] + assert reopened["marked"] == [], "a mark from the last visit carried over" + assert reopened["season"] != walk["playing"]["season"], ( + "a reopened show starts on its default season, as it always did") + + +def test_a_film_still_closes_its_modal(walk): + """Nothing left to pick once a film starts, so nothing to come back to.""" + assert walk["film"]["player"] + assert not walk["film"]["detail"] + assert walk["film"]["topmost"] == "player" diff --git a/packages/meshbay-hub/tests/test_video_stream_switch.py b/packages/meshbay-hub/tests/test_video_stream_switch.py index 7859057..6c67430 100644 --- a/packages/meshbay-hub/tests/test_video_stream_switch.py +++ b/packages/meshbay-hub/tests/test_video_stream_switch.py @@ -45,7 +45,7 @@ pytestmark = pytest.mark.skipif( @pytest.fixture(scope="module") def app(): - return APP.read_text() + return APP.read_text(encoding="utf-8") def _player(app: str) -> str: @@ -125,8 +125,8 @@ def test_the_stall_is_reproduced_and_the_reset_clears_it(tmp_path): out[name] = p.st.appended - afterFirst; } console.log(JSON.stringify(out)); - """) - proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + """, encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr got = json.loads(proc.stdout) diff --git a/packages/meshbay-hub/tests/test_video_subtitles.py b/packages/meshbay-hub/tests/test_video_subtitles.py index 5a4b215..68058cd 100644 --- a/packages/meshbay-hub/tests/test_video_subtitles.py +++ b/packages/meshbay-hub/tests/test_video_subtitles.py @@ -47,7 +47,7 @@ pytestmark = pytest.mark.skipif( @pytest.fixture(scope="module") def app(): - return APP.read_text() + return APP.read_text(encoding="utf-8") @pytest.fixture(scope="module") @@ -117,10 +117,10 @@ def _label_cases(tmp_path, app, cases, locale="en"): "const t = (k, p) => (p ? `${k}:${p.n}` : k);\n" + src + "\nconst out = JSON.parse(process.argv[2]).map(subtitleTrackLabel);\n" - "console.log(JSON.stringify(out));\n") + "console.log(JSON.stringify(out));\n", encoding="utf-8") proc = subprocess.run( ["node", str(script), json.dumps(cases)], - capture_output=True, text=True) + capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) diff --git a/packages/meshbay-hub/tests/test_welcome_layout_measured.py b/packages/meshbay-hub/tests/test_welcome_layout_measured.py index da72f34..5a77cb9 100644 --- a/packages/meshbay-hub/tests/test_welcome_layout_measured.py +++ b/packages/meshbay-hub/tests/test_welcome_layout_measured.py @@ -40,34 +40,46 @@ def _items(*keys: str) -> str: def _page() -> str: li = _items + # The source row carries the full URL rather than the host name it shows: + # an unbreakable string longer than the real one. + docs = "".join( + f'<li><span class="welcome-docs-label">{_en(label)}</span>' + f'<span class="welcome-docs-links">{links}</span></li>' + for label, links in [ + ("welcome.docs_source", "https://git.meshbay.org/meshbay.git/about/"), + ("welcome.docs_user", f"{_en('welcome.docs_quickstart')} · {_en('welcome.docs_userguide')}"), + ("welcome.docs_devel", f"{_en('welcome.docs_design')} · {_en('welcome.docs_protocol')}"), + ]) return textwrap.dedent(f""" <nav class="nav"><div class="nav-left"><a class="nav-brand" href="#/">MeshBay</a></div> <div class="nav-right"><button class="nav-btn">Login</button></div></nav> <div class="layout"><main class="main"><div class="page-center"><div class="welcome"> - <div class="card login-card"><h2>Login</h2> + <div class="welcome-side"><div class="card login-card"><h2>Login</h2> <form><input type="text" placeholder="Username" /> <input type="password" placeholder="Password" /><button>Login</button></form> <div class="login-footer">No account? <a href="#/register">Register</a></div> <div class="login-footer"><a href="#/reset">Forgot your passphrase?</a></div> </div> + <div class="welcome-links"><a class="welcome-cta" href="#">{_en('welcome.download')}</a> + <a class="welcome-legal" href="#">{_en('welcome.legal')}</a></div></div> <section class="welcome-pitch"> <h1 class="welcome-title">{_en('welcome.title')}</h1> <p class="welcome-lead">{_en('welcome.lead')}</p> <ul class="welcome-apps">{li('welcome.app_chat', 'welcome.app_photos', 'welcome.app_media', 'welcome.app_video', 'welcome.app_music')}</ul> - <p>{_en('welcome.groups')}</p> - <p class="welcome-e2e"><span>{_en('welcome.e2e')}</span></p> + <h2 class="welcome-h">{_en('welcome.how_title')}</h2> + <ol class="welcome-steps">{li('welcome.step_home', 'welcome.step_anywhere', + 'welcome.step_share')}</ol> <h2 class="welcome-h">{_en('welcome.uses_title')}</h2> <ul class="welcome-uses">{li('welcome.use_chat', 'welcome.use_photos', 'welcome.use_media', 'welcome.use_apps')}</ul> - <div class="welcome-hub"><h2 class="welcome-h">{_en('welcome.hub_title')}</h2> - <p>{_en('welcome.hub_body')}</p> - <p class="welcome-hub-never">{_en('welcome.hub_never')}</p> - <ul class="welcome-never">{li('welcome.never_transit', 'welcome.never_stored', - 'welcome.never_e2e')}</ul> - <p class="welcome-hub-free">{_en('welcome.hub_free')}</p></div> - <div class="welcome-links"><a class="welcome-cta" href="#">{_en('welcome.download')}</a> - <a class="welcome-legal" href="#">{_en('welcome.legal')}</a></div> + <div class="welcome-private"><span class="welcome-private-icon"></span><div> + <h2 class="welcome-private-title">{_en('welcome.private_title')}</h2> + <p>{_en('welcome.private_body')}</p> + <ul class="welcome-badges">{li('welcome.badge_free', 'welcome.badge_open', + 'welcome.badge_no_ads', 'welcome.badge_no_tracking')}</ul></div></div> + <div class="welcome-docs"><h2 class="welcome-h">{_en('welcome.docs_title')}</h2> + <ul>{docs}</ul></div> </section> </div></div></main></div> """) @@ -76,7 +88,8 @@ def _page() -> str: PHONES = [320, 360, 412] DESKTOPS = [1100, 1440] WIDTHS = PHONES + [768] + DESKTOPS -SELECTORS = [".welcome", ".login-card", ".welcome-pitch"] +SELECTORS = [".welcome", ".login-card", ".welcome-pitch", ".welcome-steps", ".welcome-docs", + ".welcome-links"] @pytest.fixture(scope="module") @@ -140,3 +153,14 @@ def test_the_pair_is_centred_in_the_window(measured): middle = measured["1440"]["docScrollW"] / 2 centre = box["left"] + box["width"] / 2 assert abs(centre - middle) <= 2, f"the page is centred on x={centre}, not {middle}" + + +@pytest.mark.parametrize("width", WIDTHS) +def test_the_download_and_legal_links_sit_under_the_form(measured, width): + card, links = _box(measured, width, ".login-card"), _box(measured, width, ".welcome-links") + assert links["top"] >= card["top"] + card["height"], ( + f"at {width} px the links are not below the sign-in form") + assert links["top"] - (card["top"] + card["height"]) <= 40, ( + f"at {width} px the links are far below the form") + assert abs(links["left"] - card["left"]) <= 1 and abs(links["width"] - card["width"]) <= 1, ( + f"at {width} px the links are not aligned with the form") diff --git a/packages/meshbay-hub/tests/test_zip_size_limit.py b/packages/meshbay-hub/tests/test_zip_size_limit.py index 9243fd1..6ef0989 100644 --- a/packages/meshbay-hub/tests/test_zip_size_limit.py +++ b/packages/meshbay-hub/tests/test_zip_size_limit.py @@ -46,7 +46,7 @@ def _run(total_bytes, tmp_path, picker=False): for src in STATIC.glob("*.js"): (sandbox / src.name).write_text(src.read_text(encoding="utf-8"), encoding="utf-8") - (tmp_path / "package.json").write_text('{"type":"module"}') + (tmp_path / "package.json").write_text('{"type":"module"}', encoding="utf-8") # ask.js draws a dialog in the DOM, which Node has none of; the question is # answered here instead, exactly where `confirm` used to be stubbed. (sandbox / "ask.js").write_text( @@ -87,7 +87,7 @@ if ({picker_js}) {{ }}); }} -const M = await import('{(sandbox / "file-utils.js").as_posix()}'); +const M = await import('{(sandbox / "file-utils.js").as_uri()}'); // Faithful enough to the real store: it runs `prepare` and honours what it // returns. The target is opened there now — the row exists from the click and @@ -126,7 +126,7 @@ out.limit = M.ZIP_MAX_BYTES; console.log(JSON.stringify(out)); """, encoding="utf-8") - proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) diff --git a/packages/meshbay-hub/tests/test_zipstream.py b/packages/meshbay-hub/tests/test_zipstream.py index 51a42d0..55a4d97 100644 --- a/packages/meshbay-hub/tests/test_zipstream.py +++ b/packages/meshbay-hub/tests/test_zipstream.py @@ -32,12 +32,12 @@ def _build(files, force_zip64=False, tmp_path=None): # <script type="module">, but Node reads a bare .js as CommonJS unless a # package.json says otherwise, and there is none next to the SPA. module = tmp_path / "zipstream.mjs" - module.write_text(ZIPSTREAM.read_text()) + module.write_text(ZIPSTREAM.read_text(encoding="utf-8"), encoding="utf-8") script = tmp_path / "build.mjs" out = tmp_path / "out.zip" script.write_text(f""" import {{ writeFileSync }} from 'node:fs'; -import {{ ZipStream }} from '{module.as_posix()}'; +import {{ ZipStream }} from '{module.as_uri()}'; const files = {json.dumps({k: list(v) for k, v in files.items()})}; const parts = []; @@ -55,8 +55,8 @@ for (const [name, bytes] of Object.entries(files)) {{ }} await zip.finish(); writeFileSync('{out.as_posix()}', Buffer.concat(parts)); -""") - proc = subprocess.run(["node", str(script)], capture_output=True, text=True) +""", encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr return out.read_bytes() @@ -140,14 +140,14 @@ def test_an_empty_archive_is_still_an_archive(tmp_path): def _under(entries, dir_, tmp_path): module = tmp_path / "zipstream.mjs" - module.write_text(ZIPSTREAM.read_text()) + module.write_text(ZIPSTREAM.read_text(encoding="utf-8"), encoding="utf-8") script = tmp_path / "under.mjs" script.write_text(f""" -import {{ entriesUnder }} from '{module.as_posix()}'; +import {{ entriesUnder }} from '{module.as_uri()}'; const out = entriesUnder({json.dumps(entries)}, {json.dumps(dir_)}); console.log(JSON.stringify(out.map(o => o.name))); -""") - proc = subprocess.run(["node", str(script)], capture_output=True, text=True) +""", encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8") assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) |