aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-15 17:23:10 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-15 17:23:10 +0200
commitdd3927a661273734493f65a593755b95aecf5f09 (patch)
treeb40b9a0939e79c65990f4664211025b032c9bafa /packages/meshbay-hub/tests
parent4066c754a613deb965472853fe69727d68be593e (diff)
downloadmeshbay-dd3927a661273734493f65a593755b95aecf5f09.tar.gz
feat(groups): remove a member, and keep gigabytes out of the tab
**Removing a member.** The owner can do it from the Members tab, and it is two halves in the order that fails safe: the node stops serving the group key first (an operator-signed request, so a paired browser only), then the hub drops the membership row. The other order would leave someone able to reach a node that still serves them. It is a membership, not an account. The user row is never written: their other groups, their files and their pinned identity survive, because one group's owner must not be able to erase someone from the hub. It is also per group — a node hosting two loses them from one — and it does not take back the key they already unwrapped, which is what rotating the GEK is for. The confirmation and the panel both say so. **Downloads and streaming through the disk, in both browsers.** The audit this started as found two ways to put gigabytes in a tab. Firefox and Safari have no File System Access API, so every download there was collected in memory. A service worker fixes it: the page keeps the writable half of a transferred stream, the worker answers a made-up URL with the readable half and a Content-Disposition header, and the browser writes it to disk as it arrives, with real backpressure. The worker caches nothing and falls through on every request that is not one of these downloads. A zip announces no Content-Length, since the archive is larger than the files in it and a length we miss truncates the file. Video was worse and affected both browsers. The node pushed ffmpeg's whole output as fast as it was produced while the player consumed a segment at a time, so the queue held the film — and appending all of it hit the SourceBuffer's cap, where the handler logged the error and dropped the segment, leaving a hole in the middle of the film with nothing to show for it. Streaming is credit-based now, 24 segments of 256 KB in flight, verified against the live node: three credits, three segments, then silence until more are granted. The player evicts what is more than a minute behind the playhead and retries a refused segment rather than dropping it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/test_downloads.py56
-rw-r--r--packages/meshbay-hub/tests/test_group_membership.py114
2 files changed, 170 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py
index cd0cded..41d61bf 100644
--- a/packages/meshbay-hub/tests/test_downloads.py
+++ b/packages/meshbay-hub/tests/test_downloads.py
@@ -108,3 +108,59 @@ def test_the_open_action_reads_the_file_back(tmp_path):
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"
+
+
+# ── Streaming to disk without the File System Access API ────────────────────
+
+SW = STATIC / "sw.js"
+
+
+def test_the_worker_only_answers_its_own_urls():
+ """
+ It is registered at the root scope, so it sees every request the page makes.
+ Anything that is not a download of ours has to fall through untouched — a
+ service worker that answers more than it should is a cache bug waiting to
+ happen.
+ """
+ src = SW.read_text()
+ 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.
+ for api in ("caches.open", "caches.match", "cache.put"):
+ assert api not in src, f"this worker must not cache anything ({api})"
+
+
+def test_the_download_is_announced_as_an_attachment():
+ src = SW.read_text()
+ 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
+
+
+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()
+ assert "if (entry.size > 0)" in src
+
+ app = (STATIC / "app.js").read_text()
+ zip_call = app[app.index("const target = await _openDownloadTarget(suggested"):]
+ zip_call = zip_call[:zip_call.index(");") + 2]
+ assert zip_call.rstrip().endswith(", 0);"), (
+ "the zip download announces a Content-Length it will not match")
+
+
+def test_backpressure_is_real(tmp_path):
+ """
+ The point of the service worker path is not holding the file. A stream that
+ 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()
+ fn = src[src.index("export async function openStreamedDownload"):]
+ assert "new TransformStream()" in fn
+ assert "[readable]" in fn, "the readable half must be transferred, not copied"
+ assert "writer.write(bytes)" in fn
+ assert "return null" in fn, "a browser that cannot transfer streams must say so"
diff --git a/packages/meshbay-hub/tests/test_group_membership.py b/packages/meshbay-hub/tests/test_group_membership.py
new file mode 100644
index 0000000..7eeaa2f
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_group_membership.py
@@ -0,0 +1,114 @@
+"""
+Removing someone from a group.
+
+The distinction these pin is the one that matters: removing a member from a
+group is not deleting their account. It takes away one membership row, and
+leaves the person, their other groups and everything they have uploaded exactly
+where they were.
+"""
+
+import base64
+import hashlib
+
+import pytest
+from sqlalchemy import select
+
+from meshbay_hub.db.models import Group, GroupMember, User
+
+
+def _auth_key(password: str, username: str) -> str:
+ salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest()
+ return base64.b64encode(
+ hashlib.pbkdf2_hmac("sha512", password.encode(), salt, 600_000, 32)).decode()
+
+
+async def _user(client, username, password="a-long-enough-passphrase"):
+ await client.post("/v1/users/register", json={
+ "username": username, "email": f"{username}@example.com",
+ "auth_key": _auth_key(password, username)})
+ r = await client.post("/v1/users/login", json={
+ "username": username, "auth_key": _auth_key(password, username)})
+ return {"Authorization": f"Bearer {r.json()['access_token']}"}
+
+
+async def _group_with_member(client, owner, member_name, name="crew"):
+ g = await client.post("/v1/groups", json={"name": name}, headers=owner)
+ gid = g.json()["group_id"]
+ await client.post(f"/v1/groups/{gid}/members/{member_name}", json={},
+ headers=owner)
+ return gid
+
+
+@pytest.mark.asyncio
+async def test_the_owner_removes_a_member(client, db_session):
+ owner = await _user(client, "chief")
+ await _user(client, "hanger_on")
+ gid = await _group_with_member(client, owner, "hanger_on")
+
+ r = await client.delete(f"/v1/groups/{gid}/members/hanger_on", headers=owner)
+ assert r.status_code == 200, r.text
+
+ rows = (await db_session.execute(
+ select(GroupMember).where(GroupMember.group_id == gid))).scalars().all()
+ assert [m.user_id for m in rows] != [], "the owner lost their own membership"
+ names = {(await db_session.get(User, m.user_id)).username for m in rows}
+ assert names == {"chief"}
+
+
+@pytest.mark.asyncio
+async def test_removing_a_member_is_not_deleting_an_account(client, db_session):
+ """
+ The account survives untouched, with its other groups. Anything else would
+ make one group's owner able to erase someone from the whole hub.
+ """
+ owner = await _user(client, "boss")
+ member = await _user(client, "member_x")
+ elsewhere = await _user(client, "other_owner")
+
+ gid = await _group_with_member(client, owner, "member_x")
+ other = await _group_with_member(client, elsewhere, "member_x", name="elsewhere")
+
+ await client.delete(f"/v1/groups/{gid}/members/member_x", headers=owner)
+
+ user = (await db_session.execute(
+ select(User).where(User.username == "member_x"))).scalar_one()
+ assert user.status == "active", "the account was touched"
+
+ me = await client.get("/v1/users/me", headers=member)
+ assert me.status_code == 200, "they can no longer sign in"
+
+ still = await db_session.get(GroupMember, (other, user.id))
+ assert still is not None, "removing them from one group emptied another"
+
+
+@pytest.mark.asyncio
+async def test_a_member_cannot_remove_anyone(client):
+ owner = await _user(client, "owner_y")
+ member = await _user(client, "member_y")
+ await _user(client, "victim_y")
+ gid = await _group_with_member(client, owner, "member_y")
+ await client.post(f"/v1/groups/{gid}/members/victim_y", json={}, headers=owner)
+
+ r = await client.delete(f"/v1/groups/{gid}/members/victim_y", headers=member)
+ assert r.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_the_owner_cannot_be_removed_from_their_own_group(client):
+ """Otherwise the group is left with nobody who can invite or remove."""
+ owner = await _user(client, "owner_z")
+ gid = await _group_with_member(client, owner, "owner_z")
+
+ r = await client.delete(f"/v1/groups/{gid}/members/owner_z", headers=owner)
+ assert r.status_code == 409
+
+
+@pytest.mark.asyncio
+async def test_removing_someone_who_is_not_a_member_says_so(client):
+ owner = await _user(client, "owner_w")
+ await _user(client, "stranger")
+ g = await client.post("/v1/groups", json={"name": "closed"}, headers=owner)
+ gid = g.json()["group_id"]
+
+ r = await client.delete(f"/v1/groups/{gid}/members/stranger", headers=owner)
+ assert r.status_code == 404