summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/harness/layout_probe.py2
-rw-r--r--packages/meshbay-hub/tests/harness/lazy_failure_probe.py151
-rw-r--r--packages/meshbay-hub/tests/harness/scroll_probe.py2
-rw-r--r--packages/meshbay-hub/tests/harness/video_series_probe.py266
-rw-r--r--packages/meshbay-hub/tests/test_account_pinning.py4
-rw-r--r--packages/meshbay-hub/tests/test_argon2_off_loop.py2
-rw-r--r--packages/meshbay-hub/tests/test_asset_versioning.py2
-rw-r--r--packages/meshbay-hub/tests/test_availability_between_members.py7
-rw-r--r--packages/meshbay-hub/tests/test_browser_idle_signout.py2
-rw-r--r--packages/meshbay-hub/tests/test_bundle_kdf_parity.py8
-rw-r--r--packages/meshbay-hub/tests/test_captcha_host_check.py9
-rw-r--r--packages/meshbay-hub/tests/test_cast_subtitles.py6
-rw-r--r--packages/meshbay-hub/tests/test_challenge_signature_client.py6
-rw-r--r--packages/meshbay-hub/tests/test_chat_scroll_bottom.py2
-rw-r--r--packages/meshbay-hub/tests/test_client_version_gate.py8
-rw-r--r--packages/meshbay-hub/tests/test_downloads.py54
-rw-r--r--packages/meshbay-hub/tests/test_email_change_requires_passphrase.py55
-rw-r--r--packages/meshbay-hub/tests/test_files_drop_upload.py2
-rw-r--r--packages/meshbay-hub/tests/test_files_sorting.py2
-rw-r--r--packages/meshbay-hub/tests/test_group_purge.py2
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py6
-rw-r--r--packages/meshbay-hub/tests/test_hub_api.py9
-rw-r--r--packages/meshbay-hub/tests/test_incoming_membership.py76
-rw-r--r--packages/meshbay-hub/tests/test_index_seal_client.py4
-rw-r--r--packages/meshbay-hub/tests/test_indexing_dock.py8
-rw-r--r--packages/meshbay-hub/tests/test_invite_email_choice.py5
-rw-r--r--packages/meshbay-hub/tests/test_invite_link_client.py8
-rw-r--r--packages/meshbay-hub/tests/test_invite_links.py45
-rw-r--r--packages/meshbay-hub/tests/test_layout_measured.py4
-rw-r--r--packages/meshbay-hub/tests/test_layout_responsive.py4
-rw-r--r--packages/meshbay-hub/tests/test_lazy_load_failure.py60
-rw-r--r--packages/meshbay-hub/tests/test_locales.py11
-rw-r--r--packages/meshbay-hub/tests/test_mail_is_not_a_relay.py15
-rw-r--r--packages/meshbay-hub/tests/test_media_pager.py4
-rw-r--r--packages/meshbay-hub/tests/test_member_removal.py2
-rw-r--r--packages/meshbay-hub/tests/test_memory_ceiling.py8
-rw-r--r--packages/meshbay-hub/tests/test_mnp_token.py92
-rw-r--r--packages/meshbay-hub/tests/test_music_queue.py2
-rw-r--r--packages/meshbay-hub/tests/test_node_page_pairing.py48
-rw-r--r--packages/meshbay-hub/tests/test_node_page_width_measured.py2
-rw-r--r--packages/meshbay-hub/tests/test_node_scope_not_admin.py159
-rw-r--r--packages/meshbay-hub/tests/test_notification_dismissal.py4
-rw-r--r--packages/meshbay-hub/tests/test_offer_retry.py2
-rw-r--r--packages/meshbay-hub/tests/test_playlist_crypto.py6
-rw-r--r--packages/meshbay-hub/tests/test_playlist_key.py6
-rw-r--r--packages/meshbay-hub/tests/test_playlist_merge.py6
-rw-r--r--packages/meshbay-hub/tests/test_queue_ops.py6
-rw-r--r--packages/meshbay-hub/tests/test_rate_limit_key.py48
-rw-r--r--packages/meshbay-hub/tests/test_reconnect_refresh.py12
-rw-r--r--packages/meshbay-hub/tests/test_recovery_key.py4
-rw-r--r--packages/meshbay-hub/tests/test_resume_position.py2
-rw-r--r--packages/meshbay-hub/tests/test_revoke_is_one_path.py164
-rw-r--r--packages/meshbay-hub/tests/test_rewrap_fanout.py4
-rw-r--r--packages/meshbay-hub/tests/test_search_connect_deadline.py2
-rw-r--r--packages/meshbay-hub/tests/test_search_fanout.py2
-rw-r--r--packages/meshbay-hub/tests/test_search_files_unmerged.py2
-rw-r--r--packages/meshbay-hub/tests/test_search_media_merge.py10
-rw-r--r--packages/meshbay-hub/tests/test_search_pool.py2
-rw-r--r--packages/meshbay-hub/tests/test_search_source_merge.py6
-rw-r--r--packages/meshbay-hub/tests/test_season_panel_placement.py10
-rw-r--r--packages/meshbay-hub/tests/test_session_renewal.py14
-rw-r--r--packages/meshbay-hub/tests/test_sidebar_node_section.py55
-rw-r--r--packages/meshbay-hub/tests/test_site_basics.py81
-rw-r--r--packages/meshbay-hub/tests/test_spa_ordering.py12
-rw-r--r--packages/meshbay-hub/tests/test_spa_syntax.py6
-rw-r--r--packages/meshbay-hub/tests/test_streamed_download_reliability.py14
-rw-r--r--packages/meshbay-hub/tests/test_table_rows_measured.py2
-rw-r--r--packages/meshbay-hub/tests/test_token_hardening.py128
-rw-r--r--packages/meshbay-hub/tests/test_transfers.py34
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py12
-rw-r--r--packages/meshbay-hub/tests/test_upload_seal_client.py4
-rw-r--r--packages/meshbay-hub/tests/test_versions_agree.py6
-rw-r--r--packages/meshbay-hub/tests/test_video_audio_track.py6
-rw-r--r--packages/meshbay-hub/tests/test_video_buffer_ceiling.py6
-rw-r--r--packages/meshbay-hub/tests/test_video_default_season.py6
-rw-r--r--packages/meshbay-hub/tests/test_video_detail_measured.py4
-rw-r--r--packages/meshbay-hub/tests/test_video_reconnect.py4
-rw-r--r--packages/meshbay-hub/tests/test_video_seek.py12
-rw-r--r--packages/meshbay-hub/tests/test_video_series_stays_open.py74
-rw-r--r--packages/meshbay-hub/tests/test_video_stream_switch.py6
-rw-r--r--packages/meshbay-hub/tests/test_video_subtitles.py6
-rw-r--r--packages/meshbay-hub/tests/test_welcome_layout_measured.py48
-rw-r--r--packages/meshbay-hub/tests/test_zip_size_limit.py6
-rw-r--r--packages/meshbay-hub/tests/test_zipstream.py16
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)