aboutsummaryrefslogtreecommitdiffstats
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/lazy_failure_probe.py151
-rw-r--r--packages/meshbay-hub/tests/harness/video_series_probe.py266
-rw-r--r--packages/meshbay-hub/tests/test_account_deletion.py20
-rw-r--r--packages/meshbay-hub/tests/test_availability_between_members.py139
-rw-r--r--packages/meshbay-hub/tests/test_challenge_signature_client.py2
-rw-r--r--packages/meshbay-hub/tests/test_invite_link_client.py11
-rw-r--r--packages/meshbay-hub/tests/test_lazy_load_failure.py60
-rw-r--r--packages/meshbay-hub/tests/test_memory_ceiling.py1
-rw-r--r--packages/meshbay-hub/tests/test_moderation.py306
-rw-r--r--packages/meshbay-hub/tests/test_portable_save_names.py42
-rw-r--r--packages/meshbay-hub/tests/test_unauthenticated_surface.py4
-rw-r--r--packages/meshbay-hub/tests/test_video_series_stays_open.py74
12 files changed, 858 insertions, 218 deletions
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/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_deletion.py b/packages/meshbay-hub/tests/test_account_deletion.py
index 2653f0d..64c2be8 100644
--- a/packages/meshbay-hub/tests/test_account_deletion.py
+++ b/packages/meshbay-hub/tests/test_account_deletion.py
@@ -130,16 +130,9 @@ def _device_pk() -> str:
@pytest.mark.asyncio
-async def test_deletion_clears_device_keys_and_swarm_sources(client, db_session):
- """
- The privacy statement says every account row goes but the IP log. Swarm
- sources are keyed by the *user* id despite the column's name, and carry the
- node's transport and port — `webrtc:<port>`, which is what `daemon.py`
- actually sends. This asked with `192.0.2.7:4433`, from the days when the
- field was free text documented as "ip:port": a shape no node has ever
- produced, and one that let a caller name a third party's address.
- """
- from meshbay_hub.db.models import SwarmSource, UserDevice
+async def test_deletion_clears_device_keys(client, db_session):
+ """The privacy statement says every account row goes but the IP log."""
+ from meshbay_hub.db.models import UserDevice
token, password = await _register(client, "devicer_test")
headers = {"Authorization": f"Bearer {token}"}
@@ -149,15 +142,10 @@ async def test_deletion_clears_device_keys_and_swarm_sources(client, db_session)
r = await client.post("/v1/users/devices", headers=headers,
json={"pk_auth_ed25519": _device_pk(), "label": "desktop"})
assert r.status_code == 201, r.text
- r = await client.post("/v1/swarm/register", headers=headers,
- json={"content_hash": "ab" * 32, "endpoint": "webrtc:4433"})
- assert r.status_code == 201, r.text
# Present before, or the emptiness asserted below proves nothing.
assert (await db_session.execute(
select(UserDevice).where(UserDevice.user_id == uid))).scalars().all()
- assert (await db_session.execute(
- select(SwarmSource).where(SwarmSource.node_id == uid))).scalars().all()
r = await client.request("DELETE", "/v1/users/me", headers=headers,
json={"auth_key": _auth_key(password, "devicer_test")})
@@ -166,8 +154,6 @@ async def test_deletion_clears_device_keys_and_swarm_sources(client, db_session)
db_session.expire_all()
assert (await db_session.execute(
select(UserDevice).where(UserDevice.user_id == uid))).scalars().all() == []
- assert (await db_session.execute(
- select(SwarmSource).where(SwarmSource.node_id == uid))).scalars().all() == []
@pytest.mark.asyncio
diff --git a/packages/meshbay-hub/tests/test_availability_between_members.py b/packages/meshbay-hub/tests/test_availability_between_members.py
index 24d85a0..e66be31 100644
--- a/packages/meshbay-hub/tests/test_availability_between_members.py
+++ b/packages/meshbay-hub/tests/test_availability_between_members.py
@@ -202,56 +202,6 @@ async def test_the_notify_budget_is_not_refilled_by_reconnecting(client):
rev._notify_window.pop(node_id, None)
-# ── A member must not aim other people's traffic ─────────────────────────────
-
-@pytest.mark.asyncio
-async def test_a_swarm_source_cannot_name_someone_elses_address(client):
- """
- `endpoint` was free text documented as "ip:port", so an account could
- publish a third party's address as a source for any content. Nothing dials
- a swarm source today, which is the only reason this was not already the
- reflection primitive that `notify_incoming` was fixed for (H6). A port is
- all a reader needs: where the node is comes from the node record, which is
- stamped with the address its announce arrived from.
- """
- user = await _make_user(client, "av_swarm1")
- headers = {"Authorization": f"Bearer {user['token']}"}
-
- for bad in ("192.0.2.7:4433", "evil.example:53", "webrtc:0", "webrtc:70000",
- "webrtc:4433 ", "http://example.test"):
- r = await client.post("/v1/swarm/register", headers=headers,
- json={"content_hash": "ab" * 32, "endpoint": bad})
- assert r.status_code == 422, f"{bad!r} was accepted: {r.text}"
-
- r = await client.post("/v1/swarm/register", headers=headers,
- json={"content_hash": "ab" * 32, "endpoint": "webrtc:19010"})
- assert r.status_code == 201, r.text
-
-
-@pytest.mark.asyncio
-async def test_one_account_cannot_fill_the_swarm_table(client, monkeypatch):
- """Rows are keyed (hash, account) with no cap — an invented hash each time."""
- import meshbay_hub.api.groups as groups_api
- monkeypatch.setattr(groups_api, "MAX_SWARM_HASHES_PER_ACCOUNT", 3)
-
- user = await _make_user(client, "av_swarm2")
- headers = {"Authorization": f"Bearer {user['token']}"}
- for i in range(3):
- r = await client.post("/v1/swarm/register", headers=headers,
- json={"content_hash": f"{i:064x}",
- "endpoint": "webrtc:19010"})
- assert r.status_code == 201, r.text
-
- r = await client.post("/v1/swarm/register", headers=headers,
- json={"content_hash": f"{99:064x}", "endpoint": "webrtc:19010"})
- assert r.status_code == 429, r.text
-
- # Refreshing one already held is not a new claim and must still work.
- r = await client.post("/v1/swarm/register", headers=headers,
- json={"content_hash": f"{0:064x}", "endpoint": "webrtc:19011"})
- assert r.status_code == 201, r.text
-
-
# ── A member's node must not answer for another's ────────────────────────────
def test_a_node_cannot_answer_an_offer_it_was_never_sent():
@@ -321,75 +271,6 @@ async def test_an_invite_email_says_what_the_hub_knows_not_what_it_is_told(
"the sender chose the subject line of a message the hub signs")
-# ── A relay is not authenticated by the key it publishes ─────────────────────
-
-@pytest.mark.asyncio
-async def test_a_relay_must_prove_it_holds_the_approved_key(client, monkeypatch):
- """
- `relay_register` had no `Depends` and verified nothing: it compared
- `pk_relay` against the approved value, which is a **public** key. Anyone
- who could read it could rewrite where the hub tells nodes to send relayed
- traffic — an unauthenticated write to state other people's machines act
- on. The module docstring said the relay "signs keepalive JWTs"; `jwt` was
- imported and never used.
- """
- from meshbay_hub.api import relay as relay_mod
-
- # The registry ships closed (`relay.RELAYS_ENABLED`); the proof it demands
- # is still what will be wanted the day it opens.
- monkeypatch.setattr(relay_mod, "RELAYS_ENABLED", True)
- sk = Ed25519PrivateKey.generate()
- pk = pk_to_b64(sk.public_key())
- relay_mod._relays["r1"] = {"pk": pk, "active": False}
- try:
- # The public key alone, which used to be enough.
- r = await client.post("/v1/relays/register", json={
- "relay_id": "r1", "endpoint": "198.51.100.9:9999",
- "pk_relay": pk, "capacity": 100})
- assert r.status_code == 400, r.text
- assert relay_mod._relays["r1"].get("endpoint") is None
-
- # A signature over someone else's endpoint does not carry either: the
- # endpoint is inside the signed message.
- ts = int(time.time())
- sig = sk.sign(f"meshbay:relay_register:r1:10.0.0.1:4433:{ts}".encode())
- r = await client.post("/v1/relays/register", json={
- "relay_id": "r1", "endpoint": "198.51.100.9:9999", "pk_relay": pk,
- "timestamp": ts, "signature": base64.b64encode(sig).decode()})
- assert r.status_code == 401, r.text
-
- endpoint = "203.0.113.4:4433"
- sig = sk.sign(f"meshbay:relay_register:r1:{endpoint}:{ts}".encode())
- r = await client.post("/v1/relays/register", json={
- "relay_id": "r1", "endpoint": endpoint, "pk_relay": pk,
- "timestamp": ts, "signature": base64.b64encode(sig).decode()})
- assert r.status_code == 201, r.text
- assert relay_mod._relays["r1"]["endpoint"] == endpoint
- finally:
- relay_mod._relays.pop("r1", None)
-
-
-@pytest.mark.asyncio
-async def test_every_relay_route_is_closed_as_the_hub_ships(client):
- """Nothing in the tree uses the registry, and two of its routes take no account.
-
- A dependency on the router, so a route added later is closed too. The flag
- is read as shipped, not set here — a test that closes the gate itself
- would keep passing the day somebody opens it.
- """
- admin = await _make_user(client, "relayadmin")
- from meshbay_hub.api.deps import set_admin_usernames
- set_admin_usernames(["relayadmin"])
- auth = {"Authorization": f"Bearer {admin['token']}"}
-
- for method, path in (("get", "/v1/relays"),
- ("post", "/v1/relays/register"),
- ("post", "/v1/relays/approve")):
- kwargs = {"headers": auth} if method == "get" else {"json": {}, "headers": auth}
- r = await getattr(client, method)(path, **kwargs)
- assert r.status_code == 503, (path, r.status_code, r.text)
-
-
@pytest.mark.asyncio
async def test_a_stranger_who_locks_your_name_does_not_sign_you_out(client, db_session):
"""AV26. The sign-in lockout is keyed by username, and usernames are public.
@@ -442,26 +323,6 @@ async def test_a_stranger_who_locks_your_name_does_not_sign_you_out(client, db_s
assert r.status_code == 200, r.text
-@pytest.mark.asyncio
-async def test_a_captured_relay_registration_is_not_replayable(client, monkeypatch):
- """Same reason /v1/nodes/announce bounds its timestamp."""
- from meshbay_hub.api import relay as relay_mod
-
- monkeypatch.setattr(relay_mod, "RELAYS_ENABLED", True)
- sk = Ed25519PrivateKey.generate()
- pk = pk_to_b64(sk.public_key())
- relay_mod._relays["r2"] = {"pk": pk, "active": False}
- try:
- ts = int(time.time()) - 3600
- sig = sk.sign(f"meshbay:relay_register:r2:203.0.113.5:4433:{ts}".encode())
- r = await client.post("/v1/relays/register", json={
- "relay_id": "r2", "endpoint": "203.0.113.5:4433", "pk_relay": pk,
- "timestamp": ts, "signature": base64.b64encode(sig).decode()})
- assert r.status_code == 401, r.text
- finally:
- relay_mod._relays.pop("r2", None)
-
-
# ── Mail: three paths out of the hub, one of them unmetered ──────────────────
@pytest.mark.asyncio
diff --git a/packages/meshbay-hub/tests/test_challenge_signature_client.py b/packages/meshbay-hub/tests/test_challenge_signature_client.py
index b904698..c13c55d 100644
--- a/packages/meshbay-hub/tests/test_challenge_signature_client.py
+++ b/packages/meshbay-hub/tests/test_challenge_signature_client.py
@@ -85,7 +85,7 @@ def test_the_browser_holds_the_node_to_its_challenge(tmp_path):
cases = {
"signed over this connection": (case(), "true"),
- "an older node, no signature": (case(sig=None), "false"),
+ "no signature": (case(sig=None), "refused"),
"another key announced": (case(pk=sk_other), "refused"),
"a relay's fingerprint": (case(answer=os.urandom(32)), "refused"),
"a replay under another nonce": (case(nonce=os.urandom(32)), "refused"),
diff --git a/packages/meshbay-hub/tests/test_invite_link_client.py b/packages/meshbay-hub/tests/test_invite_link_client.py
index aa8c194..3fb8922 100644
--- a/packages/meshbay-hub/tests/test_invite_link_client.py
+++ b/packages/meshbay-hub/tests/test_invite_link_client.py
@@ -151,14 +151,13 @@ def test_a_link_code_goes_to_the_node_the_link_names_and_no_other(tmp_path):
got = _run(tmp_path, fn.group(0) + """
const r = (...a) => { const e = _linkJoinRefusal(...a); return e ? e.reason : null; };
process.stdout.write(JSON.stringify([
- r('KEY', 'K7P2-9WQX', 'KEY', true),
- r('KEY', 'K7P2-9WQX', 'OTHER', true),
- r('KEY', 'K7P2-9WQX', 'KEY', false),
- r(undefined, 'K7P2-9WQX', 'OTHER', false),
- r('KEY', null, 'OTHER', false),
+ r('KEY', 'K7P2-9WQX', 'KEY'),
+ r('KEY', 'K7P2-9WQX', 'OTHER'),
+ r(undefined, 'K7P2-9WQX', 'OTHER'),
+ r('KEY', null, 'OTHER'),
]));
""")
- assert got == [None, "link_other_node", "link_node_unproved", None, None]
+ assert got == [None, "link_other_node", None, None]
# ── Read from the source ─────────────────────────────────────────────────────
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_memory_ceiling.py b/packages/meshbay-hub/tests/test_memory_ceiling.py
index 9ea6ad3..48aac1a 100644
--- a/packages/meshbay-hub/tests/test_memory_ceiling.py
+++ b/packages/meshbay-hub/tests/test_memory_ceiling.py
@@ -84,7 +84,6 @@ const platform = {{
bridgeMessage: (e) => String(e),
}};
const downloads = {{
- BLOB_LIMIT: 512 * 1024 * 1024,
// Called by the refusal to name why the streamed path declined -- absent
// from this stub, the error constructor threw TypeError and the test saw the
// wrong failure entirely.
diff --git a/packages/meshbay-hub/tests/test_moderation.py b/packages/meshbay-hub/tests/test_moderation.py
index 3109e29..b599c84 100644
--- a/packages/meshbay-hub/tests/test_moderation.py
+++ b/packages/meshbay-hub/tests/test_moderation.py
@@ -22,6 +22,20 @@ async def _register_and_login(client, username: str) -> dict:
return {"Authorization": f"Bearer {r.json()['access_token']}"}
+async def _node_headers(client, username: str) -> dict:
+ """A node daemon's token for a fresh account — what a node syncs with."""
+ from meshbay_hub.auth import issue_access_token
+ user = await _register_and_login(client, username)
+ me = (await client.get("/v1/users/me", headers=user)).json()
+ tok = issue_access_token(me["user_id"], ttl=3600, groups=[], scope="node")
+ return {"Authorization": f"Bearer {tok}"}
+
+
+async def _blocked(client, h: str) -> bool:
+ node = await _node_headers(client, f"node_{h[:6]}_{len(h)}")
+ return h in (await client.get("/v1/blocklist", headers=node)).json()["hashes"]
+
+
@pytest.fixture
async def reporter(client):
return await _register_and_login(client, "reporter_one")
@@ -34,79 +48,198 @@ async def admin_headers(client):
return headers
+async def _policy(client, admin, **values):
+ r = await client.patch("/v1/admin/settings", json={"reports": values}, headers=admin)
+ assert r.status_code == 200, r.text
+ return r.json()["reports"]
+
+
+async def _public_group(client, db_session, owner, name="commons-mod"):
+ from datetime import UTC, datetime
+
+ from meshbay_hub.db.models import Group
+ r = await client.post("/v1/groups", json={"name": name, "visibility": "public",
+ "join_policy": "open"}, headers=owner)
+ assert r.status_code == 201, r.text
+ gid = r.json()["group_id"]
+ (await db_session.get(Group, gid)).hosted_at = datetime.now(UTC)
+ await db_session.commit()
+ return gid
+
+
+async def _members(client, gid, names):
+ out = []
+ for n in names:
+ h = await _register_and_login(client, n)
+ assert (await client.post(f"/v1/groups/{gid}/join", headers=h)).status_code == 200
+ out.append(h)
+ return out
+
+
+async def _report(client, headers, gid, h=FAKE_HASH, **extra):
+ return await client.post("/v1/reports", headers=headers,
+ json={"content_hash": h, "group_id": gid,
+ "reason": "illegal", **extra})
+
+
+@pytest.fixture
+async def setting(client, admin_headers):
+ """Reports allowed from a new account, so tests need not wait a day."""
+ await _policy(client, admin_headers, min_account_age_hours=0)
+ return admin_headers
+
+
@pytest.mark.asyncio
async def test_report_requires_auth(client):
- # No credentials at all — FastAPI rejects the missing header before the body.
r = await client.post("/v1/reports", json={
- "content_hash": FAKE_HASH, "reason": "illegal"})
+ "content_hash": FAKE_HASH, "group_id": "g", "reason": "illegal"})
assert r.status_code in (401, 422)
-
- # A bogus token is a clean 401.
r = await client.post("/v1/reports",
- json={"content_hash": FAKE_HASH, "reason": "illegal"},
+ json={"content_hash": FAKE_HASH, "group_id": "g",
+ "reason": "illegal"},
headers={"Authorization": "Bearer not-a-real-token"})
assert r.status_code == 401
@pytest.mark.asyncio
-async def test_report_content_logged(client, reporter):
- r = await client.post("/v1/reports",
- json={"content_hash": FAKE_HASH, "reason": "illegal"},
- headers=reporter)
- assert r.status_code == 201
- data = r.json()
- assert data["report_count"] == 1
- assert data["status"] == "logged"
+async def test_a_node_token_cannot_report(client, db_session, setting):
+ owner = await _register_and_login(client, "owner_nodetok")
+ gid = await _public_group(client, db_session, owner, "nodetok-grp")
+ node = await _node_headers(client, "node_reporter")
+ assert (await _report(client, node, gid)).status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_a_new_account_cannot_report_yet(client, db_session, admin_headers):
+ owner = await _register_and_login(client, "owner_young")
+ gid = await _public_group(client, db_session, owner, "young-grp")
+ [young] = await _members(client, gid, ["young_member"])
+ r = await _report(client, young, gid)
+ assert r.status_code == 403 and "too new" in r.json()["detail"]
+
+
+@pytest.mark.asyncio
+async def test_only_a_member_of_that_public_group_may_report(client, db_session, setting):
+ owner = await _register_and_login(client, "owner_member")
+ gid = await _public_group(client, db_session, owner, "member-grp")
+ stranger = await _register_and_login(client, "stranger_one")
+ private = (await client.post("/v1/groups", json={"name": "priv-mod"},
+ headers=owner)).json()["group_id"]
+ answers = {(await _report(client, stranger, gid)).json()["detail"],
+ (await _report(client, owner, private)).json()["detail"],
+ (await _report(client, stranger, "no-such-group")).json()["detail"]}
+ # One uniform refusal: it must not say which groups exist or who is in them.
+ assert len(answers) == 1
+ assert (await _report(client, owner, gid)).status_code == 201
@pytest.mark.asyncio
-async def test_same_reporter_cannot_walk_the_threshold(client, reporter):
+async def test_a_report_says_nothing_about_how_close_review_is(client, db_session, setting):
+ owner = await _register_and_login(client, "owner_quiet")
+ gid = await _public_group(client, db_session, owner, "quiet-grp")
+ r = await _report(client, owner, gid)
+ assert r.status_code == 201 and r.json() == {"status": "logged"}
+
+
+@pytest.mark.asyncio
+async def test_same_reporter_cannot_walk_the_threshold(client, db_session, setting):
+ await _policy(client, setting, review_threshold=1)
+ owner = await _register_and_login(client, "owner_walk")
+ gid = await _public_group(client, db_session, owner, "walk-grp")
h = "b" * 64
- for _ in range(5):
- r = await client.post("/v1/reports",
- json={"content_hash": h, "reason": "spam"},
- headers=reporter)
- assert r.json()["report_count"] == 1
+ [m] = await _members(client, gid, ["walker_one"])
+ await _report(client, m, gid, h)
+ for _ in range(4):
+ r = await _report(client, m, gid, h)
assert r.json()["status"] == "already_reported"
- check = await client.get(f"/v1/blocklist/check?hash={h}")
- assert check.json()["blocked"] is False
-
@pytest.mark.asyncio
-async def test_auto_block_on_distinct_reporters(client):
+async def test_reaching_the_threshold_queues_for_an_administrator(client, db_session, setting):
+ owner = await _register_and_login(client, "owner_queue")
+ gid = await _public_group(client, db_session, owner, "queue-grp")
h = "c" * 64
- for i in range(3):
- headers = await _register_and_login(client, f"reporter_{i}")
- r = await client.post("/v1/reports",
- json={"content_hash": h, "reason": "illegal"},
- headers=headers)
- assert r.json()["status"] == "auto_blocked"
- assert r.json()["report_count"] == 3
+ for m in await _members(client, gid, ["queue_r0", "queue_r1", "queue_r2"]):
+ assert (await _report(client, m, gid, h)).status_code == 201
- check = await client.get(f"/v1/blocklist/check?hash={h}")
- assert check.json()["blocked"] is True
+ assert not await _blocked(client, h), "nothing is blocked without a decision"
+ queue = (await client.get("/v1/admin/reports", headers=setting)).json()["reports"]
+ [item] = [q for q in queue if q["hash"] == h]
+ assert item["reporters"] == 3 and item["reasons"] == {"illegal": 3}
+ assert item["groups"] == [{"id": gid, "name": "queue-grp"}]
+ notes = (await client.get("/v1/notifications", headers=setting)).json()
+ assert any(n["kind"] == "content_review" for n in notes["notifications"])
+
+ r = await client.post(f"/v1/admin/reports/{h}/block", headers=setting)
+ assert r.status_code == 200
+ assert await _blocked(client, h)
+ queue = (await client.get("/v1/admin/reports", headers=setting)).json()["reports"]
+ assert h not in [q["hash"] for q in queue]
@pytest.mark.asyncio
-async def test_reports_refused_when_public_groups_disabled(client, reporter, admin_headers):
- await client.patch("/v1/admin/settings",
- json={"allow_public_groups": False},
- headers=admin_headers)
+async def test_a_dismissed_report_stays_dismissed(client, db_session, setting):
+ await _policy(client, setting, review_threshold=1)
+ owner = await _register_and_login(client, "owner_dismiss")
+ gid = await _public_group(client, db_session, owner, "dismiss-grp")
+ h = "d" * 64
+ [a, b] = await _members(client, gid, ["dismiss_a", "dismiss_b"])
+ await _report(client, a, gid, h)
+ assert (await client.post(f"/v1/admin/reports/{h}/dismiss",
+ headers=setting)).status_code == 200
+ await _report(client, b, gid, h)
+ queue = (await client.get("/v1/admin/reports", headers=setting)).json()["reports"]
+ assert h not in [q["hash"] for q in queue]
+ assert not await _blocked(client, h)
- r = await client.post("/v1/reports",
- json={"content_hash": "d" * 64, "reason": "illegal"},
- headers=reporter)
+
+@pytest.mark.asyncio
+async def test_automatic_blocking_is_the_instances_choice(client, db_session, setting):
+ await _policy(client, setting, review_threshold=2, auto_block=1)
+ owner = await _register_and_login(client, "owner_auto")
+ gid = await _public_group(client, db_session, owner, "auto-grp")
+ h = "e" * 64
+ for m in await _members(client, gid, ["autoblock_r0", "autoblock_r1"]):
+ await _report(client, m, gid, h)
+ assert await _blocked(client, h)
+
+
+@pytest.mark.asyncio
+async def test_a_member_has_a_daily_allowance(client, db_session, setting):
+ await _policy(client, setting, daily_per_account=2)
+ owner = await _register_and_login(client, "owner_daily")
+ gid = await _public_group(client, db_session, owner, "daily-grp")
+ for i in range(2):
+ assert (await _report(client, owner, gid, f"{i:064x}")).status_code == 201
+ assert (await _report(client, owner, gid, f"{9:064x}")).status_code == 429
+
+
+@pytest.mark.asyncio
+async def test_a_report_is_shaped(client, db_session, setting):
+ owner = await _register_and_login(client, "owner_shape")
+ gid = await _public_group(client, db_session, owner, "shape-grp")
+ assert (await _report(client, owner, gid, "not-a-hash")).status_code == 422
+ assert (await _report(client, owner, gid, reason="because")).status_code == 422
+ assert (await _report(client, owner, gid, detail="x" * 257)).status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_only_an_administrator_decides(client, db_session, setting):
+ await _policy(client, setting, review_threshold=1)
+ owner = await _register_and_login(client, "owner_decide")
+ gid = await _public_group(client, db_session, owner, "decide-grp")
+ await _report(client, owner, gid, "f" * 64)
+ r = await client.post(f"/v1/admin/reports/{'f' * 64}/block", headers=owner)
assert r.status_code == 403
@pytest.mark.asyncio
-async def test_invalid_hash_rejected(client, reporter):
- r = await client.post("/v1/reports",
- json={"content_hash": "not-a-valid-blake3-hash",
- "reason": "test"},
- headers=reporter)
- assert r.status_code == 422
+async def test_reports_refused_when_public_groups_disabled(client, db_session, setting):
+ owner = await _register_and_login(client, "owner_off")
+ gid = await _public_group(client, db_session, owner, "off-grp")
+ await client.patch("/v1/admin/settings", json={"allow_public_groups": False},
+ headers=setting)
+ assert (await _report(client, owner, gid)).status_code == 403
@pytest.mark.asyncio
@@ -118,14 +251,13 @@ async def test_admin_add_remove_blocklist(client, admin_headers):
headers=admin_headers)
assert r.status_code == 201
- r = await client.get(f"/v1/blocklist/check?hash={hash4}")
- assert r.json()["blocked"] is True
+ assert await _blocked(client, hash4)
r = await client.delete(f"/v1/admin/blocklist/{hash4}", headers=admin_headers)
assert r.status_code == 200
- r = await client.get(f"/v1/blocklist/check?hash={hash4}")
- assert r.json()["blocked"] is False
+ node = await _node_headers(client, "node_after_unblock")
+ assert hash4 not in (await client.get("/v1/blocklist", headers=node)).json()["hashes"]
@pytest.mark.asyncio
@@ -134,6 +266,80 @@ async def test_full_blocklist(client, admin_headers):
await client.post("/v1/admin/blocklist",
json={"content_hash": hash5, "reason": "test"},
headers=admin_headers)
- r = await client.get("/v1/blocklist")
+ node = await _node_headers(client, "node_full")
+ r = await client.get("/v1/blocklist", headers=node)
assert r.status_code == 200
assert hash5 in r.json()["hashes"]
+
+
+@pytest.mark.asyncio
+async def test_only_a_node_token_reads_the_blocklist(client, reporter):
+ assert (await client.get("/v1/blocklist")).status_code in (401, 422)
+ assert (await client.get("/v1/blocklist", headers=reporter)).status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_the_blocklist_pages_past_its_limit(client, admin_headers):
+ hashes = sorted(f"{i:064x}" for i in range(5))
+ for h in hashes:
+ await client.post("/v1/admin/blocklist",
+ json={"content_hash": h, "reason": "test"},
+ headers=admin_headers)
+ node = await _node_headers(client, "node_pager")
+ seen, after = [], ""
+ while True:
+ page = (await client.get(f"/v1/blocklist?limit=2&after={after}",
+ headers=node)).json()
+ seen += page["hashes"]
+ if not page["next"]:
+ break
+ after = page["next"]
+ assert [h for h in seen if h in hashes] == hashes
+
+
+@pytest.mark.asyncio
+async def test_an_admin_cannot_block_something_that_is_not_a_hash(client, admin_headers):
+ r = await client.post("/v1/admin/blocklist",
+ json={"content_hash": "x" * 5000, "reason": "test"},
+ headers=admin_headers)
+ assert r.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_a_change_is_pushed_to_nodes_hosting_a_public_group_only(db_session):
+ """A node hosting only private groups has nothing to apply the list to."""
+ import json
+
+ import meshbay_hub.api.revocation as rev
+ from meshbay_hub.db.models import Group, User
+
+ db_session.add(User(id="owner-bl", username="owner_bl", email="x", hub_id="h",
+ pw_hash=b"x", pw_salt=b"x"))
+ db_session.add(Group(id="pub-bl", name="pub", admin_id="owner-bl",
+ visibility="public"))
+ db_session.add(Group(id="priv-bl", name="priv", admin_id="owner-bl",
+ visibility="private"))
+ await db_session.commit()
+
+ class _WS:
+ def __init__(self):
+ self.sent = []
+
+ async def send_text(self, text):
+ self.sent.append(json.loads(text))
+
+ public_node, private_node = _WS(), _WS()
+ saved = dict(rev._connected_nodes), dict(rev._node_groups)
+ rev._connected_nodes.update({"n-pub": public_node, "n-priv": private_node})
+ rev._node_groups.update({"n-pub": ["pub-bl"], "n-priv": ["priv-bl"]})
+ try:
+ sent = await rev.broadcast_blocklist_update(db_session, add=["a" * 64])
+ finally:
+ rev._connected_nodes.clear()
+ rev._connected_nodes.update(saved[0])
+ rev._node_groups.clear()
+ rev._node_groups.update(saved[1])
+ assert sent == 1
+ assert public_node.sent == [{"type": "blocklist_update", "add": ["a" * 64],
+ "remove": []}]
+ assert private_node.sent == []
diff --git a/packages/meshbay-hub/tests/test_portable_save_names.py b/packages/meshbay-hub/tests/test_portable_save_names.py
new file mode 100644
index 0000000..05a4af7
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_portable_save_names.py
@@ -0,0 +1,42 @@
+"""
+Every way a file is saved goes through a name that can be written everywhere
+(docs/MESHBAY_DESIGN.md §10, `static/portable-name.js`).
+
+The rule itself is held to Python's by `test_portable_name_parity.py`. What this
+holds is that the save paths use it: a single file under `portableName`, the
+entries of a folder's zip and the archive's own name under `portablePath` /
+`portableName`, and the node's own name never handed to a save target directly.
+Read from the source, because these paths need a browser and a disk to run.
+"""
+
+import re
+from pathlib import Path
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+FILE_UTILS = (STATIC / "file-utils.js").read_text(encoding="utf-8")
+
+
+def _body(name: str) -> str:
+ m = re.search(rf"^async function {name}\(.*?^\}}", FILE_UTILS, re.M | re.S)
+ assert m, f"file-utils.js no longer has {name}"
+ return m.group(0)
+
+
+def test_a_single_file_is_saved_under_a_portable_name():
+ body = _body("downloadEntry")
+ assert "portableName(entry.name)" in body
+ assert "_openTargetInTurn(saveName" in body
+ assert "_saveBlob(blob, saveName)" in body
+ assert "_openTargetInTurn(entry.name" not in body
+ assert "_saveBlob(blob, entry.name)" not in body
+
+
+def test_a_zip_writes_portable_names_and_is_named_portably():
+ body = _body("downloadDirectory")
+ assert "zip.begin(portablePath(name)" in body
+ assert "portableName(dir.split('/').pop()" in body
+
+
+def test_a_renamed_file_is_said_so_on_its_row():
+ assert "t('transfers.renamed'" in _body("downloadEntry")
+ assert "t('transfers.renamed_n'" in _body("downloadDirectory")
diff --git a/packages/meshbay-hub/tests/test_unauthenticated_surface.py b/packages/meshbay-hub/tests/test_unauthenticated_surface.py
index f2da42b..563dfa8 100644
--- a/packages/meshbay-hub/tests/test_unauthenticated_surface.py
+++ b/packages/meshbay-hub/tests/test_unauthenticated_surface.py
@@ -42,10 +42,6 @@ PUBLIC = {
("POST", "/v1/nodes/auth"): "node sign-in — Ed25519 signature over a fresh timestamp",
("WS", "/v1/nodes/ws"): "a node-scoped JWT in the first message, within a timeout",
("GET", "/v1/groups"): "the public directory — empty when public groups are off",
- ("GET", "/v1/blocklist"): "nodes sync it on their own behalf — hashes only",
- ("GET", "/v1/blocklist/check"): "nodes consult it on their own behalf",
- ("GET", "/v1/relays"): "closed: 503 while relay.RELAYS_ENABLED is False",
- ("POST", "/v1/relays/register"): "closed; when open, approved key + signature",
("GET", "/mhp/info"): "closed: 503 while federation.FEDERATION_ENABLED is False",
("GET", "/mhp/directory"): "closed; when open, an MHP token",
("POST", "/mhp/directory"): "closed; when open, an MHP token",
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"