diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-28 02:51:00 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-28 02:51:00 +0200 |
| commit | b5b4f188a39fc96c4d32e67151e067b1add6dcfc (patch) | |
| tree | ce0c4ff78044a56c0882d7ba94fbea436f0e83c3 /packages/meshbay-hub/tests/test_public_groups_toggle.py | |
| parent | e1f1b65cfac031096e4bae24ccf102ca0dbb86d9 (diff) | |
| download | meshbay-b5b4f188a39fc96c4d32e67151e067b1add6dcfc.tar.gz | |
feat(hub): let a hub admin disable public groups instance-wide
A new General tab in Administration carries one switch, allow_public_groups,
stored in a hub_settings key/value table (runtime-editable, unlike hub.toml).
Default is on; an absent row means on, so an upgrade changes nothing.
Enforcement is server-side on every hub-mediated path, not just the SPA:
- create_group refuses visibility=public (403), staff included
- list_public_groups the directory returns nothing (local + federated)
- join_group open-joining a public group is refused
- group_online_nodes a non-member of a public group is handed no node
- signaling.webrtc_offer drops the "node hosts an open group" fallback
- federation.export_directory advertises nothing to peer hubs
The switch is read live, so flipping it back restores every path. Existing
members of a group that predates the switch keep their membership row and
their access — this is plan A, not a purge. GET /v1/hub/info exposes the
flag (unauthenticated) so the create-group form and the sidebar's "Public
groups" link render correctly.
Also in the admin Groups tab: a Revoke action beside Suspend. Suspend is the
reversible hub flag; Revoke calls POST /v1/admin/revoke, which sets
status=revoked and broadcasts a signed revocation every node enforces
(denylist + dropped live sessions). It is confirm-guarded and names the group.
And a message fix the revoke work surfaced: group_online_nodes, join_group and
webrtc_offer answered "Group is suspended" for any non-active status. They now
report the real state, so a member of a revoked group is told "Group is
revoked" rather than something reversible-sounding.
Tests: test_public_groups_toggle.py (10) covers the switch end to end and the
five enforcement paths; test_revocation.py gains the status-message assertion.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
Diffstat (limited to 'packages/meshbay-hub/tests/test_public_groups_toggle.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_public_groups_toggle.py | 269 |
1 files changed, 269 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_public_groups_toggle.py b/packages/meshbay-hub/tests/test_public_groups_toggle.py new file mode 100644 index 0000000..0d36b99 --- /dev/null +++ b/packages/meshbay-hub/tests/test_public_groups_toggle.py @@ -0,0 +1,269 @@ +""" +An admin can turn off public groups for the whole hub. + +The control is server-side and covers every hub-mediated path, not just +creation: + + * `create_group` refuses `visibility=public` + * `list_public_groups` (the directory) returns nothing + * `join_group` refuses open joining of a public group + * `group_online_nodes` hands a non-member no node to connect to + * `signaling.webrtc_offer` drops the "node hosts an open group" fallback + * `federation.export_directory` advertises nothing to peers + +Existing members of a group that predates the switch keep their membership row +and their access — plan A, not a purge. +""" + +import base64 +import hashlib +from datetime import datetime, timezone + +import pytest +from meshbay_hub.api.deps import set_admin_usernames +from meshbay_hub.db.models import Group + + +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 _admin(client, username="root"): + await _user(client, username) + set_admin_usernames([username]) + # re-login so the token is minted with the admin role in context + r = await client.post("/v1/users/login", json={ + "username": username, "auth_key": _auth_key("a-long-enough-passphrase", username)}) + return {"Authorization": f"Bearer {r.json()['access_token']}"} + + +def _public(name): + return {"name": name, "visibility": "public", "join_policy": "open"} + + +async def _create_public(client, owner, name): + r = await client.post("/v1/groups", json=_public(name), headers=owner) + assert r.status_code == 201, r.text + return r.json()["group_id"] + + +async def _mark_hosted(db_session, *group_ids): + """Pretend a node announced these groups, as /v1/nodes/ws would.""" + for gid in group_ids: + (await db_session.get(Group, gid)).hosted_at = datetime.now(timezone.utc) + await db_session.commit() + + +async def _set_public_groups(client, admin, allowed): + r = await client.patch("/v1/admin/settings", + json={"allow_public_groups": allowed}, headers=admin) + assert r.status_code == 200 + assert r.json()["allow_public_groups"] is allowed + + +@pytest.mark.asyncio +async def test_public_groups_are_allowed_by_default(client): + owner = await _user(client, "alice") + r = await client.get("/v1/hub/info") + assert r.json()["allow_public_groups"] is True + r = await client.post("/v1/groups", json=_public("open-house"), headers=owner) + assert r.status_code == 201, r.text + + +@pytest.mark.asyncio +async def test_a_normal_member_cannot_change_the_setting(client): + member = await _user(client, "mallory") + r = await client.patch("/v1/admin/settings", + json={"allow_public_groups": False}, headers=member) + assert r.status_code == 403 + + +@pytest.mark.asyncio +async def test_admin_disables_public_groups_end_to_end(client): + admin = await _admin(client) + owner = await _user(client, "bob") + + r = await client.patch("/v1/admin/settings", + json={"allow_public_groups": False}, headers=admin) + assert r.status_code == 200 + assert r.json()["allow_public_groups"] is False + + # Reflected on both the admin read and the unauthenticated hub info. + assert (await client.get("/v1/admin/settings", headers=admin) + ).json()["allow_public_groups"] is False + assert (await client.get("/v1/hub/info")).json()["allow_public_groups"] is False + + # A member is refused a public group... + r = await client.post("/v1/groups", json=_public("nope"), headers=owner) + assert r.status_code == 403 + assert "public" in r.json()["detail"].lower() + + # ...and so is the admin: the way back is to re-enable it, not slip past. + r = await client.post("/v1/groups", json=_public("admin-nope"), headers=admin) + assert r.status_code == 403 + + # Private groups are unaffected. + r = await client.post("/v1/groups", + json={"name": "still-fine", "visibility": "private"}, + headers=owner) + assert r.status_code == 201, r.text + + +@pytest.mark.asyncio +async def test_re_enabling_restores_public_creation(client): + admin = await _admin(client, "chief") + owner = await _user(client, "carol") + + await client.patch("/v1/admin/settings", + json={"allow_public_groups": False}, headers=admin) + r = await client.post("/v1/groups", json=_public("first-try"), headers=owner) + assert r.status_code == 403 + + await client.patch("/v1/admin/settings", + json={"allow_public_groups": True}, headers=admin) + r = await client.post("/v1/groups", json=_public("second-try"), headers=owner) + assert r.status_code == 201, r.text + + +@pytest.mark.asyncio +async def test_a_patch_without_the_field_is_a_no_op(client): + admin = await _admin(client, "keeper") + await client.patch("/v1/admin/settings", + json={"allow_public_groups": False}, headers=admin) + r = await client.patch("/v1/admin/settings", json={}, headers=admin) + assert r.status_code == 200 + assert r.json()["allow_public_groups"] is False + + +# ── plan A: existing public groups when the switch is off ──────────────────── + +@pytest.mark.asyncio +async def test_disabled_empties_the_public_directory(client, db_session): + admin = await _admin(client) + owner = await _user(client, "dora") + gid = await _create_public(client, owner, "town-square") + await _mark_hosted(db_session, gid) + + listed = await client.get("/v1/groups") + assert any(g["id"] == gid for g in listed.json()["groups"]) + + await _set_public_groups(client, admin, False) + + listed = await client.get("/v1/groups") + body = listed.json() + assert body["groups"] == [] and body["total"] == 0 + + +@pytest.mark.asyncio +async def test_disabled_refuses_open_join_of_a_public_group(client, db_session): + admin = await _admin(client, "chief") + owner = await _user(client, "erin") + early = await _user(client, "early-bird") + late = await _user(client, "late-comer") + gid = await _create_public(client, owner, "commons") + await _mark_hosted(db_session, gid) + + assert (await client.post(f"/v1/groups/{gid}/join", headers=early)).status_code == 200 + + await _set_public_groups(client, admin, False) + + r = await client.post(f"/v1/groups/{gid}/join", headers=late) + assert r.status_code == 403 + + # The person who joined while it was allowed is still a member. + mine = await client.get("/v1/groups/mine", headers=early) + assert any(g["id"] == gid for g in mine.json()["groups"]) + + +@pytest.mark.asyncio +async def test_disabled_hands_a_non_member_no_node(client, db_session): + admin = await _admin(client, "chief") + owner = await _user(client, "frank") + member = await _user(client, "grace") + stranger = await _user(client, "heidi") + gid = await _create_public(client, owner, "atrium") + await _mark_hosted(db_session, gid) + assert (await client.post(f"/v1/groups/{gid}/join", headers=member)).status_code == 200 + + # While allowed, anyone may ask which nodes serve a public group. + assert (await client.get(f"/v1/groups/{gid}/nodes", headers=stranger) + ).status_code == 200 + + await _set_public_groups(client, admin, False) + + assert (await client.get(f"/v1/groups/{gid}/nodes", headers=stranger) + ).status_code == 403 + # Members and the owner still get an answer (no nodes online here, but 200). + assert (await client.get(f"/v1/groups/{gid}/nodes", headers=member) + ).status_code == 200 + assert (await client.get(f"/v1/groups/{gid}/nodes", headers=owner) + ).status_code == 200 + + +def _mhp_token(hub_id): + """A peer-hub JWT, signed with the running hub's own key. + + `federation._issue_mhp_token` binds `_hub_sk_pem` at import time, before the + lifespan loads it, so it cannot be used from a test. This signs directly. + """ + import time + import uuid + + import jwt + from meshbay_hub import auth as hub_auth + + now = int(time.time()) + # No `aud`: export_directory verifies without an expected audience, and PyJWT + # rejects a token that carries `aud` when decode() is given none. + return jwt.encode( + {"iss": hub_id, "sub": hub_id, + "jti": str(uuid.uuid4()), "iat": now, "exp": now + 300}, + hub_auth._hub_sk_pem, algorithm="EdDSA") + + +@pytest.mark.asyncio +async def test_disabled_empties_the_federation_export(client): + admin = await _admin(client) + owner = await _user(client, "ivan") + await _create_public(client, owner, "exported-square") + + info = (await client.get("/mhp/info")).json() + await client.post("/mhp/peers", headers=admin, json={ + "hub_id": info["hub_id"], "hub_url": "https://peer.example", + "pk_hub_pem": info["pk_hub_pem"], + }) + tok = _mhp_token(info["hub_id"]) + + r = await client.get("/mhp/directory", headers={"Authorization": f"Bearer {tok}"}) + assert r.status_code == 200 and len(r.json()["groups"]) == 1 + + await _set_public_groups(client, admin, False) + + r = await client.get("/mhp/directory", headers={"Authorization": f"Bearer {tok}"}) + assert r.status_code == 200 and r.json()["groups"] == [] + + +@pytest.mark.asyncio +async def test_re_enabling_brings_the_directory_back(client, db_session): + admin = await _admin(client, "chief") + owner = await _user(client, "judy") + gid = await _create_public(client, owner, "reopened") + await _mark_hosted(db_session, gid) + + await _set_public_groups(client, admin, False) + assert (await client.get("/v1/groups")).json()["groups"] == [] + + await _set_public_groups(client, admin, True) + listed = await client.get("/v1/groups") + assert any(g["id"] == gid for g in listed.json()["groups"]) |