aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_revoke_is_one_path.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/test_revoke_is_one_path.py')
-rw-r--r--packages/meshbay-hub/tests/test_revoke_is_one_path.py164
1 files changed, 164 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_revoke_is_one_path.py b/packages/meshbay-hub/tests/test_revoke_is_one_path.py
new file mode 100644
index 0000000..2acb46c
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_revoke_is_one_path.py
@@ -0,0 +1,164 @@
+"""Revoke is one door, it is admin-only, and it broadcasts.
+
+Two coupled gaps used to sit in the moderation surface (docs/MESHBAY_DESIGN.md
+§7.5):
+
+ * `admin_patch_group` let a *moderator* set a group to `revoked`, while the
+ user handler makes revoke admin-only;
+ * a `revoked` set through either PATCH was **never broadcast** to nodes —
+ unlike `POST /v1/admin/revoke` and account deletion — so it behaved like
+ `suspended` on nodes while claiming to be the signed, node-enforced state.
+
+Revoke now has one path, `POST /v1/admin/revoke` (admin-only, signs and
+broadcasts). PATCH refuses `revoked` and refuses to move an entity *out* of
+`revoked` unless the caller is an admin.
+"""
+
+import pytest
+
+from meshbay_hub.api.deps import set_admin_usernames
+
+
+async def _register(client, username):
+ r = await client.post("/v1/users/register", json={
+ "username": username, "email": f"{username}@test.local", "auth_key": "k" * 44})
+ assert r.status_code == 201
+ return r.json()["user_id"]
+
+
+async def _login(client, username):
+ r = await client.post("/v1/users/login", json={
+ "username": username, "auth_key": "k" * 44})
+ assert r.status_code == 200
+ return r.json()["access_token"]
+
+
+def _h(token):
+ return {"Authorization": f"Bearer {token}"}
+
+
+async def _admin(client, name="admin_rev_test"):
+ await _register(client, name)
+ set_admin_usernames([name])
+ return await _login(client, name)
+
+
+async def _moderator(client, admin_token, name="mod_rev_test"):
+ uid = await _register(client, name)
+ r = await client.patch(f"/v1/admin/users/{uid}", json={"role": "moderator"},
+ headers=_h(admin_token))
+ assert r.status_code == 200
+ return uid, await _login(client, name)
+
+
+async def _a_group(client, owner="owner_rev_test"):
+ await _register(client, owner)
+ tok = await _login(client, owner)
+ r = await client.post("/v1/groups", headers=_h(tok),
+ json={"name": "g", "visibility": "private", "join_policy": "invite"})
+ assert r.status_code == 201
+ return r.json()["group_id"]
+
+
+async def _group_status(client, admin_token, group_id):
+ data = (await client.get("/v1/admin/groups?limit=200", headers=_h(admin_token))).json()
+ return next(g["status"] for g in data["groups"] if g["id"] == group_id)
+
+
+# ── PATCH cannot revoke ──────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_moderator_cannot_revoke_a_group_via_patch(client):
+ admin_token = await _admin(client)
+ _, mod_token = await _moderator(client, admin_token)
+ gid = await _a_group(client)
+ r = await client.patch(f"/v1/admin/groups/{gid}", json={"status": "revoked"},
+ headers=_h(mod_token))
+ assert r.status_code == 403
+ assert await _group_status(client, admin_token, gid) == "active"
+
+
+@pytest.mark.asyncio
+async def test_admin_patch_revoked_group_is_redirected_not_silently_applied(client):
+ admin_token = await _admin(client)
+ gid = await _a_group(client)
+ r = await client.patch(f"/v1/admin/groups/{gid}", json={"status": "revoked"},
+ headers=_h(admin_token))
+ assert r.status_code == 400
+ assert "revoke" in r.json()["detail"].lower()
+ # And it was not quietly applied.
+ assert await _group_status(client, admin_token, gid) == "active"
+
+
+@pytest.mark.asyncio
+async def test_admin_patch_revoked_user_is_redirected(client):
+ admin_token = await _admin(client)
+ victim = await _register(client, "vic_rev_test")
+ r = await client.patch(f"/v1/admin/users/{victim}", json={"status": "revoked"},
+ headers=_h(admin_token))
+ assert r.status_code == 400
+
+
+# ── The one door: /v1/admin/revoke, admin-only, and it broadcasts ────────────
+
+@pytest.mark.asyncio
+async def test_admin_revoke_group_broadcasts_and_sets_status(client):
+ admin_token = await _admin(client)
+ gid = await _a_group(client)
+ r = await client.post("/v1/admin/revoke", headers=_h(admin_token),
+ json={"target": "group", "target_id": gid})
+ assert r.status_code == 200
+ body = r.json()
+ assert body["status"] == "revoked"
+ assert "nodes_notified" in body # it went through the broadcast path
+ assert await _group_status(client, admin_token, gid) == "revoked"
+
+
+@pytest.mark.asyncio
+async def test_moderator_cannot_reach_the_revoke_endpoint(client):
+ admin_token = await _admin(client)
+ _, mod_token = await _moderator(client, admin_token)
+ gid = await _a_group(client)
+ r = await client.post("/v1/admin/revoke", headers=_h(mod_token),
+ json={"target": "group", "target_id": gid})
+ assert r.status_code == 403
+
+
+# ── Leaving `revoked` is an admin's call ─────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_moderator_cannot_unrevoke_a_group(client):
+ admin_token = await _admin(client)
+ _, mod_token = await _moderator(client, admin_token)
+ gid = await _a_group(client)
+ await client.post("/v1/admin/revoke", headers=_h(admin_token),
+ json={"target": "group", "target_id": gid})
+ r = await client.patch(f"/v1/admin/groups/{gid}", json={"status": "active"},
+ headers=_h(mod_token))
+ assert r.status_code == 403
+ assert await _group_status(client, admin_token, gid) == "revoked"
+
+
+@pytest.mark.asyncio
+async def test_moderator_cannot_unrevoke_a_user(client):
+ admin_token = await _admin(client)
+ _, mod_token = await _moderator(client, admin_token)
+ victim = await _register(client, "vic2_rev_test")
+ await client.post("/v1/admin/revoke", headers=_h(admin_token),
+ json={"target": "user", "target_id": victim})
+ r = await client.patch(f"/v1/admin/users/{victim}", json={"status": "active"},
+ headers=_h(mod_token))
+ assert r.status_code == 403
+
+
+# ── Regression: suspend/unsuspend by a moderator still works ─────────────────
+
+@pytest.mark.asyncio
+async def test_moderator_can_still_suspend_and_restore(client):
+ admin_token = await _admin(client)
+ _, mod_token = await _moderator(client, admin_token)
+ gid = await _a_group(client)
+ assert (await client.patch(f"/v1/admin/groups/{gid}", json={"status": "suspended"},
+ headers=_h(mod_token))).status_code == 200
+ assert (await client.patch(f"/v1/admin/groups/{gid}", json={"status": "active"},
+ headers=_h(mod_token))).status_code == 200