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/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