diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-16 15:28:28 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-16 15:28:28 +0200 |
| commit | 0bd3f805ffbd04b40b5150474336a5e5d200e72b (patch) | |
| tree | 2a92a21e02bae823e9ed52d489d74ba8db3de9b6 /packages/meshbay-hub/tests/test_group_hosting.py | |
| parent | 0231d240b92a2a11042fa62c0222d4c4b96a859d (diff) | |
| download | meshbay-0bd3f805ffbd04b40b5150474336a5e5d200e72b.tar.gz | |
feat(hub): leaving a group, a cap on public ones, and hosting as a precondition
Leaving is its own endpoint rather than a relaxation of the owner's removal
check — an authorization rule with an exception in it is the one that gets read
wrong later. The owner cannot leave: the group would be left with nobody able
to admit, edit or delete it, which is the answer removal and account deletion
already give.
Public groups are capped at ten live ones per owner. They are the ones that
cost other people something — listed in Discover, joinable by anyone — so a
script that opens hundreds fills the directory for everybody. Private groups
are invisible to non-members and are not capped. Hub staff are exempt; the cap
is anti-spam, not a rule about running an instance. Creation is the only place
it can be checked, and deliberately so, because PATCH refuses to change
visibility at all.
A group is now listed only once a node has announced that it hosts it. Before
that it has no files, no key and nothing to connect to, so showing it to a
member produces a name they cannot open and cannot be told why; its owner still
sees it while they set the node up. `meshbay-hub prune-groups` collects the
ones that never got a node, meant for cron, with --dry-run. The migration
backfills hosted_at from created_at: without that the first run would have
deleted every live group.
Presence rides on the group list itself, read from the signaling registry the
hub already keeps — no poll, no timer. It says a node is connected *to the hub*,
which is not a promise 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.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/test_group_hosting.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_group_hosting.py | 271 |
1 files changed, 271 insertions, 0 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([]) |