summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_roster_pairing.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-15 17:23:10 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-15 17:23:10 +0200
commitdd3927a661273734493f65a593755b95aecf5f09 (patch)
treeb40b9a0939e79c65990f4664211025b032c9bafa /packages/meshbay-node/tests/test_roster_pairing.py
parent4066c754a613deb965472853fe69727d68be593e (diff)
downloadmeshbay-dd3927a661273734493f65a593755b95aecf5f09.tar.gz
feat(groups): remove a member, and keep gigabytes out of the tab
**Removing a member.** The owner can do it from the Members tab, and it is two halves in the order that fails safe: the node stops serving the group key first (an operator-signed request, so a paired browser only), then the hub drops the membership row. The other order would leave someone able to reach a node that still serves them. It is a membership, not an account. The user row is never written: their other groups, their files and their pinned identity survive, because one group's owner must not be able to erase someone from the hub. It is also per group — a node hosting two loses them from one — and it does not take back the key they already unwrapped, which is what rotating the GEK is for. The confirmation and the panel both say so. **Downloads and streaming through the disk, in both browsers.** The audit this started as found two ways to put gigabytes in a tab. Firefox and Safari have no File System Access API, so every download there was collected in memory. A service worker fixes it: the page keeps the writable half of a transferred stream, the worker answers a made-up URL with the readable half and a Content-Disposition header, and the browser writes it to disk as it arrives, with real backpressure. The worker caches nothing and falls through on every request that is not one of these downloads. A zip announces no Content-Length, since the archive is larger than the files in it and a length we miss truncates the file. Video was worse and affected both browsers. The node pushed ffmpeg's whole output as fast as it was produced while the player consumed a segment at a time, so the queue held the film — and appending all of it hit the SourceBuffer's cap, where the handler logged the error and dropped the segment, leaving a hole in the middle of the film with nothing to show for it. Streaming is credit-based now, 24 segments of 256 KB in flight, verified against the live node: three credits, three segments, then silence until more are granted. The player evicts what is more than a minute behind the playhead and retries a refused segment rather than dropping it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/tests/test_roster_pairing.py')
-rw-r--r--packages/meshbay-node/tests/test_roster_pairing.py82
1 files changed, 82 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py
index 88e426d..435cc76 100644
--- a/packages/meshbay-node/tests/test_roster_pairing.py
+++ b/packages/meshbay-node/tests/test_roster_pairing.py
@@ -914,3 +914,85 @@ async def test_someone_elses_signature_does_not_remove_it(tmp_path, roster):
assert _last(session).get("detail") == "Signature verification failed"
assert (tmp_path / "shared" / "theirs").is_dir(), (
"a member's signature removed a directory — only the operator may")
+
+
+# ── Removing a member ────────────────────────────────────────────────────────
+
+async def test_revoking_needs_an_operator_signature(tmp_path, roster):
+ from meshbay_common.adminop import admin_transcript
+
+ sk_op, pk_op, pk_x_op = _keypair()
+ await roster.pin_identity("grenet", "grenet", pk_op, pk_x_op, "code")
+ await roster.set_member("", "grenet", ROLE_OPERATOR, "active", "local-cli")
+
+ sk_m, pk_m, pk_x_m = _keypair()
+ await roster.pin_identity("victim", "victim", pk_m, pk_x_m, "code")
+ await roster.set_member("g1", "victim", ROLE_MEMBER, "active", "grenet")
+
+ session = _session(tmp_path, roster, group_id="g1", gek=generate_gek())
+ session._admin_ops = {}
+ session._ctx["has_admin_authority"] = True
+ session._ctx["peers"] = {}
+
+ transcript = admin_transcript(
+ op="member_revoke", node_pk_b64=session._node_pk_b64(), group_id="g1",
+ subject="victim", nonce=b"\x33" * 32, ts=int(time.time()))
+
+ # A member's own signature is not enough.
+ await session._admin_exec_member_revoke(
+ {"op": "member_revoke", "subject": "victim"}, transcript,
+ sk_m.sign(transcript))
+ assert _last(session).get("detail") == "Signature verification failed"
+ assert (await roster.get_member("g1", "victim"))["status"] == "active"
+
+ # The operator's is.
+ await session._admin_exec_member_revoke(
+ {"op": "member_revoke", "subject": "victim"}, transcript,
+ sk_op.sign(transcript))
+ assert _last(session)["type"] == "member_revoke_ack"
+ assert (await roster.get_member("g1", "victim"))["status"] == "revoked"
+
+
+async def test_revoking_is_confined_to_the_group_it_was_asked_for(tmp_path, roster):
+ """
+ A node hosting two groups must not lose someone from both. Their pinned
+ identity survives as well — forgetting a key is `member unpin`, and saying
+ "remove them" should not silently do it.
+ """
+ from meshbay_common.adminop import admin_transcript
+
+ sk_op, pk_op, pk_x_op = _keypair()
+ await roster.pin_identity("grenet", "grenet", pk_op, pk_x_op, "code")
+ await roster.set_member("", "grenet", ROLE_OPERATOR, "active", "local-cli")
+
+ _, pk_m, pk_x_m = _keypair()
+ await roster.pin_identity("both", "both", pk_m, pk_x_m, "code")
+ await roster.set_member("g1", "both", ROLE_MEMBER, "active", "grenet")
+ await roster.set_member("g2", "both", ROLE_MEMBER, "active", "grenet")
+
+ session = _session(tmp_path, roster, group_id="g1", gek=generate_gek())
+ session._admin_ops = {}
+ session._ctx["has_admin_authority"] = True
+ session._ctx["peers"] = {}
+
+ transcript = admin_transcript(
+ op="member_revoke", node_pk_b64=session._node_pk_b64(), group_id="g1",
+ subject="both", nonce=b"\x44" * 32, ts=int(time.time()))
+ await session._admin_exec_member_revoke(
+ {"op": "member_revoke", "subject": "both"}, transcript,
+ sk_op.sign(transcript))
+
+ assert (await roster.get_member("g1", "both"))["status"] == "revoked"
+ assert (await roster.get_member("g2", "both"))["status"] == "active"
+ assert await roster.get_identity("both") is not None, (
+ "the pinned identity was dropped; that is `member unpin`, not this")
+
+
+async def test_an_operator_cannot_revoke_themselves(tmp_path, roster):
+ """It would leave the group with nobody able to invite or remove."""
+ session = _session(tmp_path, roster, group_id="g1", gek=generate_gek())
+ session._admin_ops = {}
+ session._ctx["has_admin_authority"] = True
+
+ session._do_member_revoke({"user_id": session._user_id})
+ assert _last(session).get("detail") == "Cannot revoke yourself"