aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_mnp_token.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-30 11:49:56 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-30 11:49:56 +0200
commitd3ad243c4ae3a273f623bd5fc631e3266aa4d0e4 (patch)
tree95ff1252c80a71e93c5098822d31b835572d8b52 /packages/meshbay-hub/tests/test_mnp_token.py
parent69554fac7eba6eef7eb8a1c0111c5b92e7f21256 (diff)
downloadmeshbay-d3ad243c4ae3a273f623bd5fc631e3266aa4d0e4.tar.gz
fix: only the owner decides who hosts a group, and nobody is made a member unasked
- hub: a node may host a group only if its account owns it or the owner approved that node (new `group_hosts`). Membership was the ceiling, and every member holds the group key, so any member's node could register as a host and be the one clients kept. A node claiming a group it may not host is recorded as a request; the owner is notified once and approves or refuses it (GET/POST/DELETE /v1/groups/{id}/hosts[/{node_id}]), which takes effect on a connected node at once. - hub: an owner adding a username creates an invitation (new `group_invitations`), accepted or declined by the invitee (/v1/groups/invitations, /{id}/invitation/accept|decline). Until then the group is not listed, not dialled, not searched and not in any token. Invitation links, open joins and group creation still make members directly: they are the account's own act. - hub: the MNP token names only the group it is minted for (group_id is now required), so a node operator no longer learns a member's other groups. - SPA: invitations on the home page; invited people and host requests in the group's settings; the transport sends group_id. Ten catalogues. - Browser probes for both screens, run in Chrome and Firefox. - Design §5.2, §7.2, §7.3, AV32, AV33; protocol §6.3; user guide. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/test_mnp_token.py')
-rw-r--r--packages/meshbay-hub/tests/test_mnp_token.py58
1 files changed, 52 insertions, 6 deletions
diff --git a/packages/meshbay-hub/tests/test_mnp_token.py b/packages/meshbay-hub/tests/test_mnp_token.py
index 3aa3115..ef6423d 100644
--- a/packages/meshbay-hub/tests/test_mnp_token.py
+++ b/packages/meshbay-hub/tests/test_mnp_token.py
@@ -8,9 +8,7 @@ these tests pin that it authorises to a node and is refused by the hub API.
"""
import pytest
-
from meshbay_common.handshake import HandshakeError, authorize_token
-from meshbay_common.tokens import MNP_AUD
async def _session_token(client, username="mnp_user_test"):
@@ -21,6 +19,12 @@ async def _session_token(client, username="mnp_user_test"):
return r.json()["access_token"]
+async def _own_group(client, tok, name="g"):
+ return (await client.post("/v1/groups", headers={"Authorization": f"Bearer {tok}"},
+ json={"name": name, "visibility": "private",
+ "join_policy": "invite"})).json()["group_id"]
+
+
@pytest.mark.asyncio
async def test_mnp_token_endpoint_needs_a_session(client):
# No Authorization header at all — FastAPI rejects the required header (422),
@@ -32,7 +36,8 @@ async def test_mnp_token_endpoint_needs_a_session(client):
@pytest.mark.asyncio
async def test_mnp_token_is_minted_for_a_member(client):
tok = await _session_token(client)
- r = await client.post("/v1/nodes/mnp-token",
+ gid = await _own_group(client, tok)
+ r = await client.post("/v1/nodes/mnp-token", json={"group_id": gid},
headers={"Authorization": f"Bearer {tok}"})
assert r.status_code == 200
assert r.json().get("mnp_token")
@@ -42,7 +47,8 @@ async def test_mnp_token_is_minted_for_a_member(client):
async def test_mnp_token_is_refused_at_the_hub_api(client):
"""The whole point: the credential a node receives opens nothing at the hub."""
tok = await _session_token(client, "mnp_api_test")
- mnp = (await client.post("/v1/nodes/mnp-token",
+ gid = await _own_group(client, tok)
+ mnp = (await client.post("/v1/nodes/mnp-token", json={"group_id": gid},
headers={"Authorization": f"Bearer {tok}"})).json()["mnp_token"]
# Presenting it to a hub endpoint fails.
r = await client.get("/v1/users/me", headers={"Authorization": f"Bearer {mnp}"})
@@ -60,7 +66,8 @@ async def test_a_session_token_is_refused_by_a_node_but_the_mnp_token_is_not(cli
H = {"Authorization": f"Bearer {tok}"}
gid = (await client.post("/v1/groups", headers=H, json={
"name": "g", "visibility": "private", "join_policy": "invite"})).json()["group_id"]
- mnp = (await client.post("/v1/nodes/mnp-token", headers=H)).json()["mnp_token"]
+ mnp = (await client.post("/v1/nodes/mnp-token", headers=H,
+ json={"group_id": gid})).json()["mnp_token"]
pk = hub_public_key_pem()
# The session token is refused by the node handshake (wrong audience).
@@ -83,10 +90,49 @@ async def test_the_mnp_token_is_bound_to_the_node_it_names(client):
"name": "g", "visibility": "private", "join_policy": "invite"})).json()["group_id"]
# A token bound to node A's key.
mnp = (await client.post("/v1/nodes/mnp-token", headers=H,
- json={"node_pk": "node-A-pk"})).json()["mnp_token"]
+ json={"node_pk": "node-A-pk", "group_id": gid})).json()["mnp_token"]
pk = hub_public_key_pem()
# Node B refuses it; node A accepts it.
with pytest.raises(HandshakeError, match="this node"):
authorize_token(mnp, pk, group_id=gid, node_pk_b64="node-B-pk")
peer = authorize_token(mnp, pk, group_id=gid, node_pk_b64="node-A-pk")
assert peer.group_id == gid
+
+
+@pytest.mark.asyncio
+async def test_the_mnp_token_names_only_the_group_asked_for(client):
+ """It is handed to that group's node operator, who has no business learning
+ every other group the member belongs to."""
+ import jwt as _jwt
+
+ tok = await _session_token(client, "mnp_scope_test")
+ H = {"Authorization": f"Bearer {tok}"}
+ one = await _own_group(client, tok, "one")
+ await _own_group(client, tok, "two")
+ await _own_group(client, tok, "three")
+ mnp = (await client.post("/v1/nodes/mnp-token", headers=H,
+ json={"group_id": one})).json()["mnp_token"]
+ claims = _jwt.decode(mnp, options={"verify_signature": False})
+ assert claims["groups"] == [one]
+
+
+@pytest.mark.asyncio
+async def test_no_group_is_named_for_a_non_member(client):
+ from meshbay_hub.auth import hub_public_key_pem
+
+ owner = await _session_token(client, "mnp_owner_test")
+ gid = await _own_group(client, owner)
+ stranger = await _session_token(client, "mnp_stranger")
+ mnp = (await client.post("/v1/nodes/mnp-token", json={"group_id": gid},
+ headers={"Authorization": f"Bearer {stranger}"})).json()["mnp_token"]
+ with pytest.raises(HandshakeError) as refused:
+ authorize_token(mnp, hub_public_key_pem(), group_id=gid)
+ assert refused.value.code == "not_a_member"
+
+
+@pytest.mark.asyncio
+async def test_a_token_must_name_its_group(client):
+ tok = await _session_token(client, "mnp_nogroup_test")
+ r = await client.post("/v1/nodes/mnp-token", json={},
+ headers={"Authorization": f"Bearer {tok}"})
+ assert r.status_code == 422