diff options
Diffstat (limited to 'packages/meshbay-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/test_downloads.py | 56 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_group_membership.py | 114 |
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 |