diff options
Diffstat (limited to 'packages/meshbay-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/test_group_hosting.py | 271 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_group_leave_and_quota.py | 258 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_groups_self_service.py | 24 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_hub_api.py | 10 |
4 files changed, 558 insertions, 5 deletions
diff --git a/packages/meshbay-hub/tests/test_group_hosting.py b/packages/meshbay-hub/tests/test_group_hosting.py new file mode 100644 index 0000000..ad5f57d --- /dev/null +++ b/packages/meshbay-hub/tests/test_group_hosting.py @@ -0,0 +1,271 @@ +""" +Groups nobody hosts, and the admission policy a public group is allowed. + +A group created before its node exists has no files, no key and nothing to +connect to. Showing it in the directory produces a dead end for whoever clicks +it, so it stays with its owner until a node announces it — and if none ever +does, `prune-groups` collects it. + +The distinction that matters is *ever hosted* against *online now*. `hosted_at` +is stamped once and never cleared, so a node being offline today cannot make a +live group look abandoned. Checking the live socket registry instead would have +deleted every group during a hub restart. +""" + +import base64 +import hashlib +from datetime import datetime, timedelta, timezone + +import pytest +from sqlalchemy import select + +from meshbay_hub.db.models import Group, GroupMember +from meshbay_hub.tasks.cleanup import find_unhosted_groups, prune_unhosted_groups + + +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(client, owner, name, visibility="private", join_policy=None): + body = {"name": name, "visibility": visibility} + if join_policy is not None: + body["join_policy"] = join_policy + elif visibility == "public": + body["join_policy"] = "open" + return await client.post("/v1/groups", json=body, headers=owner) + + +async def _mark_hosted(db_session, group_id, when=None): + g = await db_session.get(Group, group_id) + g.hosted_at = when or datetime.now(timezone.utc) + await db_session.commit() + + +# ── Visibility before a node exists ─────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_the_owner_sees_their_unhosted_group(client): + """They have to: it is the page they set the node up from.""" + owner = await _user(client, "setup1") + r = await _group(client, owner, "not-yet") + assert r.status_code == 201 + + mine = await client.get("/v1/groups/mine", headers=owner) + entry = next(g for g in mine.json()["groups"] if g["name"] == "not-yet") + assert entry["hosted"] is False + + +@pytest.mark.asyncio +async def test_a_member_does_not_see_an_unhosted_group(client): + """A name they cannot open, with no way to say why, is worse than nothing.""" + owner = await _user(client, "setup2") + member = await _user(client, "early_bird") + gid = (await _group(client, owner, "premature")).json()["group_id"] + await client.post(f"/v1/groups/{gid}/members/early_bird", json={}, headers=owner) + + mine = await client.get("/v1/groups/mine", headers=member) + assert [g["name"] for g in mine.json()["groups"]] == [] + + +@pytest.mark.asyncio +async def test_a_member_sees_it_once_a_node_has_announced_it(client, db_session): + owner = await _user(client, "setup3") + member = await _user(client, "patient") + gid = (await _group(client, owner, "ready")).json()["group_id"] + await client.post(f"/v1/groups/{gid}/members/patient", json={}, headers=owner) + + await _mark_hosted(db_session, gid) + + mine = await client.get("/v1/groups/mine", headers=member) + entry = next(g for g in mine.json()["groups"] if g["id"] == gid) + assert entry["hosted"] is True + + +@pytest.mark.asyncio +async def test_the_public_directory_hides_unhosted_groups(client, db_session): + owner = await _user(client, "setup4") + hidden = (await _group(client, owner, "pub-unhosted", "public")).json()["group_id"] + shown = (await _group(client, owner, "pub-hosted", "public")).json()["group_id"] + await _mark_hosted(db_session, shown) + + listed = (await client.get("/v1/groups")).json()["groups"] + ids = [g["id"] for g in listed] + assert shown in ids + assert hidden not in ids + + +@pytest.mark.asyncio +async def test_a_group_stays_visible_when_its_node_goes_offline(client, db_session): + """`hosted_at` records that a node existed, not that one is answering now.""" + owner = await _user(client, "setup5") + gid = (await _group(client, owner, "quiet-node", "public")).json()["group_id"] + await _mark_hosted(db_session, gid) + + listed = (await client.get("/v1/groups")).json()["groups"] + assert gid in [g["id"] for g in listed], "no node is online, and that is not the question" + + +# ── Collecting the abandoned ones ───────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_a_fresh_unhosted_group_is_left_alone(client, db_session): + owner = await _user(client, "reaper1") + gid = (await _group(client, owner, "brand-new")).json()["group_id"] + + assert await find_unhosted_groups(db_session) == [] + assert await db_session.get(Group, gid) is not None + + +@pytest.mark.asyncio +async def test_an_unhosted_group_past_the_grace_period_is_collected(client, db_session): + owner = await _user(client, "reaper2") + gid = (await _group(client, owner, "abandoned")).json()["group_id"] + + g = await db_session.get(Group, gid) + g.created_at = datetime.now(timezone.utc) - timedelta(days=8) + await db_session.commit() + + gone = await prune_unhosted_groups(db_session) + assert [name for _, name in gone] == ["abandoned"] + assert await db_session.get(Group, gid) is None + + +@pytest.mark.asyncio +async def test_an_old_group_that_was_hosted_is_never_collected(client, db_session): + """The whole point of the column: age alone must not condemn a group.""" + owner = await _user(client, "reaper3") + gid = (await _group(client, owner, "long-lived")).json()["group_id"] + + g = await db_session.get(Group, gid) + g.created_at = datetime.now(timezone.utc) - timedelta(days=400) + g.hosted_at = datetime.now(timezone.utc) - timedelta(days=399) + await db_session.commit() + + assert await find_unhosted_groups(db_session) == [] + + +@pytest.mark.asyncio +async def test_dry_run_reports_without_deleting(client, db_session): + owner = await _user(client, "reaper4") + gid = (await _group(client, owner, "still-here")).json()["group_id"] + g = await db_session.get(Group, gid) + g.created_at = datetime.now(timezone.utc) - timedelta(days=30) + await db_session.commit() + + gone = await prune_unhosted_groups(db_session, dry_run=True) + assert [name for _, name in gone] == ["still-here"] + assert await db_session.get(Group, gid) is not None, "dry run deleted a group" + + +@pytest.mark.asyncio +async def test_collecting_a_group_takes_its_memberships_with_it(client, db_session): + """Nothing cascades in the schema, and an orphan row keeps the group in /mine.""" + owner = await _user(client, "reaper5") + await _user(client, "tagalong") + gid = (await _group(client, owner, "doomed")).json()["group_id"] + await client.post(f"/v1/groups/{gid}/members/tagalong", json={}, headers=owner) + + g = await db_session.get(Group, gid) + g.created_at = datetime.now(timezone.utc) - timedelta(days=9) + await db_session.commit() + + await prune_unhosted_groups(db_session) + + rows = (await db_session.execute( + select(GroupMember).where(GroupMember.group_id == gid))).scalars().all() + assert rows == [] + + +# ── What a public group may be ──────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_a_public_group_cannot_be_invite_only(client): + """It would be listed to everyone and admit nobody. + + Admission by request was the alternative and was dropped: between strangers + the only channel is the hub, so a one-time code would travel through the + party it exists to exclude. + """ + owner = await _user(client, "policy1") + r = await _group(client, owner, "contradiction", "public", join_policy="invite") + assert r.status_code == 422, r.text + assert "private" in r.json()["detail"] + + +@pytest.mark.asyncio +async def test_a_public_group_is_open(client): + owner = await _user(client, "policy2") + r = await _group(client, owner, "welcoming", "public", join_policy="open") + assert r.status_code == 201, r.text + + +@pytest.mark.asyncio +async def test_a_private_group_is_invite_only_by_default(client, db_session): + owner = await _user(client, "policy3") + gid = (await _group(client, owner, "closed")).json()["group_id"] + assert (await db_session.get(Group, gid)).join_policy == "invite" + + +# ── The stamp itself ────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_node_registration_stamps_hosted_at(client, db_session): + """The tests above set `hosted_at` directly; this one exercises the writer. + + `_mark_hosted` is called from the node WebSocket handler with the group list + the hub derived from its own tables — a node can narrow that set but never + widen it (finding C2), so being announced here is evidence, not a claim. + """ + from meshbay_hub.api.revocation import _mark_hosted + + owner = await _user(client, "stamped") + gid = (await _group(client, owner, "about-to-be-hosted")).json()["group_id"] + assert (await db_session.get(Group, gid)).hosted_at is None + + await _mark_hosted([gid]) + + await db_session.refresh(await db_session.get(Group, gid)) + assert (await db_session.get(Group, gid)).hosted_at is not None + + +@pytest.mark.asyncio +async def test_the_stamp_is_not_moved_by_a_later_reconnection(client, db_session): + """It records that a node once existed, not when one last showed up.""" + from meshbay_hub.api.revocation import _mark_hosted + + owner = await _user(client, "stamped2") + gid = (await _group(client, owner, "steady")).json()["group_id"] + first = datetime.now(timezone.utc) - timedelta(days=30) + await _mark_hosted([gid]) + g = await db_session.get(Group, gid) + g.hosted_at = first + await db_session.commit() + + await _mark_hosted([gid]) + + g = await db_session.get(Group, gid) + await db_session.refresh(g) + # SQLite hands back a naive datetime where PostgreSQL keeps the offset, so + # the comparison is made on common ground rather than on the driver. + stored = g.hosted_at.replace(tzinfo=timezone.utc) if g.hosted_at.tzinfo is None \ + else g.hosted_at + assert abs((stored - first).total_seconds()) < 1, "the stamp moved" + + +@pytest.mark.asyncio +async def test_marking_hosted_survives_an_empty_list(client): + """A node that hosts nothing must not take the socket down.""" + from meshbay_hub.api.revocation import _mark_hosted + await _mark_hosted([]) diff --git a/packages/meshbay-hub/tests/test_group_leave_and_quota.py b/packages/meshbay-hub/tests/test_group_leave_and_quota.py new file mode 100644 index 0000000..dfa18b8 --- /dev/null +++ b/packages/meshbay-hub/tests/test_group_leave_and_quota.py @@ -0,0 +1,258 @@ +""" +Leaving a group of your own accord, and the cap on public groups. + +Sits beside `test_group_membership.py`, which covers the owner removing someone +else. The two are deliberately different endpoints rather than one with an +exception for the self case, so they are tested apart. + +The refusals are the interesting half: the owner who cannot walk out and leave +the group unmanageable, and the eleventh public group. What leaving does *not* +do is asserted too — the hub can only drop a membership row. The node keeps the +identity it pinned, the keypair bundle and the uploaded files until its operator +removes them, and whoever left still holds the group key they were served. +""" + +import base64 +import hashlib +from datetime import datetime, timezone + +import pytest +from sqlalchemy import select + +from meshbay_hub.api.groups import MAX_PUBLIC_GROUPS +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(client, owner, name, visibility="private"): + # A public group must be open to join — invite-only is refused, because a + # group everyone can find and nobody can enter is a dead end. + body = {"name": name, "visibility": visibility} + if visibility == "public": + body["join_policy"] = "open" + r = await client.post("/v1/groups", json=body, headers=owner) + assert r.status_code == 201, r.text + return r.json()["group_id"] + + +# ── Leaving ─────────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_a_member_can_leave(client, db_session): + owner = await _user(client, "owner1") + member = await _user(client, "member1") + gid = await _group(client, owner, "readers") + await client.post(f"/v1/groups/{gid}/members/member1", json={}, headers=owner) + + # Marked hosted, or the member would not see the group in the first place + # and the assertion below would hold whether or not leaving worked. + g = await db_session.get(Group, gid) + g.hosted_at = datetime.now(timezone.utc) + await db_session.commit() + + before = await client.get("/v1/groups/mine", headers=member) + assert [g["id"] for g in before.json()["groups"]] == [gid], "precondition" + + r = await client.post(f"/v1/groups/{gid}/leave", headers=member) + assert r.status_code == 200, r.text + assert r.json()["status"] == "left" + + mine = await client.get("/v1/groups/mine", headers=member) + assert [g["id"] for g in mine.json()["groups"]] == [] + + +@pytest.mark.asyncio +async def test_leaving_removes_only_that_membership_row(client, db_session): + """The group and everyone else in it are untouched — this is not a deletion.""" + owner = await _user(client, "owner2") + member = await _user(client, "member2") + gid = await _group(client, owner, "still-here") + await client.post(f"/v1/groups/{gid}/members/member2", json={}, headers=owner) + + await client.post(f"/v1/groups/{gid}/leave", headers=member) + + assert await db_session.get(Group, gid) is not None + rows = (await db_session.execute( + select(GroupMember).where(GroupMember.group_id == gid))).scalars().all() + assert len(rows) == 1, "the owner should still be a member" + + mine = await client.get("/v1/groups/mine", headers=owner) + assert [g["id"] for g in mine.json()["groups"]] == [gid] + + +@pytest.mark.asyncio +async def test_leaving_does_not_touch_the_account_or_its_other_groups(client, db_session): + owner = await _user(client, "owner3") + member = await _user(client, "member3") + elsewhere = await _user(client, "owner3b") + gid = await _group(client, owner, "leaving") + other = await _group(client, elsewhere, "staying") + await client.post(f"/v1/groups/{gid}/members/member3", json={}, headers=owner) + await client.post(f"/v1/groups/{other}/members/member3", json={}, headers=elsewhere) + + await client.post(f"/v1/groups/{gid}/leave", headers=member) + + user = (await db_session.execute( + select(User).where(User.username == "member3"))).scalar_one() + assert user.status == "active" + assert await db_session.get(GroupMember, (other, user.id)) is not None + + +@pytest.mark.asyncio +async def test_the_owner_cannot_leave_their_own_group(client): + """It would leave the group with nobody able to admit, edit or delete it.""" + owner = await _user(client, "owner4") + gid = await _group(client, owner, "orphan-risk") + + r = await client.post(f"/v1/groups/{gid}/leave", headers=owner) + assert r.status_code == 409, r.text + assert "own this group" in r.json()["detail"] + + mine = await client.get("/v1/groups/mine", headers=owner) + assert [g["id"] for g in mine.json()["groups"]] == [gid] + + +@pytest.mark.asyncio +async def test_leaving_twice_is_refused(client): + owner = await _user(client, "owner5") + member = await _user(client, "member5") + gid = await _group(client, owner, "once") + await client.post(f"/v1/groups/{gid}/members/member5", json={}, headers=owner) + + assert (await client.post(f"/v1/groups/{gid}/leave", + headers=member)).status_code == 200 + assert (await client.post(f"/v1/groups/{gid}/leave", + headers=member)).status_code == 404 + + +@pytest.mark.asyncio +async def test_leaving_a_group_you_were_never_in_is_refused(client): + owner = await _user(client, "owner6") + stranger = await _user(client, "stranger6") + gid = await _group(client, owner, "not-yours") + + r = await client.post(f"/v1/groups/{gid}/leave", headers=stranger) + assert r.status_code == 404 + + +# ── Public group cap ────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_public_groups_are_capped(client): + owner = await _user(client, "prolific") + for i in range(MAX_PUBLIC_GROUPS): + await _group(client, owner, f"public-{i}", visibility="public") + + r = await client.post("/v1/groups", + json={"name": "one-too-many", "visibility": "public", + "join_policy": "open"}, + headers=owner) + assert r.status_code == 409, r.text + detail = r.json()["detail"] + assert str(MAX_PUBLIC_GROUPS) in detail + assert "private" in detail, "the message should name the way out" + + +@pytest.mark.asyncio +async def test_private_groups_are_not_capped(client): + """Private groups cost other people nothing — they are invisible to non-members.""" + owner = await _user(client, "hoarder") + for i in range(MAX_PUBLIC_GROUPS + 5): + await _group(client, owner, f"private-{i}") + + mine = await client.get("/v1/groups/mine", headers=owner) + assert len(mine.json()["groups"]) == MAX_PUBLIC_GROUPS + 5 + + +@pytest.mark.asyncio +async def test_a_suspended_public_group_does_not_hold_a_slot(client, db_session): + """Moderation already dealt with the owner; the slot should not punish twice.""" + owner = await _user(client, "moderated") + ids = [await _group(client, owner, f"pub-{i}", visibility="public") + for i in range(MAX_PUBLIC_GROUPS)] + + suspended = await db_session.get(Group, ids[0]) + suspended.status = "suspended" + await db_session.commit() + + r = await client.post("/v1/groups", + json={"name": "replacement", "visibility": "public", + "join_policy": "open"}, + headers=owner) + assert r.status_code == 201, r.text + + +@pytest.mark.asyncio +async def test_the_cap_is_per_owner(client): + """Being a member of someone else's public groups costs nothing.""" + a = await _user(client, "ownera") + await _user(client, "ownerb") + b = await _user(client, "ownerb2") + for i in range(MAX_PUBLIC_GROUPS): + gid = await _group(client, a, f"a-pub-{i}", visibility="public") + await client.post(f"/v1/groups/{gid}/members/ownerb2", json={}, headers=a) + + r = await client.post("/v1/groups", + json={"name": "b-first", "visibility": "public", + "join_policy": "open"}, + headers=b) + assert r.status_code == 201, r.text + + +@pytest.mark.asyncio +async def test_hub_staff_are_exempt(client, db_session): + """The cap is anti-spam, not a rule about running an instance.""" + owner = await _user(client, "instanceadmin") + user = (await db_session.execute( + select(User).where(User.username == "instanceadmin"))).scalar_one() + user.role = "admin" + await db_session.commit() + + for i in range(MAX_PUBLIC_GROUPS + 2): + r = await client.post("/v1/groups", + json={"name": f"admin-pub-{i}", "visibility": "public", + "join_policy": "open"}, + headers=owner) + assert r.status_code == 201, r.text + + +# ── Presence in the group list ──────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_the_group_list_reports_node_presence(client): + """`node_online` rides on the request the sidebar already makes. + + It reflects the hub's signaling registry, so it says a node is connected + *to the hub* — not that this browser can reach it, and not something a + dishonest hub could not fake. The client downgrades it on a connection it + tried and failed, which is the evidence that concerns the reader. + """ + owner = await _user(client, "watcher") + gid = await _group(client, owner, "quiet") + + mine = await client.get("/v1/groups/mine", headers=owner) + entry = next(g for g in mine.json()["groups"] if g["id"] == gid) + assert entry["node_online"] is False + + from meshbay_hub.api import revocation + revocation._node_groups["node-x"] = [gid] + try: + mine = await client.get("/v1/groups/mine", headers=owner) + entry = next(g for g in mine.json()["groups"] if g["id"] == gid) + assert entry["node_online"] is True + finally: + revocation._node_groups.pop("node-x", None) diff --git a/packages/meshbay-hub/tests/test_groups_self_service.py b/packages/meshbay-hub/tests/test_groups_self_service.py index a60209d..3202658 100644 --- a/packages/meshbay-hub/tests/test_groups_self_service.py +++ b/packages/meshbay-hub/tests/test_groups_self_service.py @@ -40,6 +40,16 @@ async def _create_group(client, token, name="test-group", visibility="public", return r.json()["group_id"] + +async def _mark_hosted(db_session, *group_ids): + """Pretend a node announced these groups, as /v1/nodes/ws would.""" + from datetime import datetime, timezone + from meshbay_hub.db.models import Group + for gid in group_ids: + (await db_session.get(Group, gid)).hosted_at = datetime.now(timezone.utc) + await db_session.commit() + + @pytest.mark.asyncio async def test_create_group(client): await _register(client, "alice", email="a@x.com") @@ -73,8 +83,11 @@ async def test_join_open_group(client): async def test_join_invite_group_rejected(client): await _register(client, "alice", email="a@x.com") alice_token = await _login(client, "alice") + # Private: invite-only is refused on a public group now, since a group + # everyone can find and nobody can enter is a dead end. What is under test + # here — /join refusing a group that is not open — is unchanged. gid = await _create_group(client, alice_token, "invite-group", - join_policy="invite") + visibility="private", join_policy="invite") await _register(client, "bob", email="b@x.com") bob_token = await _login(client, "bob") @@ -132,11 +145,14 @@ async def test_group_members_non_member_denied(client): @pytest.mark.asyncio -async def test_group_search(client): +async def test_group_search(client, db_session): await _register(client, "alice", email="a@x.com") token = await _login(client, "alice") - await _create_group(client, token, "alpha-team") - await _create_group(client, token, "beta-team") + a = await _create_group(client, token, "alpha-team") + b = await _create_group(client, token, "beta-team") + # The directory shows groups a node has announced. Marked here so this test + # exercises the search filter rather than the hosting one. + await _mark_hosted(db_session, a, b) r = await client.get("/v1/groups?q=alpha") assert r.status_code == 200 diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py index 5a2cf86..5314b93 100644 --- a/packages/meshbay-hub/tests/test_hub_api.py +++ b/packages/meshbay-hub/tests/test_hub_api.py @@ -365,7 +365,7 @@ async def test_jwt_contains_groups_claim(client): # ── My groups (9.6) ───────────────────────────────────────────────────────── @pytest.mark.asyncio -async def test_my_groups(client): +async def test_my_groups(client, db_session): """GET /v1/groups/mine returns groups the user belongs to.""" pk_ed_a, pk_x_a, _ = _gen_user_keys() pk_ed_b, pk_x_b, _ = _gen_user_keys() @@ -396,6 +396,14 @@ async def test_my_groups(client): json={}, headers={"Authorization": f"Bearer {alice_token}"}) + # A group no node has announced is shown to its owner only — a member would + # otherwise see a name they cannot open. Stamped here so the rest of this + # test is about membership, which is what it was written for. + from datetime import datetime, timezone + from meshbay_hub.db.models import Group + (await db_session.get(Group, group_id)).hosted_at = datetime.now(timezone.utc) + await db_session.commit() + # Re-login to get fresh token with group claims bob_token = (await client.post("/v1/users/login", json={"username": "mg_bob", "password": "bobpass99"})).json()["access_token"] |