summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-16 15:28:28 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-16 15:28:28 +0200
commit0bd3f805ffbd04b40b5150474336a5e5d200e72b (patch)
tree2a92a21e02bae823e9ed52d489d74ba8db3de9b6 /packages/meshbay-hub/tests
parent0231d240b92a2a11042fa62c0222d4c4b96a859d (diff)
downloadmeshbay-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')
-rw-r--r--packages/meshbay-hub/tests/test_group_hosting.py271
-rw-r--r--packages/meshbay-hub/tests/test_group_leave_and_quota.py258
-rw-r--r--packages/meshbay-hub/tests/test_groups_self_service.py24
-rw-r--r--packages/meshbay-hub/tests/test_hub_api.py10
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"]