aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_revocation.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-09 05:15:57 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-09 05:15:57 +0200
commit1734c668406c66e2be63e0c6999b4b2af2f60808 (patch)
tree3a537834f777d879993fde1f910e5a2e9c92505f /packages/meshbay-hub/tests/test_revocation.py
parent88cfc139333ac3fe5789f39f3970065181df9043 (diff)
downloadmeshbay-1734c668406c66e2be63e0c6999b4b2af2f60808.tar.gz
feat: add revocation push (WebSocket hub→node) — 5.7
Hub: /v1/nodes/ws WebSocket endpoint for persistent node connections. /v1/admin/revoke marks user/group revoked in DB, signs JWT revocation token (EdDSA), broadcasts to all connected nodes. Node: RevocationSubscriber maintains WS connection, verifies incoming revocation tokens offline (hub Ed25519 PK), adds to local blocklist (_revoked_users/_revoked_groups sets). 53/53 tests. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/test_revocation.py')
-rw-r--r--packages/meshbay-hub/tests/test_revocation.py119
1 files changed, 119 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_revocation.py b/packages/meshbay-hub/tests/test_revocation.py
new file mode 100644
index 0000000..f5d6050
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_revocation.py
@@ -0,0 +1,119 @@
+"""Tests for revocation — admin endpoint + token signing."""
+
+import time
+import pytest
+import jwt
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
+
+from meshbay_common.crypto import pk_to_b64
+
+
+async def _register_and_login(client, username, pk_ed, pk_x):
+ await client.post("/v1/users/register", json={
+ "username": username, "email": f"{username}@test.com",
+ "password": "testpass99",
+ "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x,
+ })
+ r = await client.post("/v1/users/login",
+ json={"username": username, "password": "testpass99"})
+ return r.json()["access_token"], r.json()["user_id"] if "user_id" in r.json() else None
+
+
+@pytest.mark.asyncio
+async def test_revoke_user_marks_db(client):
+ """POST /v1/admin/revoke marks user as revoked in DB."""
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
+
+ sk_ed = Ed25519PrivateKey.generate()
+ sk_x = X25519PrivateKey.generate()
+ pk_ed = pk_to_b64(sk_ed.public_key())
+ pk_x = pk_to_b64(sk_x.public_key())
+
+ r = await client.post("/v1/users/register", json={
+ "username": "vic1", "email": "v@t.com", "password": "vicpass99",
+ "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x,
+ })
+ victim_id = r.json()["user_id"]
+
+ # Admin (any registered user for now — production would add role check)
+ sk_admin_ed = Ed25519PrivateKey.generate()
+ sk_admin_x = X25519PrivateKey.generate()
+ admin_token, _ = await _register_and_login(
+ client, "admin1",
+ pk_to_b64(sk_admin_ed.public_key()),
+ pk_to_b64(sk_admin_x.public_key()),
+ )
+
+ r = await client.post("/v1/admin/revoke", json={
+ "target": "user", "target_id": victim_id, "reason": "spam",
+ }, headers={"Authorization": f"Bearer {admin_token}"})
+ assert r.status_code == 200
+ data = r.json()
+ assert data["status"] == "revoked"
+ assert "token" in data
+
+ # Victim can no longer login
+ r = await client.post("/v1/users/login", json={
+ "username": "vic1", "password": "vicpass99"})
+ assert r.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_revocation_token_verifiable_offline(client):
+ """Revocation token is a valid JWT signed by hub Ed25519 key."""
+ sk_ed = Ed25519PrivateKey.generate()
+ sk_x = X25519PrivateKey.generate()
+
+ r = await client.post("/v1/users/register", json={
+ "username": "vic2", "email": "v2@t.com", "password": "vicpass99",
+ "pk_user_ed25519": pk_to_b64(sk_ed.public_key()),
+ "pk_user_x25519": pk_to_b64(sk_x.public_key()),
+ })
+ victim_id = r.json()["user_id"]
+
+ sk_admin_ed = Ed25519PrivateKey.generate()
+ sk_admin_x = X25519PrivateKey.generate()
+ admin_token, _ = await _register_and_login(
+ client, "admin2",
+ pk_to_b64(sk_admin_ed.public_key()),
+ pk_to_b64(sk_admin_x.public_key()),
+ )
+
+ r = await client.post("/v1/admin/revoke", json={
+ "target": "user", "target_id": victim_id, "reason": "test",
+ }, headers={"Authorization": f"Bearer {admin_token}"})
+ rev_token = r.json()["token"]
+
+ # Verify offline with hub's public key
+ r_pk = await client.get("/v1/hub/pubkey")
+ hub_pk_pem = r_pk.json()["pk_hub_pem"].encode()
+
+ decoded = jwt.decode(rev_token, hub_pk_pem, algorithms=["EdDSA"],
+ options={"verify_exp": False})
+ assert decoded["type"] == "revocation"
+ assert decoded["target"] == "user"
+ assert decoded["target_id"] == victim_id
+ assert "jti" in decoded
+
+
+@pytest.mark.asyncio
+async def test_revoke_group(client):
+ sk_ed = Ed25519PrivateKey.generate()
+ sk_x = X25519PrivateKey.generate()
+ admin_token, _ = await _register_and_login(
+ client, "admin3",
+ pk_to_b64(sk_ed.public_key()),
+ pk_to_b64(sk_x.public_key()),
+ )
+ hdrs = {"Authorization": f"Bearer {admin_token}"}
+
+ r = await client.post("/v1/groups", json={"name": "grp-to-revoke"}, headers=hdrs)
+ group_id = r.json()["group_id"]
+
+ r = await client.post("/v1/admin/revoke", json={
+ "target": "group", "target_id": group_id, "reason": "tos_violation",
+ }, headers=hdrs)
+ assert r.status_code == 200
+ assert r.json()["status"] == "revoked"