aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/harness/group_hosts_probe.py175
-rw-r--r--packages/meshbay-hub/tests/harness/invitation_probe.py201
-rw-r--r--packages/meshbay-hub/tests/membership.py39
-rw-r--r--packages/meshbay-hub/tests/test_account_deletion.py4
-rw-r--r--packages/meshbay-hub/tests/test_admin_views.py3
-rw-r--r--packages/meshbay-hub/tests/test_availability_between_members.py6
-rw-r--r--packages/meshbay-hub/tests/test_group_description.py3
-rw-r--r--packages/meshbay-hub/tests/test_group_hosting.py7
-rw-r--r--packages/meshbay-hub/tests/test_group_leave_and_quota.py13
-rw-r--r--packages/meshbay-hub/tests/test_group_membership.py6
-rw-r--r--packages/meshbay-hub/tests/test_group_purge.py17
-rw-r--r--packages/meshbay-hub/tests/test_hub_api.py17
-rw-r--r--packages/meshbay-hub/tests/test_invitation_ui.py66
-rw-r--r--packages/meshbay-hub/tests/test_invitations_and_hosts.py204
-rw-r--r--packages/meshbay-hub/tests/test_mnp_token.py58
-rw-r--r--packages/meshbay-hub/tests/test_node_auth.py6
-rw-r--r--packages/meshbay-hub/tests/test_notifications_behaviour.py10
17 files changed, 787 insertions, 48 deletions
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"""<!doctype html><html><head><meta charset=utf-8>
+<link rel="stylesheet" href="/style.css"></head><body>
+<div id="root"></div>
+<script type="module">
+import { html, render } from '/vendor/htm-preact.js';
+import { initLocale } from '/i18n.js';
+import { GroupSettingsPanel } from '/group-settings.js';
+
+const realFetch = window.fetch.bind(window);
+const calls = [];
+const json = (body) => ({ ok: true, status: 200, statusText: '', headers: new Headers(),
+ json: async () => body, text: async () => JSON.stringify(body) });
+window.fetch = async (url, init = {}) => {
+ const u = String(url);
+ calls.push(`${init.method || 'GET'} ${u.replace(/^https?:\/\/[^/]+/, '')}`);
+ if (u.endsWith('/v1/groups/g1/members')) return json({
+ group_id: 'g1', admin_id: 'u1',
+ members: [{ user_id: 'u1', username: 'the-owner' }],
+ invited: [{ user_id: 'u9', username: 'someone_asked' }] });
+ if (u.endsWith('/v1/groups/g1/hosts')) return json({ hosts: [
+ { node_id: 'n-asking', pk_node: 'AAAA', username: 'a-member', status: 'pending',
+ online: true },
+ { node_id: 'n-ok', pk_node: 'BBBB', username: 'another-member', status: 'approved',
+ online: false }] });
+ return json({});
+};
+
+await initLocale();
+render(html`<${GroupSettingsPanel} groupId="g1" token="t" userId="u1"
+ group=${{ id: 'g1', name: 'a group', owner_username: 'the-owner', is_admin: true }}
+ transportRef=${{ current: null }} gekRef=${{ current: null }}
+ isNodeAdmin=${false} operatorPaired=${false} connected=${false}
+ enabledApps=${[]} entries=${[]} nodeDirs=${[]} />`, document.getElementById('root'));
+
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+const out = {};
+try {
+ await wait(1200);
+ const rows = [...document.querySelectorAll('.admin-table tr')].map((r) => r.innerText);
+ out.invited_row = rows.some((r) => r.includes('someone_asked'));
+ out.host_rows = rows.filter((r) => r.includes('AAAA') || r.includes('BBBB')).length;
+ const asking = [...document.querySelectorAll('.admin-table tr')]
+ .find((r) => r.innerText.includes('AAAA'));
+ out.buttons_on_request = asking ? asking.querySelectorAll('button').length : -1;
+ const approved = [...document.querySelectorAll('.admin-table tr')]
+ .find((r) => r.innerText.includes('BBBB'));
+ out.buttons_on_approved = approved ? approved.querySelectorAll('button').length : -1;
+ if (asking) { asking.querySelector('button').click(); await wait(800); }
+ out.decision = calls.filter((c) => c.includes('/hosts/'));
+} catch (e) {
+ out.error = String(e && e.stack || e);
+}
+realFetch('/log', { method: 'POST', body: JSON.stringify(out) });
+</script>__HOLD__</body></html>"""
+
+HOLD_TAG = '<img src="/hold" style="position:fixed;left:-4px;top:-4px;width:1px">'
+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"""<!doctype html><html><head><meta charset=utf-8></head><body>
+<div id="app"></div>
+<script type="module">
+const CASE = new URLSearchParams(location.search).get('case');
+const realFetch = window.fetch.bind(window);
+const post = (o) => realFetch('/log', { method: 'POST', body: JSON.stringify(o) });
+const calls = [];
+let accepted = false;
+const json = (body, status = 200) => ({
+ ok: status < 400, status, statusText: '', headers: new Headers(),
+ json: async () => body, text: async () => JSON.stringify(body),
+});
+const GROUP_ROW = { id: '__GROUP__', name: 'Some Group', owner_username: 'the-owner',
+ visibility: 'private', join_policy: 'invite', is_admin: false, muted: false,
+ created_at: '2026-09-30T00:00:00+00:00', last_activity_at: '2026-09-30T00:00:00+00:00',
+ hosted: true, node_online: false, description: '' };
+window.fetch = async (url, init = {}) => {
+ const u = String(url);
+ calls.push({ url: u, method: init.method || 'GET' });
+ if (u.includes('/v1/users/me/preferences')) return json({});
+ if (u.includes('/v1/users/me')) return json({ user_id: 'u-1', role: 'user' });
+ if (u.includes('/v1/groups/mine')) return json({ groups: accepted ? [GROUP_ROW] : [] });
+ if (u.includes('/v1/groups/invitations')) return json({ invitations: [{
+ group_id: '__GROUP__', name: 'Some Group', owner_username: 'the-owner',
+ invited_by: 'the-owner', created_at: '2026-09-30T00:00:00+00:00' }] });
+ if (u.includes('/invitation/accept')) { accepted = true; return json({ status: 'joined' }); }
+ if (u.includes('/invitation/decline')) return json({ status: 'declined' });
+ if (u.includes('/v1/notifications')) return json({ notifications: [], unread_count: 0 });
+ return json({});
+};
+localStorage.setItem('mb_auth', JSON.stringify({
+ username: 'invitee-account', userId: 'u-1', token: 'tok', refreshToken: 'ref',
+ role: 'user' }));
+history.replaceState(null, '', '/?case=' + CASE + '#/');
+
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+const text = () => document.getElementById('app').innerText;
+(async () => {
+ const out = { case: CASE };
+ try {
+ await import('/app.js');
+ await wait(1500);
+ out.listed = text().includes('Some Group');
+ // By position, not by label: the browser's language picks the label.
+ const buttons = [...document.querySelectorAll('.invite-links li button')];
+ out.buttons = buttons.length;
+ const button = buttons[CASE === 'accept' ? 0 : 1];
+ if (button) { button.click(); await wait(1200); }
+ out.answer_call = calls.filter((c) => c.url.includes('/invitation/'))
+ .map((c) => `${c.method} ${c.url.replace(/^https?:\/\/[^/]+/, '')}`);
+ out.mine_refetched = calls.filter((c) => c.url.includes('/v1/groups/mine')).length;
+ out.invitation_still_shown = document.querySelectorAll('.invite-links li').length > 0;
+ out.group_card = [...document.querySelectorAll('a.group-card')]
+ .some((a) => a.getAttribute('href') === '#/group/__GROUP__');
+ } catch (e) {
+ out.error = String(e && e.stack || e);
+ }
+ post(out);
+})();
+</script>__HOLD__</body></html>
+""".replace("__GROUP__", GROUP)
+
+HOLD_TAG = '<img src="/hold" style="position:fixed;left:-4px;top:-4px;width:1px">'
+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"]))