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_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"]