From d3ad243c4ae3a273f623bd5fc631e3266aa4d0e4 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 30 Sep 2026 11:49:56 +0200 Subject: fix: only the owner decides who hosts a group, and nobody is made a member unasked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../meshbay-hub/tests/harness/group_hosts_probe.py | 175 ++++++++++++++++++ .../meshbay-hub/tests/harness/invitation_probe.py | 201 ++++++++++++++++++++ packages/meshbay-hub/tests/membership.py | 39 ++++ .../meshbay-hub/tests/test_account_deletion.py | 4 +- packages/meshbay-hub/tests/test_admin_views.py | 3 +- .../tests/test_availability_between_members.py | 6 +- .../meshbay-hub/tests/test_group_description.py | 3 +- packages/meshbay-hub/tests/test_group_hosting.py | 7 +- .../tests/test_group_leave_and_quota.py | 13 +- .../meshbay-hub/tests/test_group_membership.py | 6 +- packages/meshbay-hub/tests/test_group_purge.py | 17 +- packages/meshbay-hub/tests/test_hub_api.py | 17 +- packages/meshbay-hub/tests/test_invitation_ui.py | 66 +++++++ .../tests/test_invitations_and_hosts.py | 204 +++++++++++++++++++++ packages/meshbay-hub/tests/test_mnp_token.py | 58 +++++- packages/meshbay-hub/tests/test_node_auth.py | 6 +- .../tests/test_notifications_behaviour.py | 10 +- 17 files changed, 787 insertions(+), 48 deletions(-) create mode 100644 packages/meshbay-hub/tests/harness/group_hosts_probe.py create mode 100644 packages/meshbay-hub/tests/harness/invitation_probe.py create mode 100644 packages/meshbay-hub/tests/membership.py create mode 100644 packages/meshbay-hub/tests/test_invitation_ui.py create mode 100644 packages/meshbay-hub/tests/test_invitations_and_hosts.py (limited to 'packages/meshbay-hub/tests') diff --git a/packages/meshbay-hub/tests/harness/group_hosts_probe.py b/packages/meshbay-hub/tests/harness/group_hosts_probe.py new file mode 100644 index 0000000..0611e3d --- /dev/null +++ b/packages/meshbay-hub/tests/harness/group_hosts_probe.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +""" +A group owner's settings: who was invited, and which other nodes asked to host. + +Renders the shipped `GroupSettingsPanel` with `fetch` stubbed: one member, one +unanswered invitation, one node asking to host and one already approved. Then +clicks Approve on the request and reports what reached the hub. + + group_hosts_probe.py [--engine chrome|firefox] + +Prints JSON. +""" + +import argparse +import http.server +import json +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" +PORT = 8773 +RECORDS = [] +FINISHED = threading.Event() +socketserver.TCPServer.allow_reuse_address = True + +PAGE = r""" + +
+__HOLD__""" + +HOLD_TAG = '' +HOLD = "" + + +class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) + if self.path == "/log": + RECORDS.append(json.loads(body.decode())) + FINISHED.set() + self.send_response(204) + self.end_headers() + + def _send(self, body: bytes, ctype: str) -> None: + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?")[0] + if path == "/hold": + FINISHED.wait(60) + self._send(b"", "image/gif") + elif path == "/": + self._send(PAGE.replace("__HOLD__", HOLD).encode(), "text/html; charset=utf-8") + else: + asset = (STATIC / path.lstrip("/")).resolve() + if not str(asset).startswith(str(STATIC)) or not asset.is_file(): + self.send_response(404) + self.end_headers() + return + self._send(asset.read_bytes(), + "text/css" if asset.suffix == ".css" + else "text/javascript" if asset.suffix == ".js" + else "application/octet-stream") + + +# Launchers and profile rule: see sticky_header_probe.py. +ENGINES = { + "chrome": lambda profile: [ + "google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + f"--user-data-dir={profile}", "--window-size=1100,900"], + "firefox": lambda profile: [ + "firefox", "--headless", "--profile", profile, + "--screenshot", str(Path(profile) / "shot.png"), "--window-size", "1100,900"], +} + + +def main() -> int: + global HOLD + ap = argparse.ArgumentParser() + ap.add_argument("--engine", choices=sorted(ENGINES), default="chrome") + args = ap.parse_args() + HOLD = HOLD_TAG if args.engine == "firefox" else "" + parent = None + if args.engine == "firefox": + snap = Path.home() / "snap" / "firefox" / "common" + parent = str(snap if snap.is_dir() else Path.home()) + with socketserver.ThreadingTCPServer(("127.0.0.1", PORT), H) as srv: + threading.Thread(target=srv.serve_forever, daemon=True).start() + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True, + prefix="meshbay-probe-", dir=parent) as profile: + proc = subprocess.Popen(ENGINES[args.engine](profile) + + [f"http://127.0.0.1:{PORT}/"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(300): + if RECORDS: + break + time.sleep(0.1) + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + if not RECORDS: + print(json.dumps({"error": "no measurement"}), file=sys.stderr) + return 1 + print(json.dumps(dict(RECORDS[0], engine=args.engine), indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/meshbay-hub/tests/harness/invitation_probe.py b/packages/meshbay-hub/tests/harness/invitation_probe.py new file mode 100644 index 0000000..29133ae --- /dev/null +++ b/packages/meshbay-hub/tests/harness/invitation_probe.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +An invitation waiting on the home page, answered in the real application. + +Being added to a group is an invitation until it is accepted (AV33). What only +the running application shows is that the home page lists it, that Accept and +Decline reach the hub, and that an accepted group then appears among the +reader's groups — while a declined one does not. + +Loads the shipped `app.js` with `fetch` stubbed, once per case: + + accept — the invitation is listed, Accept is clicked + decline — the invitation is listed, Decline is clicked + + invitation_probe.py [--engine chrome|firefox] + +Prints JSON: one object per case. +""" + +import argparse +import http.server +import json +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" +PORT = 8772 +RECORDS = [] +FINISHED = threading.Event() +socketserver.TCPServer.allow_reuse_address = True + +GROUP = "0f8fad5b-d9cb-469f-a165-70867728950e" + +PAGE = r""" +
+__HOLD__ +""".replace("__GROUP__", GROUP) + +HOLD_TAG = '' +HOLD = "" + + +class H(http.server.SimpleHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) + if self.path == "/log": + RECORDS.append(json.loads(body.decode())) + FINISHED.set() + self.send_response(204) + self.end_headers() + + def _send(self, body: bytes, ctype: str) -> None: + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?")[0] + if path == "/hold": + FINISHED.wait(60) + self._send(b"", "image/gif") + return + if path == "/": + self._send(PAGE.replace("__HOLD__", HOLD).encode(), "text/html; charset=utf-8") + return + asset = (STATIC / path.lstrip("/")).resolve() + if not str(asset).startswith(str(STATIC)) or not asset.is_file(): + self.send_response(404) + self.end_headers() + return + ctype = "text/javascript" if asset.suffix in (".js", ".mjs") else ( + "application/wasm" if asset.suffix == ".wasm" else "application/octet-stream") + self._send(asset.read_bytes(), ctype) + + +# Same launchers and the same profile rule as sticky_header_probe.py, which +# explains both: Firefox is a snap here, and needs a profile under $HOME. +ENGINES = { + "chrome": lambda profile: [ + "google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + f"--user-data-dir={profile}"], + "firefox": lambda profile: [ + "firefox", "--headless", "--profile", profile, + "--screenshot", str(Path(profile) / "shot.png")], +} + + +def _profile_parent(engine: str) -> str | None: + if engine != "firefox": + return None + snap = Path.home() / "snap" / "firefox" / "common" + return str(snap if snap.is_dir() else Path.home()) + + +def _run(engine: str, case: str) -> dict | None: + before = len(RECORDS) + FINISHED.clear() + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True, prefix="meshbay-probe-", + dir=_profile_parent(engine)) as profile: + proc = subprocess.Popen( + ENGINES[engine](profile) + [f"http://127.0.0.1:{PORT}/?case={case}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(300): + if len(RECORDS) > before: + break + time.sleep(0.1) + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + return RECORDS[before] if len(RECORDS) > before else None + + +def main() -> int: + global HOLD + ap = argparse.ArgumentParser() + ap.add_argument("--engine", choices=sorted(ENGINES), default="chrome") + args = ap.parse_args() + HOLD = HOLD_TAG if args.engine == "firefox" else "" + with socketserver.ThreadingTCPServer(("127.0.0.1", PORT), H) as srv: + threading.Thread(target=srv.serve_forever, daemon=True).start() + results = [_run(args.engine, "accept"), _run(args.engine, "decline")] + if not all(results): + print(json.dumps({"error": "no measurement", "got": results}), file=sys.stderr) + return 1 + print(json.dumps([dict(r, engine=args.engine) for r in results], indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/meshbay-hub/tests/membership.py b/packages/meshbay-hub/tests/membership.py new file mode 100644 index 0000000..041eb74 --- /dev/null +++ b/packages/meshbay-hub/tests/membership.py @@ -0,0 +1,39 @@ +""" +Making somebody a member, the way the product does it. + +An owner adding a username creates an *invitation*; the account becomes a +member only when it accepts (`POST /v1/groups/{id}/invitation/accept`). Tests +that need a member go through both steps, with a token of the invitee's own — +a shortcut that wrote `GroupMember` directly would test a hub where adding +someone still made them a member without asking, which is the hole this +closed. +""" + +from meshbay_hub.auth import issue_access_token +from meshbay_hub.db.engine import get_session_factory +from meshbay_hub.db.models import User +from sqlalchemy import select + + +async def token_of(username: str) -> str: + async with get_session_factory()() as db: + uid = await db.scalar(select(User.id).where(User.username == username)) + assert uid, f"no such account {username!r}" + return issue_access_token(uid) + + +async def accept_invitation(client, group_id: str, username: str) -> None: + tok = await token_of(username) + r = await client.post(f"/v1/groups/{group_id}/invitation/accept", + headers={"Authorization": f"Bearer {tok}"}) + assert r.status_code == 200, r.text + + +async def add_member(client, group_id: str, username: str, owner_headers: dict): + """Invite, then accept as the invitee. Returns the invitation response.""" + r = await client.post(f"/v1/groups/{group_id}/members/{username}", json={}, + headers=owner_headers) + assert r.status_code in (200, 201), r.text + if r.json().get("status") == "invited": + await accept_invitation(client, group_id, username) + return r diff --git a/packages/meshbay-hub/tests/test_account_deletion.py b/packages/meshbay-hub/tests/test_account_deletion.py index 64c2be8..a31aaff 100644 --- a/packages/meshbay-hub/tests/test_account_deletion.py +++ b/packages/meshbay-hub/tests/test_account_deletion.py @@ -12,6 +12,7 @@ command. import hashlib import pytest +from membership import add_member from meshbay_hub.db.models import GroupMember, Notification, RefreshToken, User from sqlalchemy import select @@ -107,8 +108,7 @@ async def test_deletion_clears_memberships_notifications_and_tokens( g = await client.post("/v1/groups", json={"name": "shared"}, headers={"Authorization": f"Bearer {owner_token}"}) gid = g.json()["group_id"] - await client.post(f"/v1/groups/{gid}/members/member1_test", json={}, - headers={"Authorization": f"Bearer {owner_token}"}) + await add_member(client, gid, 'member1_test', {"Authorization": f"Bearer {owner_token}"}) uid = (await db_session.execute( select(User.id).where(User.username == "member1_test"))).scalar_one() diff --git a/packages/meshbay-hub/tests/test_admin_views.py b/packages/meshbay-hub/tests/test_admin_views.py index da2d3c9..2ac37c9 100644 --- a/packages/meshbay-hub/tests/test_admin_views.py +++ b/packages/meshbay-hub/tests/test_admin_views.py @@ -11,6 +11,7 @@ import base64 import hashlib import pytest +from membership import add_member from meshbay_hub.db.models import User from sqlalchemy import select @@ -78,7 +79,7 @@ async def test_the_member_list_of_a_group_skips_them(client, db_session): g = await client.post("/v1/groups", json={"name": "party"}, headers=owner) gid = g.json()["group_id"] - await client.post(f"/v1/groups/{gid}/members/quitter_test", json={}, headers=owner) + await add_member(client, gid, 'quitter_test', owner) await client.request("DELETE", "/v1/users/me", headers=leaver, json={"auth_key": _auth_key( diff --git a/packages/meshbay-hub/tests/test_availability_between_members.py b/packages/meshbay-hub/tests/test_availability_between_members.py index e66be31..e6531c4 100644 --- a/packages/meshbay-hub/tests/test_availability_between_members.py +++ b/packages/meshbay-hub/tests/test_availability_between_members.py @@ -24,6 +24,7 @@ import time import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from membership import add_member from meshbay_common.crypto import pk_to_b64 @@ -69,9 +70,8 @@ async def _make_group(client, owner: dict, name: str) -> str: async def _add_member(client, owner: dict, group_id: str, member: dict) -> None: - r = await client.post( - f"/v1/groups/{group_id}/members/{member['username']}", - headers={"Authorization": f"Bearer {owner['token']}"}) + r = await add_member(client, group_id, member['username'], + {"Authorization": f"Bearer {owner['token']}"}) assert r.status_code == 201, r.text diff --git a/packages/meshbay-hub/tests/test_group_description.py b/packages/meshbay-hub/tests/test_group_description.py index b41a7db..98ed3cf 100644 --- a/packages/meshbay-hub/tests/test_group_description.py +++ b/packages/meshbay-hub/tests/test_group_description.py @@ -11,6 +11,7 @@ import base64 import hashlib import pytest +from membership import add_member def _auth_key(password: str, username: str) -> str: @@ -52,7 +53,7 @@ async def test_a_member_cannot(client): owner = await _user(client, "owner2_test") member = await _user(client, "member2_test") gid = await _group(client, owner, name="not-yours") - await client.post(f"/v1/groups/{gid}/members/member2_test", json={}, headers=owner) + await add_member(client, gid, 'member2_test', owner) r = await client.patch(f"/v1/groups/{gid}", json={"description": "mine now"}, headers=member) diff --git a/packages/meshbay-hub/tests/test_group_hosting.py b/packages/meshbay-hub/tests/test_group_hosting.py index c6111f2..7aa6bd1 100644 --- a/packages/meshbay-hub/tests/test_group_hosting.py +++ b/packages/meshbay-hub/tests/test_group_hosting.py @@ -17,6 +17,7 @@ import hashlib from datetime import UTC, datetime, timedelta import pytest +from membership import add_member from meshbay_hub.db.models import Group, GroupMember from meshbay_hub.tasks.cleanup import find_unhosted_groups, prune_unhosted_groups from sqlalchemy import select @@ -72,7 +73,7 @@ async def test_a_member_does_not_see_an_unhosted_group(client): owner = await _user(client, "setup2_test") 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) + await add_member(client, gid, 'early_bird', owner) mine = await client.get("/v1/groups/mine", headers=member) assert [g["name"] for g in mine.json()["groups"]] == [] @@ -83,7 +84,7 @@ async def test_a_member_sees_it_once_a_node_has_announced_it(client, db_session) owner = await _user(client, "setup3_test") member = await _user(client, "patient_test") gid = (await _group(client, owner, "ready")).json()["group_id"] - await client.post(f"/v1/groups/{gid}/members/patient_test", json={}, headers=owner) + await add_member(client, gid, 'patient_test', owner) await _mark_hosted(db_session, gid) @@ -174,7 +175,7 @@ async def test_collecting_a_group_takes_its_memberships_with_it(client, db_sessi owner = await _user(client, "reaper5_test") 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) + await add_member(client, gid, 'tagalong', owner) g = await db_session.get(Group, gid) g.created_at = datetime.now(UTC) - timedelta(days=9) diff --git a/packages/meshbay-hub/tests/test_group_leave_and_quota.py b/packages/meshbay-hub/tests/test_group_leave_and_quota.py index 8af830d..e4efc09 100644 --- a/packages/meshbay-hub/tests/test_group_leave_and_quota.py +++ b/packages/meshbay-hub/tests/test_group_leave_and_quota.py @@ -17,6 +17,7 @@ import hashlib from datetime import UTC, datetime import pytest +from membership import add_member from meshbay_hub.api.groups import MAX_PUBLIC_GROUPS from meshbay_hub.db.models import Group, GroupMember, User from sqlalchemy import select @@ -55,7 +56,7 @@ async def test_a_member_can_leave(client, db_session): owner = await _user(client, "owner1_test") member = await _user(client, "member1_test") gid = await _group(client, owner, "readers") - await client.post(f"/v1/groups/{gid}/members/member1_test", json={}, headers=owner) + await add_member(client, gid, 'member1_test', 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. @@ -80,7 +81,7 @@ async def test_leaving_removes_only_that_membership_row(client, db_session): owner = await _user(client, "owner2_test") member = await _user(client, "member2_test") gid = await _group(client, owner, "still-here") - await client.post(f"/v1/groups/{gid}/members/member2_test", json={}, headers=owner) + await add_member(client, gid, 'member2_test', owner) await client.post(f"/v1/groups/{gid}/leave", headers=member) @@ -100,8 +101,8 @@ async def test_leaving_does_not_touch_the_account_or_its_other_groups(client, db elsewhere = await _user(client, "owner3b_test") gid = await _group(client, owner, "leaving") other = await _group(client, elsewhere, "staying") - await client.post(f"/v1/groups/{gid}/members/member3_test", json={}, headers=owner) - await client.post(f"/v1/groups/{other}/members/member3_test", json={}, headers=elsewhere) + await add_member(client, gid, 'member3_test', owner) + await add_member(client, other, 'member3_test', elsewhere) await client.post(f"/v1/groups/{gid}/leave", headers=member) @@ -130,7 +131,7 @@ async def test_leaving_twice_is_refused(client): owner = await _user(client, "owner5_test") member = await _user(client, "member5_test") gid = await _group(client, owner, "once") - await client.post(f"/v1/groups/{gid}/members/member5_test", json={}, headers=owner) + await add_member(client, gid, 'member5_test', owner) assert (await client.post(f"/v1/groups/{gid}/leave", headers=member)).status_code == 200 @@ -203,7 +204,7 @@ async def test_the_cap_is_per_owner(client): b = await _user(client, "ownerb2_test") 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_test", json={}, headers=a) + await add_member(client, gid, 'ownerb2_test', a) r = await client.post("/v1/groups", json={"name": "b-first", "visibility": "public", diff --git a/packages/meshbay-hub/tests/test_group_membership.py b/packages/meshbay-hub/tests/test_group_membership.py index ad1b3ab..eb8ad78 100644 --- a/packages/meshbay-hub/tests/test_group_membership.py +++ b/packages/meshbay-hub/tests/test_group_membership.py @@ -11,6 +11,7 @@ import base64 import hashlib import pytest +from membership import add_member from meshbay_hub.db.models import GroupMember, User from sqlalchemy import select @@ -33,8 +34,7 @@ async def _user(client, username, password="a-long-enough-passphrase"): async def _group_with_member(client, owner, member_name, name="crew"): g = await client.post("/v1/groups", json={"name": name}, headers=owner) gid = g.json()["group_id"] - await client.post(f"/v1/groups/{gid}/members/{member_name}", json={}, - headers=owner) + await add_member(client, gid, member_name, owner) return gid @@ -86,7 +86,7 @@ async def test_a_member_cannot_remove_anyone(client): member = await _user(client, "member_y") await _user(client, "victim_y") gid = await _group_with_member(client, owner, "member_y") - await client.post(f"/v1/groups/{gid}/members/victim_y", json={}, headers=owner) + await add_member(client, gid, 'victim_y', owner) r = await client.delete(f"/v1/groups/{gid}/members/victim_y", headers=member) assert r.status_code == 403 diff --git a/packages/meshbay-hub/tests/test_group_purge.py b/packages/meshbay-hub/tests/test_group_purge.py index 40d2d54..c802513 100644 --- a/packages/meshbay-hub/tests/test_group_purge.py +++ b/packages/meshbay-hub/tests/test_group_purge.py @@ -21,13 +21,17 @@ from datetime import UTC, datetime, timedelta import jwt import pytest +from membership import add_member from meshbay_hub.db.models import ( ContentReport, EmailVerification, Group, + GroupHost, + GroupInvitation, GroupInviteLink, GroupMember, IPLog, + Node, Notification, User, ) @@ -36,7 +40,7 @@ from sqlalchemy import delete, func, select, text from sqlalchemy.exc import IntegrityError SEEDED = {"group_members", "notifications", "email_verifications", "content_reports", - "group_invite_links"} + "group_invite_links", "group_invitations", "group_hosts"} def _auth_key(password: str, username: str) -> str: @@ -72,8 +76,7 @@ async def _group_with_everything(client, db, owner_token: str, member: str, name headers={"Authorization": f"Bearer {owner_token}"}) assert r.status_code in (200, 201), r.text gid = r.json()["group_id"] - r = await client.post(f"/v1/groups/{gid}/members/{member}", json={}, - headers={"Authorization": f"Bearer {owner_token}"}) + r = await add_member(client, gid, member, {"Authorization": f"Bearer {owner_token}"}) assert r.status_code in (200, 201), r.text member_id = await _uid(db, member) db.add(Notification(user_id=member_id, kind="chat", group_id=gid, title="a message")) @@ -87,6 +90,14 @@ async def _group_with_everything(client, db, owner_token: str, member: str, name email_masked="m***@e***.com", expires_at=datetime.now(UTC) + timedelta(days=1), redeemed_by=member_id, redeemed_at=datetime.now(UTC))) + # An unanswered invitation, and a node asking to host. + invitee = (await db.execute(select(User.id).where(User.id != owner_id, + User.id != member_id))).scalars().first() + db.add(GroupInvitation(group_id=gid, user_id=invitee or owner_id, invited_by=owner_id)) + node = Node(user_id=member_id, pk_node=gid[:43]) + db.add(node) + await db.flush() + db.add(GroupHost(group_id=gid, node_id=node.id, status="pending")) await db.commit() return gid diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py index 162b074..378bbb8 100644 --- a/packages/meshbay-hub/tests/test_hub_api.py +++ b/packages/meshbay-hub/tests/test_hub_api.py @@ -3,13 +3,14 @@ Integration tests for the Hub API. Uses SQLite in-memory + httpx.AsyncClient — no PostgreSQL, no network. """ -from meshbay_common.tokens import HUB_API_AUD from datetime import UTC import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from membership import add_member from meshbay_common.crypto import pk_to_b64 +from meshbay_common.tokens import HUB_API_AUD from meshbay_hub.api.deps import set_admin_usernames @@ -272,8 +273,7 @@ async def test_group_member_add(client): group_id = r.json()["group_id"] # Add bob as member (hub handles membership only, GEK exchange is P2P) - r = await client.post(f"/v1/groups/{group_id}/members/bob2_test", - json={}, headers=a_hdrs) + r = await add_member(client, group_id, 'bob2_test', a_hdrs) assert r.status_code == 201 # Verify bob is in the group @@ -314,8 +314,7 @@ async def test_non_admin_cannot_add_member(client): group_id = r.json()["group_id"] # Dan (non-admin) tries to add a member → 403 - r = await client.post(f"/v1/groups/{group_id}/members/charlie_test", - json={}, + r = await client.post(f"/v1/groups/{group_id}/members/charlie_test", json={}, headers={"Authorization": f"Bearer {dan_token}"}) assert r.status_code == 403 @@ -352,9 +351,7 @@ async def test_jwt_contains_groups_claim(client): headers={"Authorization": f"Bearer {alice_token}"}) group_id = r.json()["group_id"] - await client.post(f"/v1/groups/{group_id}/members/grp_bob_test", - json={}, - headers={"Authorization": f"Bearer {alice_token}"}) + await add_member(client, group_id, 'grp_bob_test', {"Authorization": f"Bearer {alice_token}"}) # Login again — groups should contain the new group r = await client.post("/v1/users/login", json={ @@ -400,9 +397,7 @@ async def test_my_groups(client, db_session): r = await client.post("/v1/groups", json={"name": "mg-group"}, headers={"Authorization": f"Bearer {alice_token}"}) group_id = r.json()["group_id"] - await client.post(f"/v1/groups/{group_id}/members/mg_bob_test", - json={}, - headers={"Authorization": f"Bearer {alice_token}"}) + await add_member(client, group_id, 'mg_bob_test', {"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 diff --git a/packages/meshbay-hub/tests/test_invitation_ui.py b/packages/meshbay-hub/tests/test_invitation_ui.py new file mode 100644 index 0000000..99538e9 --- /dev/null +++ b/packages/meshbay-hub/tests/test_invitation_ui.py @@ -0,0 +1,66 @@ +""" +The two answers a person now gives, in the real application, in both engines. + +An invitee answers an invitation on the home page (harness/invitation_probe.py); +a group owner answers a node that asked to host the group, and sees who was +invited and has not answered (harness/group_hosts_probe.py). The hub side is +`test_invitations_and_hosts.py`; this is where it meets the interface. +""" + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +HARNESS = Path(__file__).parent / "harness" +BINARY = {"chrome": "google-chrome", "firefox": "firefox"} + + +def _run(probe: str, engine: str): + if shutil.which(BINARY[engine]) is None: + pytest.skip(f"{engine} is not available") + proc = subprocess.run([sys.executable, str(HARNESS / probe), "--engine", engine], + capture_output=True, text=True, timeout=240) + assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}" + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module", params=["chrome", "firefox"]) +def invitation(request): + return {c["case"]: c for c in _run("invitation_probe.py", request.param)} + + +@pytest.fixture(scope="module", params=["chrome", "firefox"]) +def hosts(request): + return _run("group_hosts_probe.py", request.param) + + +def test_an_invitation_is_listed_with_its_two_answers(invitation): + for c in invitation.values(): + assert "error" not in c, c["error"] + assert c["listed"] and c["buttons"] == 2 + + +def test_accepting_joins_and_shows_the_group(invitation): + c = invitation["accept"] + assert c["answer_call"] == [ + "POST /v1/groups/0f8fad5b-d9cb-469f-a165-70867728950e/invitation/accept"] + assert c["group_card"] and not c["invitation_still_shown"] + + +def test_declining_leaves_no_group(invitation): + c = invitation["decline"] + assert c["answer_call"][0].endswith("/invitation/decline") + assert not c["group_card"] and not c["invitation_still_shown"] + + +def test_the_owner_sees_the_invited_and_approves_a_host(hosts): + assert "error" not in hosts, hosts.get("error") + assert hosts["invited_row"] + assert hosts["host_rows"] == 2 + assert hosts["buttons_on_request"] == 2 # approve and refuse + assert hosts["buttons_on_approved"] == 1 # refuse only + assert hosts["decision"] == ["POST /v1/groups/g1/hosts/n-asking"] diff --git a/packages/meshbay-hub/tests/test_invitations_and_hosts.py b/packages/meshbay-hub/tests/test_invitations_and_hosts.py new file mode 100644 index 0000000..6460d09 --- /dev/null +++ b/packages/meshbay-hub/tests/test_invitations_and_hosts.py @@ -0,0 +1,204 @@ +""" +Two things one participant must not be able to decide for another. + +**That you are in a group.** An owner could add any username and the account +became a member at once: the group was in its sidebar, named in its MNP tokens, +and its client dialled the group's nodes — nodes the owner chose. So an owner's +addition is an invitation until the invitee accepts it. + +**That a node hosts a group.** A node could register for any group its account +belonged to, and clients keep the first registered node that completes the +handshake — which any member's node does, since every member holds the group +key. So a node hosts a group only if its account owns it or the owner approved +it; the rest of its claim is a request the owner sees. + +Each test is two accounts or more, because a one-member test proves a +one-member property (CLAUDE.md, "ask who pays"). +""" + +import base64 +import time + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from membership import accept_invitation +from meshbay_common.crypto import pk_to_b64 +from meshbay_hub.db.models import GroupHost, Notification +from sqlalchemy import select + +KEY = base64.b64encode(b"k" * 32).decode() + + +async def _user(client, username): + sk = Ed25519PrivateKey.generate() + r = await client.post("/v1/users/register", json={ + "username": username, "email": f"{username}@example.test", "auth_key": KEY}) + assert r.status_code == 201, r.text + uid = r.json()["user_id"] + r = await client.post("/v1/users/login", json={"username": username, "auth_key": KEY}) + return {"id": uid, "name": username, "sk": sk, "pk": pk_to_b64(sk.public_key()), + "H": {"Authorization": f"Bearer {r.json()['access_token']}"}} + + +async def _group(client, owner, name="family"): + """A group that a node already hosts: `/mine` hides an unhosted group from + everyone but its owner, which would make "not in /mine" prove nothing.""" + from meshbay_hub.api.revocation import _mark_hosted + r = await client.post("/v1/groups", headers=owner["H"], + json={"name": name, "visibility": "private", "join_policy": "invite"}) + gid = r.json()["group_id"] + await _mark_hosted([gid]) + return gid + + +async def _node(client, user): + ts = int(time.time()) + msg = f"meshbay:node_announce:{user['id']}:{user['pk']}:{ts}".encode() + r = await client.post("/v1/nodes/announce", headers=user["H"], json={ + "pk_node": user["pk"], "timestamp": ts, + "signature": base64.b64encode(user["sk"].sign(msg)).decode()}) + assert r.status_code == 201, r.text + return r.json()["node_id"] + + +def _node_token(user): + from meshbay_hub.auth import issue_access_token + return issue_access_token(user["id"], scope="node") + + +async def _mine(client, user): + return [g["id"] for g in (await client.get("/v1/groups/mine", + headers=user["H"])).json()["groups"]] + + +# ── Invitations ────────────────────────────────────────────────────────────── + +async def test_being_added_is_an_invitation_not_a_membership(client): + owner, invitee = await _user(client, "inv_owner"), await _user(client, "inv_guest") + gid = await _group(client, owner) + + r = await client.post(f"/v1/groups/{gid}/members/{invitee['name']}", headers=owner["H"]) + assert r.json()["status"] == "invited" + + assert gid not in await _mine(client, invitee) + r = await client.get(f"/v1/groups/{gid}/nodes", headers=invitee["H"]) + assert r.status_code == 403, "an invitee must not be handed a node to dial" + listed = (await client.get("/v1/groups/invitations", headers=invitee["H"])).json() + assert [i["group_id"] for i in listed["invitations"]] == [gid] + + +async def test_accepting_makes_a_member_and_declining_leaves_nothing(client): + owner = await _user(client, "acc_owner") + yes, no = await _user(client, "acc_yes_user"), await _user(client, "acc_no_user") + gid = await _group(client, owner) + for u in (yes, no): + await client.post(f"/v1/groups/{gid}/members/{u['name']}", headers=owner["H"]) + + await accept_invitation(client, gid, yes["name"]) + r = await client.post(f"/v1/groups/{gid}/invitation/decline", headers=no["H"]) + assert r.status_code == 200 + + assert gid in await _mine(client, yes) + assert gid not in await _mine(client, no) + assert (await client.get("/v1/groups/invitations", headers=no["H"])).json()[ + "invitations"] == [] + r = await client.post(f"/v1/groups/{gid}/invitation/accept", headers=no["H"]) + assert r.status_code == 404, "a declined invitation cannot be accepted afterwards" + + +async def test_nobody_else_can_accept_an_invitation(client): + owner, invitee = await _user(client, "only_owner"), await _user(client, "only_guest") + other = await _user(client, "only_other") + gid = await _group(client, owner) + await client.post(f"/v1/groups/{gid}/members/{invitee['name']}", headers=owner["H"]) + r = await client.post(f"/v1/groups/{gid}/invitation/accept", headers=other["H"]) + assert r.status_code == 404 + assert gid not in await _mine(client, other) + + +async def test_the_owner_sees_and_can_take_back_an_invitation(client): + owner, invitee = await _user(client, "back_owner"), await _user(client, "back_guest") + member = await _user(client, "back_member") + gid = await _group(client, owner) + await client.post(f"/v1/groups/{gid}/members/{invitee['name']}", headers=owner["H"]) + await client.post(f"/v1/groups/{gid}/members/{member['name']}", headers=owner["H"]) + await accept_invitation(client, gid, member["name"]) + + seen = (await client.get(f"/v1/groups/{gid}/members", headers=owner["H"])).json() + assert [u["username"] for u in seen["invited"]] == [invitee["name"]] + # Another member is not told who was asked. + assert "invited" not in (await client.get(f"/v1/groups/{gid}/members", + headers=member["H"])).json() + + r = await client.delete(f"/v1/groups/{gid}/members/{invitee['name']}", + headers=owner["H"]) + assert r.status_code == 200 + assert (await client.get("/v1/groups/invitations", headers=invitee["H"])).json()[ + "invitations"] == [] + + +# ── Hosts ──────────────────────────────────────────────────────────────────── + +async def test_a_members_node_does_not_host_a_group_it_does_not_own(client, db_session): + from meshbay_hub.api.revocation import resolve_node_groups + + owner, member = await _user(client, "host_owner"), await _user(client, "host_member") + gid = await _group(client, owner) + await client.post(f"/v1/groups/{gid}/members/{member['name']}", headers=owner["H"]) + await accept_invitation(client, gid, member["name"]) + member_node = await _node(client, member) + owner_node = await _node(client, owner) + + assert await resolve_node_groups(member_node, member["id"], [gid]) == [] + assert await resolve_node_groups(owner_node, owner["id"], [gid]) == [gid] + + row = await db_session.get(GroupHost, (gid, member_node)) + assert row is not None and row.status == "pending" + notes = (await db_session.execute(select(Notification).where( + Notification.user_id == owner["id"], Notification.kind == "host_request"))).all() + assert len(notes) == 1 + + +async def test_the_owner_approves_a_host_and_can_take_it_back(client, db_session): + from meshbay_hub.api import revocation + + owner, member = await _user(client, "appr_owner"), await _user(client, "appr_member") + gid = await _group(client, owner) + await client.post(f"/v1/groups/{gid}/members/{member['name']}", headers=owner["H"]) + await accept_invitation(client, gid, member["name"]) + node = await _node(client, member) + await revocation.resolve_node_groups(node, member["id"], [gid]) + + # A connected node, as the socket handler records it. + revocation._connected_nodes[node] = object() + revocation._node_claims[node] = [gid] + revocation._node_users[node] = member["id"] + try: + # Not the member's call to make. + r = await client.post(f"/v1/groups/{gid}/hosts/{node}", headers=member["H"]) + assert r.status_code == 403 + + hosts = (await client.get(f"/v1/groups/{gid}/hosts", headers=owner["H"])).json() + assert [(h["node_id"], h["status"]) for h in hosts["hosts"]] == [(node, "pending")] + + r = await client.post(f"/v1/groups/{gid}/hosts/{node}", headers=owner["H"]) + assert r.status_code == 200 + assert revocation._node_groups[node] == [gid], "approval must apply at once" + + r = await client.delete(f"/v1/groups/{gid}/hosts/{node}", headers=owner["H"]) + assert r.status_code == 200 + assert revocation._node_groups[node] == [] + # Refused, and asking again does not notify the owner a second time. + await revocation.resolve_node_groups(node, member["id"], [gid]) + notes = (await db_session.execute(select(Notification).where( + Notification.user_id == owner["id"], Notification.kind == "host_request"))).all() + assert len(notes) == 1 + finally: + revocation.forget_node(node) + + +async def test_only_a_node_that_asked_can_be_approved(client): + owner, member = await _user(client, "ask_owner"), await _user(client, "ask_member") + gid = await _group(client, owner) + node = await _node(client, member) + r = await client.post(f"/v1/groups/{gid}/hosts/{node}", headers=owner["H"]) + assert r.status_code == 404 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 diff --git a/packages/meshbay-hub/tests/test_node_auth.py b/packages/meshbay-hub/tests/test_node_auth.py index 09816de..fb05f9b 100644 --- a/packages/meshbay-hub/tests/test_node_auth.py +++ b/packages/meshbay-hub/tests/test_node_auth.py @@ -11,6 +11,7 @@ import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from membership import add_member def _gen_ed25519(): @@ -166,8 +167,7 @@ async def test_node_token_may_add_a_member_to_its_own_operators_group(client): node_token = (await _node_auth(client, "op1_test", sk_op)).json()["access_token"] - r = await client.post(f"/v1/groups/{gid}/members/member1_test", - headers={"Authorization": f"Bearer {node_token}"}) + r = await add_member(client, gid, 'member1_test', {"Authorization": f"Bearer {node_token}"}) assert r.status_code == 201 # …but not to a group it does not own. @@ -175,7 +175,7 @@ async def test_node_token_may_add_a_member_to_its_own_operators_group(client): r = await client.post("/v1/groups", json={"name": "theirs", "visibility": "private"}, headers={"Authorization": f"Bearer {other_token}"}) other_gid = r.json()["group_id"] - r = await client.post(f"/v1/groups/{other_gid}/members/member1_test", + r = await client.post(f"/v1/groups/{other_gid}/members/member1_test", json={}, headers={"Authorization": f"Bearer {node_token}"}) assert r.status_code == 403 diff --git a/packages/meshbay-hub/tests/test_notifications_behaviour.py b/packages/meshbay-hub/tests/test_notifications_behaviour.py index 4684d3f..2d0b273 100644 --- a/packages/meshbay-hub/tests/test_notifications_behaviour.py +++ b/packages/meshbay-hub/tests/test_notifications_behaviour.py @@ -11,6 +11,7 @@ import base64 import hashlib import pytest +from membership import add_member from meshbay_hub.db.models import GroupMember, Notification, User from sqlalchemy import select @@ -40,8 +41,7 @@ async def test_chat_keeps_one_notification_per_group(client, db_session): g = await client.post("/v1/groups", json={"name": "busy"}, headers={"Authorization": f"Bearer {owner}"}) gid = g.json()["group_id"] - await client.post(f"/v1/groups/{gid}/members/listener", json={}, - headers={"Authorization": f"Bearer {owner}"}) + await add_member(client, gid, 'listener', {"Authorization": f"Bearer {owner}"}) uid = (await db_session.execute( select(User.id).where(User.username == "listener"))).scalar_one() @@ -72,8 +72,7 @@ async def test_muting_a_group_stops_notifications_being_created(client, db_sessi g = await client.post("/v1/groups", json={"name": "loud"}, headers={"Authorization": f"Bearer {owner}"}) gid = g.json()["group_id"] - await client.post(f"/v1/groups/{gid}/members/quiet_test", json={}, - headers={"Authorization": f"Bearer {owner}"}) + await add_member(client, gid, 'quiet_test', {"Authorization": f"Bearer {owner}"}) r = await client.post(f"/v1/groups/{gid}/mute", json={"muted": True}, headers={"Authorization": f"Bearer {token}"}) @@ -170,8 +169,7 @@ async def test_you_are_not_notified_of_your_own_message(client, db_session): headers={"Authorization": f"Bearer {owner}"}) gid = g.json()["group_id"] for name in ("chatty_test", "quiet_test"): - await client.post(f"/v1/groups/{gid}/members/{name}", json={}, - headers={"Authorization": f"Bearer {owner}"}) + await add_member(client, gid, name, {"Authorization": f"Bearer {owner}"}) ids = {u.username: u.id for u in (await db_session.execute( select(User).where(User.username.in_(["operator", "chatty_test", "quiet_test"])) -- cgit v1.2.3