summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_roster_pairing.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 02:15:02 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 02:15:02 +0200
commite9d5e979fdab9a1cc3c729d602e6f27207b9480c (patch)
treeb5993f2c81b760ba56f251457edf84dd91ad63dc /packages/meshbay-node/tests/test_roster_pairing.py
parent50ebb4f2e620dad8e1fbca8307b97c5e10e7e6c0 (diff)
downloadmeshbay-e9d5e979fdab9a1cc3c729d602e6f27207b9480c.tar.gz
feat(node): several named roots per group, and one implementation per operation
Stage A — a group's content is a set of named roots --------------------------------------------------- `shared_dir` becomes a list of {name, path, kind}. The name is the directory's basename, derived once at add time and *stored*: recomputing it would re-identify a whole library the day someone renames a folder on disk. Duplicate names are refused case-insensitively and no root may contain another — both compared with NFC folding, because most of these directories live on exFAT or NTFS where `Films` and `films` are one directory. Every index path carries its root name, in a one-root group as much as in a five-root one. One path shape has to be got right once; two have to be kept right for ever. **A root that goes away freezes; it never empties.** Unmounting a volume makes watchdog report every file under it as deleted, or presents an empty directory to the next scan. Acting on either propagates deletions for a whole library to every member, as though the owner had erased it. So a deletion is acted on only once its root is confirmed readable, and availability is tracked per root — one unplugged drive leaves the others serving. 12 tests, verified to fail against an indexer without the check. Events are not trusted to be complete either: ReadDirectoryChangesW drops them under load and inotify on a FUSE mount misses changes made outside it. A periodic reconciliation sweep is the only thing that recovers a missed event. MNP 0.2 → 0.3 (additive). The hub needs no change: SwarmSource carries a content hash, a node id and an endpoint — no paths, no filenames — and private groups register nothing (H7). Stage B — one implementation behind every front door ---------------------------------------------------- C1 and C6 were both "a second path into the node with its own weaker handshake". Two implementations of `revoke` with two authorization checks is that shape one size down. `meshbay_node/ops.py` holds each operation once, takes the daemon state, and knows nothing about HTTP, argv or MNP. The loopback API is one `_op(...)` line per endpoint; the MNP handlers call the same functions. test_ops.py asserts the shape rather than trusting it. Phase 14 is finished on top of it — `group list`, `gek init|rotate`, `reload` (SIGHUP), `denylist show|clear`, `file list|rm`. **No operator action requires a browser any more.** Plus `gek_rotate` and `member_unpin` as operator-signed MNP operations: rotation is the half of revocation that revocation cannot do, since the ex-member holds the current key, and the node generates the replacement with its own CSPRNG — no key material crosses the wire, which is what the C5b rule is actually about. Two bugs found by running it rather than by testing it ------------------------------------------------------ GroupIndex is keyed by **content hash**, so the same bytes at two paths are one entry — which is also why a scan reports ten files and indexes nine. Reconciliation compared paths, so it decided the second path was a missed event every 60 s, rewrote the entry and pushed an index update to every connected peer. Seen in a live node's log. `meshbay-node reload` crashed on first use with `subprocess` unimported: the module compiles fine, which is the "syntax, not names" trap already recorded for the SPA. test_cli_dispatch.py now walks every verb and refuses to let one be added to the parser without an entry there. Also corrected: protocol.py declared a second MNP_VERSION of "0.1" while the wire carried "0.2" — harmless only because nothing imported it. And _do_dir_create/_do_dir_delete referenced an undefined `filename` on their error path. 740 tests pass; QE/deploy/e2e.py passes end to end against the live deployment. 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.py41
1 files changed, 27 insertions, 14 deletions
diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py
index 435cc76..9e45dbc 100644
--- a/packages/meshbay-node/tests/test_roster_pairing.py
+++ b/packages/meshbay-node/tests/test_roster_pairing.py
@@ -22,6 +22,7 @@ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from meshbay_common.crypto import generate_gek, pk_to_b64, unwrap_gek_aes
from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR, join_transcript
from meshbay_node.indexer.group_index import GroupIndex
+from conftest import one_root
from meshbay_node.roster import Roster, hash_code, normalize_code
from meshbay_node.transport.webrtc_server import WebRTCPeerSession
@@ -65,7 +66,7 @@ def _session(tmp_path: Path, roster, user_id: str = "grenet",
session = WebRTCPeerSession.__new__(WebRTCPeerSession)
session._ctx = {
- "shared_root": shared_root,
+ "roots": one_root(shared_root),
"index": index,
"sk_node": index.sk_node,
"roster": roster,
@@ -74,7 +75,7 @@ def _session(tmp_path: Path, roster, user_id: str = "grenet",
session._ctx["groups"] = {
group_id: {
"gek": gek,
- "shared_root": shared_root,
+ "roots": one_root(shared_root),
"index": index,
"join_policy": join_policy,
},
@@ -599,7 +600,7 @@ async def test_revoke_endpoint_stops_authorization(tmp_path, roster):
resp = client.post(f"/api/members/bob/revoke?group_id={GROUP}&t=tok")
assert resp.status_code == 200
- assert "gek-init" in resp.json()["reminder"], (
+ assert "gek rotate" in resp.json()["reminder"], (
"revocation must remind the operator to rotate the key they still hold")
assert not await roster.is_authorized(GROUP, "bob")
@@ -823,7 +824,7 @@ async def test_a_directory_with_anything_in_it_is_refused(tmp_path, roster):
full.mkdir()
(full / "keep.txt").write_text("still here")
- session._do_dir_delete({"dir": "full"})
+ session._do_dir_delete({"dir": "shared/full"})
assert _last(session).get("detail") == "Directory is not empty"
assert full.exists() and (full / "keep.txt").exists()
@@ -835,15 +836,23 @@ async def test_no_challenge_is_issued_without_an_operator(tmp_path, roster):
session._ctx["has_admin_authority"] = False
(tmp_path / "shared" / "empty").mkdir()
- session._do_dir_delete({"dir": "empty"})
+ session._do_dir_delete({"dir": "shared/empty"})
assert _last(session).get("detail") == "No authorized key for deletion"
assert (tmp_path / "shared" / "empty").exists()
-async def test_the_shared_root_itself_is_not_a_target(tmp_path, roster):
+async def test_a_root_itself_is_not_a_target(tmp_path, roster):
+ """
+ Neither the virtual root nor a root directory can be removed this way.
+
+ Removing a root is a configuration change: doing it through a file operation
+ would leave the group config naming a directory nobody can reach. And the
+ virtual root is not a directory on anyone's disk at all — it belongs to no
+ volume.
+ """
session = await _dir_session(tmp_path, roster)
- for attempt in ("", ".", "/", "../shared"):
+ for attempt in ("", ".", "/", "../shared", "shared", "shared/", "SHARED"):
session._do_dir_delete({"dir": attempt})
assert _last(session).get("type") == "error", f"{attempt!r} was accepted"
assert (tmp_path / "shared").is_dir()
@@ -854,7 +863,11 @@ async def test_escaping_the_shared_root_is_refused(tmp_path, roster):
outside = tmp_path / "outside"
outside.mkdir()
- for attempt in ("../outside", "../../outside", "sub/../../outside"):
+ # Both shapes: a path that names no root at all, and one that starts inside
+ # a real root and then climbs out of it.
+ for attempt in ("../outside", "../../outside", "sub/../../outside",
+ "shared/../outside", "shared/../../outside",
+ "shared/sub/../../outside"):
session._do_dir_delete({"dir": attempt})
assert _last(session).get("type") == "error", f"{attempt!r} was accepted"
assert outside.is_dir(), "a path leaving the shared root removed a directory"
@@ -871,19 +884,19 @@ async def test_an_empty_directory_needs_a_signature_and_then_goes(tmp_path, rost
session = await _dir_session(tmp_path, roster)
(tmp_path / "shared" / "gone").mkdir()
- session._do_dir_delete({"dir": "gone"})
+ session._do_dir_delete({"dir": "shared/gone"})
challenge = _last(session)
assert challenge["type"] == "admin_challenge"
assert challenge["op"] == "dir_delete"
- assert challenge["subject"] == "gone"
+ assert challenge["subject"] == "shared/gone"
transcript = admin_transcript(
op="dir_delete", node_pk_b64=session._node_pk_b64(), group_id="g1",
- subject="gone", nonce=base64.b64decode(challenge["nonce"]),
+ subject="shared/gone", nonce=base64.b64decode(challenge["nonce"]),
ts=challenge["ts"])
await session._admin_exec_dir_delete(
session._admin_ops.pop(challenge["op_id"]) if session._admin_ops
- else {"op": "dir_delete", "subject": "gone"},
+ else {"op": "dir_delete", "subject": "shared/gone"},
transcript, sk_ed.sign(transcript))
assert _last(session)["type"] == "dir_delete_ack"
@@ -906,9 +919,9 @@ async def test_someone_elses_signature_does_not_remove_it(tmp_path, roster):
transcript = admin_transcript(
op="dir_delete", node_pk_b64=session._node_pk_b64(), group_id="g1",
- subject="theirs", nonce=b"\x22" * 32, ts=int(time.time()))
+ subject="shared/theirs", nonce=b"\x22" * 32, ts=int(time.time()))
await session._admin_exec_dir_delete(
- {"op": "dir_delete", "subject": "theirs"},
+ {"op": "dir_delete", "subject": "shared/theirs"},
transcript, sk_member.sign(transcript))
assert _last(session).get("detail") == "Signature verification failed"