summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-20 08:56:23 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-20 08:56:23 +0200
commitc8af746c846b5dbc792f7e4f0d806647d513cc5c (patch)
tree56c54ec5c703ec042589697b22b53e42451a61e4 /packages/meshbay-node/tests
parent90c3c6a01f46c9b29c5d92702d7383f32e8951d7 (diff)
downloadmeshbay-c8af746c846b5dbc792f7e4f0d806647d513cc5c.tar.gz
feat(node): full Node admin panel — CLI parity, hot-reload, group lifecycle
Node admin panel (NodePage) now covers every CLI operation over MNP: group attach/detach, roster, member unpin, GEK rotate, denylist, reload. Daemon hot-loads new groups and tears down removed ones on config reload instead of requiring a full restart. Group attach/detach via MNP or local API triggers an automatic reload so the group is live immediately. Fixed GroupPage hang on first visit to a newly created group: the JWT issued at login didn't include the new group, the node rejected with not_a_member, and the token-refresh path returned without re-triggering the connect effect (Boolean(token) didn't change). Now bumps retryKey after a successful refresh so the effect re-runs with the fresh token. NodePage marks groups hosted by the node but absent from the hub with a "not on hub" badge so stale groups are visible and easy to remove. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/tests')
-rw-r--r--packages/meshbay-node/tests/test_cli_dispatch.py1
-rw-r--r--packages/meshbay-node/tests/test_node_status.py625
2 files changed, 626 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py
index faca0ce..62dfe13 100644
--- a/packages/meshbay-node/tests/test_cli_dispatch.py
+++ b/packages/meshbay-node/tests/test_cli_dispatch.py
@@ -40,6 +40,7 @@ VERBS = [
["denylist", "show"],
["denylist", "clear", "--yes"],
["reload"],
+ ["restart-daemon"],
]
diff --git a/packages/meshbay-node/tests/test_node_status.py b/packages/meshbay-node/tests/test_node_status.py
new file mode 100644
index 0000000..b56eb6e
--- /dev/null
+++ b/packages/meshbay-node/tests/test_node_status.py
@@ -0,0 +1,625 @@
+"""
+node_status, root_add, root_remove over MNP.
+
+These test the D5 node management panel's server-side behaviour: the admin
+identity check on node_status, the list_groups operation, and the root
+add/remove flows through the MNP handlers.
+"""
+
+import base64
+from dataclasses import asdict
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from conftest import one_root
+from meshbay_common.adminop import (
+ OP_ROOT_ADD, OP_ROOT_REMOVE, OP_GROUP_ATTACH, OP_MEMBER_UNPIN,
+ admin_transcript,
+)
+from meshbay_node.transport.quic_server import Denylist
+from meshbay_common.crypto import pk_to_b64
+from meshbay_common.join import ROLE_OPERATOR
+from meshbay_common.protocol import MNP
+from meshbay_node import ops
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.roster import open_roster
+from meshbay_node.roots import RootSet
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+GROUP = "g" * 32
+
+
+@pytest.fixture
+async def roster(tmp_path):
+ r = await open_roster(tmp_path)
+ yield r
+ await r.close()
+
+
+def _keypair():
+ sk = Ed25519PrivateKey.generate()
+ return sk, pk_to_b64(sk.public_key())
+
+
+class _FakeBundleStore:
+ def __init__(self):
+ self.stored = []
+
+ async def store(self, *args):
+ self.stored.append(args)
+
+
+class _FakeHub:
+ class _S:
+ user_id = "node-user"
+ _session = _S()
+
+
+def _last(session):
+ return session.sent[-1] if session.sent else {}
+
+
+async def _drain(session):
+ for coro in session.spawned:
+ await coro
+ session.spawned.clear()
+
+
+async def _session(
+ tmp_path: Path, roster, *, operator: bool, node_user_id: str = "node-user",
+) -> WebRTCPeerSession:
+ shared = tmp_path / "shared"
+ shared.mkdir(exist_ok=True)
+ index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
+ roots = one_root(shared)
+
+ sk_op, pk_op = _keypair()
+ if operator:
+ await roster.pin_identity("grenet", "grenet", pk_op, pk_op, "code")
+ await roster.set_member("", "grenet", ROLE_OPERATOR, "active", "local-cli")
+
+ group_ctx = {"gek": b"\x01" * 32, "roots": roots, "index": index,
+ "join_policy": "invite"}
+ denylist = Denylist()
+ reload_called = []
+ async def _reload():
+ reload_called.append(True)
+
+ state = {
+ "groups_ctx": {GROUP: group_ctx},
+ "roster": roster,
+ "indexes": {GROUP: index},
+ "bundle_store": _FakeBundleStore(),
+ "pk_x25519_raw": b"\x02" * 32,
+ "hub": _FakeHub(),
+ "node_user_id": node_user_id,
+ "denylist": denylist,
+ "reload_fn": _reload,
+ }
+
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = {
+ "roots": roots, "index": index, "sk_node": index.sk_node,
+ "roster": roster, "groups": {GROUP: group_ctx},
+ "has_admin_authority": operator,
+ "daemon_state": state,
+ "node_user_id": node_user_id,
+ }
+ session._group_id = GROUP
+ session._user_id = "grenet" if operator else "mallory"
+ session._username = session._user_id
+ session._pk_user = ""
+ session._uploads = {}
+ session._admin_ops = {}
+ session._remote_ip = ""
+ session.sent = []
+ session._send = session.sent.append
+ session._audit = lambda *a, **k: None
+ session.state = state
+ session.sk_op = sk_op
+ session.denylist = denylist
+ session.reload_called = reload_called
+ session.spawned = []
+ session._spawn = session.spawned.append
+ return session
+
+
+async def _sign_and_exec(session, op: str, subject: str, sk, exec_fn,
+ group_id: str = GROUP):
+ challenge = _last(session)
+ assert challenge["type"] == "admin_challenge", challenge
+ transcript = admin_transcript(
+ op=op, node_pk_b64=session._node_pk_b64(), group_id=group_id,
+ subject=subject, nonce=base64.b64decode(challenge["nonce"]),
+ ts=challenge["ts"])
+ pending = session._admin_ops.get(challenge["op_id"]) or {
+ "op": op, "subject": subject}
+ await exec_fn(pending, transcript, sk.sign(transcript))
+
+
+# ── _is_node_admin ──────────────────────────────────────────────────────────
+
+async def test_is_node_admin_matches_user_id(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=True,
+ node_user_id="grenet")
+ assert session._is_node_admin()
+
+
+async def test_is_node_admin_rejects_different_user(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=True,
+ node_user_id="someone-else")
+ assert not session._is_node_admin()
+
+
+async def test_is_node_admin_rejects_missing_node_user_id(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=True,
+ node_user_id="grenet")
+ del session._ctx["node_user_id"]
+ assert not session._is_node_admin()
+
+
+# ── node_status ─────────────────────────────────────────────────────────────
+
+async def test_node_status_returns_groups_for_admin(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=True,
+ node_user_id="grenet")
+ session._spawn(session._do_node_status({}))
+ await _drain(session)
+ msg = _last(session)
+ assert msg["type"] == MNP.NODE_STATUS_ACK
+ assert len(msg["groups"]) == 1
+ assert msg["groups"][0]["id"] == GROUP
+
+
+async def test_node_status_refused_for_non_admin(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=False,
+ node_user_id="grenet")
+ session._spawn(session._do_node_status({}))
+ await _drain(session)
+ msg = _last(session)
+ assert msg["type"] == "error"
+ assert "operator" in msg["detail"].lower()
+
+
+async def test_node_status_refused_when_user_is_operator_but_ids_mismatch(
+ tmp_path, roster,
+):
+ """A paired operator who is not the node owner cannot see node_status."""
+ session = await _session(tmp_path, roster, operator=True,
+ node_user_id="someone-else")
+ session._spawn(session._do_node_status({}))
+ await _drain(session)
+ msg = _last(session)
+ assert msg["type"] == "error"
+
+
+async def test_node_status_catches_send_failure(tmp_path, roster):
+ """If _send itself throws (e.g. msgpack encoding fails), the error must
+ not silently vanish — it used to, because _send was outside the try block."""
+ session = await _session(tmp_path, roster, operator=True,
+ node_user_id="grenet")
+ original_send = session._send
+ sent = []
+ call_count = [0]
+
+ def _exploding_send(msg):
+ call_count[0] += 1
+ if msg.get("type") == "node_status_ack":
+ raise TypeError("msgpack cannot encode this")
+ sent.append(msg)
+
+ session._send = _exploding_send
+ session._spawn(session._do_node_status({}))
+ await _drain(session)
+ # The try/except around _send should catch the error and send an error reply
+ assert any(m.get("type") == "error" for m in sent)
+
+
+# ── ops.list_groups ─────────────────────────────────────────────────────────
+
+async def test_list_groups_returns_group_metadata(tmp_path):
+ shared = tmp_path / "shared"
+ shared.mkdir()
+ index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
+ roots = one_root(shared)
+ state = {
+ "groups_ctx": {GROUP: {"index": index, "roots": roots, "gek": b"\x01" * 32}},
+ "peers": {"p1": {"group_id": GROUP}, "p2": {"group_id": GROUP},
+ "p3": {"group_id": "other"}},
+ "config": None,
+ }
+ result = await ops.list_groups(state)
+ groups = result["groups"]
+ assert len(groups) == 1
+ g = groups[0]
+ assert g["id"] == GROUP
+ assert g["has_gek"] is True
+ assert g["peers"] == 2
+ assert isinstance(g["roots"], list)
+
+
+async def test_list_groups_empty(tmp_path):
+ state = {"groups_ctx": {}, "peers": {}, "config": None}
+ result = await ops.list_groups(state)
+ assert result["groups"] == []
+
+
+# ── ops.add_root ────────────────────────────────────────────────────────────
+
+async def test_add_root_creates_directory_and_returns_info(tmp_path):
+ shared = tmp_path / "shared"
+ shared.mkdir()
+ new_dir = tmp_path / "new_root"
+
+ from meshbay_node.config import NodeConfig, GroupConfig, RootSpec
+ cfg = GroupConfig(id=GROUP, name="test", roots=[
+ RootSpec(path=str(shared), name="shared", kind="generic", upload=True),
+ ])
+
+ conf = tmp_path / "node.toml"
+ conf.write_text(f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n'
+ f' [[groups.roots]]\n path = "{shared}"\n')
+ node_cfg = NodeConfig.__new__(NodeConfig)
+ node_cfg.groups = [cfg]
+
+ index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
+ roots = one_root(shared)
+ state = {
+ "config": node_cfg,
+ "config_path": str(conf),
+ "groups_ctx": {GROUP: {"index": index, "roots": roots, "gek": b"\x01" * 32}},
+ }
+
+ result = await ops.add_root(state, GROUP, str(new_dir))
+ assert result["status"] == "added"
+ assert new_dir.is_dir()
+ assert len(result["roots"]) == 2
+
+
+async def test_add_root_rejects_unknown_group(tmp_path):
+ from meshbay_node.config import NodeConfig
+ node_cfg = NodeConfig.__new__(NodeConfig)
+ node_cfg.groups = []
+ state = {"config": node_cfg, "groups_ctx": {}}
+ with pytest.raises(ops.OpError, match="not configured"):
+ await ops.add_root(state, "nonexistent", "/tmp/nope")
+
+
+# ── ops.remove_root ─────────────────────────────────────────────────────────
+
+async def test_remove_root_requires_at_least_one_remaining(tmp_path):
+ shared = tmp_path / "shared"
+ shared.mkdir()
+
+ from meshbay_node.config import GroupConfig, RootSpec, NodeConfig
+ cfg = GroupConfig(id=GROUP, name="test", roots=[
+ RootSpec(path=str(shared), name="shared", kind="generic", upload=True),
+ ])
+ node_cfg = NodeConfig.__new__(NodeConfig)
+ node_cfg.groups = [cfg]
+
+ conf = tmp_path / "node.toml"
+ conf.write_text(f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n'
+ f' [[groups.roots]]\n path = "{shared}"\n')
+ index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
+ roots = one_root(shared)
+ state = {
+ "config": node_cfg,
+ "config_path": str(conf),
+ "groups_ctx": {GROUP: {"index": index, "roots": roots, "gek": b"\x01" * 32}},
+ }
+ with pytest.raises(ops.OpError, match="only root"):
+ await ops.remove_root(state, GROUP, "shared")
+
+
+async def test_remove_root_refuses_upload_root(tmp_path):
+ d1 = tmp_path / "uploads"
+ d2 = tmp_path / "shared"
+ d1.mkdir()
+ d2.mkdir()
+
+ from meshbay_node.config import GroupConfig, RootSpec, NodeConfig
+ cfg = GroupConfig(id=GROUP, name="test", roots=[
+ RootSpec(path=str(d1), name="uploads", kind="generic", upload=True),
+ RootSpec(path=str(d2), name="shared", kind="generic", upload=False),
+ ])
+ node_cfg = NodeConfig.__new__(NodeConfig)
+ node_cfg.groups = [cfg]
+
+ conf = tmp_path / "node.toml"
+ conf.write_text(
+ f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n'
+ f' [[groups.roots]]\n path = "{d1}"\n name = "uploads"\n upload = true\n\n'
+ f' [[groups.roots]]\n path = "{d2}"\n name = "shared"\n')
+ roots = RootSet.build([asdict(r) for r in cfg.roots])
+ index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
+ state = {
+ "config": node_cfg,
+ "config_path": str(conf),
+ "groups_ctx": {GROUP: {"index": index, "roots": roots, "gek": b"\x01" * 32}},
+ }
+ with pytest.raises(ops.OpError, match="upload root"):
+ await ops.remove_root(state, GROUP, "uploads")
+
+
+async def test_remove_root_succeeds_with_two_roots(tmp_path):
+ d1 = tmp_path / "dir1"
+ d2 = tmp_path / "dir2"
+ d1.mkdir()
+ d2.mkdir()
+
+ from meshbay_node.config import GroupConfig, RootSpec, NodeConfig
+ cfg = GroupConfig(id=GROUP, name="test", roots=[
+ RootSpec(path=str(d1), name="dir1", kind="generic", upload=True),
+ RootSpec(path=str(d2), name="dir2", kind="generic", upload=False),
+ ])
+ node_cfg = NodeConfig.__new__(NodeConfig)
+ node_cfg.groups = [cfg]
+
+ conf = tmp_path / "node.toml"
+ conf.write_text(
+ f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n'
+ f' [[groups.roots]]\n path = "{d1}"\n name = "dir1"\n\n'
+ f' [[groups.roots]]\n path = "{d2}"\n name = "dir2"\n')
+ roots = RootSet.build([asdict(r) for r in cfg.roots])
+ index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
+ state = {
+ "config": node_cfg,
+ "config_path": str(conf),
+ "groups_ctx": {GROUP: {"index": index, "roots": roots, "gek": b"\x01" * 32}},
+ }
+
+ result = await ops.remove_root(state, GROUP, "dir2")
+ assert result["status"] == "removed"
+ assert len(cfg.roots) == 1
+ assert cfg.roots[0].name == "dir1"
+
+
+# ── root_add MNP handler ───────────────────────────────────────────────────
+
+async def test_root_add_issues_challenge(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=True,
+ node_user_id="grenet")
+ session._do_root_add({"group_id": GROUP, "path": "/tmp/test"})
+ msg = _last(session)
+ assert msg["type"] == "admin_challenge"
+
+
+async def test_root_add_refuses_without_authority(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=False)
+ session._do_root_add({"group_id": GROUP, "path": "/tmp/test"})
+ msg = _last(session)
+ assert msg["type"] == "error"
+ assert "authorized" in msg["detail"].lower()
+
+
+async def test_root_add_refuses_missing_fields(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=True,
+ node_user_id="grenet")
+ session._do_root_add({"group_id": GROUP})
+ assert _last(session)["type"] == "error"
+ assert "Missing" in _last(session)["detail"]
+
+
+# ── root_remove MNP handler ────────────────────────────────────────────────
+
+async def test_root_remove_issues_challenge(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=True,
+ node_user_id="grenet")
+ session._do_root_remove({"group_id": GROUP, "root_name": "shared"})
+ msg = _last(session)
+ assert msg["type"] == "admin_challenge"
+
+
+async def test_root_remove_refuses_without_authority(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=False)
+ session._do_root_remove({"group_id": GROUP, "root_name": "shared"})
+ msg = _last(session)
+ assert msg["type"] == "error"
+
+
+async def test_root_remove_refuses_missing_fields(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=True,
+ node_user_id="grenet")
+ session._do_root_remove({"group_id": GROUP})
+ assert _last(session)["type"] == "error"
+ assert "Missing" in _last(session)["detail"]
+
+
+# ── roster_read MNP handler ──────────────────────────────────────────────
+
+async def test_roster_read_returns_members_for_admin(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=True,
+ node_user_id="grenet")
+ session._spawn(session._do_roster_read({"group_id": GROUP}))
+ await _drain(session)
+ msg = _last(session)
+ assert msg["type"] == "roster_read_ack"
+ assert "members" in msg
+ assert "identities" in msg
+
+
+async def test_roster_read_refused_for_non_admin(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=False,
+ node_user_id="grenet")
+ session._spawn(session._do_roster_read({"group_id": GROUP}))
+ await _drain(session)
+ msg = _last(session)
+ assert msg["type"] == "error"
+
+
+async def test_roster_read_filters_ghost_members(tmp_path, roster):
+ """Members whose identity was deleted (revoked then unpinned) are filtered
+ out by read_roster — the LEFT JOIN returns them with pk_ed25519 = NULL but
+ they should never reach the UI."""
+ session = await _session(tmp_path, roster, operator=True,
+ node_user_id="grenet")
+ sk2, pk2 = _keypair()
+ await roster.pin_identity("ghost", "ghost", pk2, pk2, "code")
+ await roster.set_member(GROUP, "ghost", "member", "revoked", "local-cli")
+ await roster._db.execute("DELETE FROM identities WHERE user_id = 'ghost'")
+ await roster._db.commit()
+ # Also add a real member so the roster isn't empty
+ sk3, pk3 = _keypair()
+ await roster.pin_identity("real", "real", pk3, pk3, "code")
+ await roster.set_member(GROUP, "real", "member", "active", "local-cli")
+
+ session._spawn(session._do_roster_read({"group_id": GROUP}))
+ await _drain(session)
+ msg = _last(session)
+ assert msg["type"] == "roster_read_ack"
+ ghost = [m for m in msg["members"] if m["user_id"] == "ghost"]
+ assert len(ghost) == 0, "ghost members must be filtered out"
+ real = [m for m in msg["members"] if m["user_id"] == "real"]
+ assert len(real) == 1
+
+
+async def test_unpin_fails_for_ghost_member(tmp_path, roster):
+ """Unpinning a member with no identity gives a clear error, not a crash."""
+ session = await _session(tmp_path, roster, operator=True,
+ node_user_id="grenet")
+ await roster.set_member(GROUP, "ghost", "member", "revoked", "local-cli")
+ # ghost has no identity row
+ session._do_member_unpin({"user_id": "ghost"})
+ challenge = _last(session)
+ assert challenge["type"] == "admin_challenge"
+
+ await _sign_and_exec(session, OP_MEMBER_UNPIN, "ghost",
+ session.sk_op, session._admin_exec_member_unpin)
+ msg = _last(session)
+ assert msg["type"] == "error"
+ assert "No such pinned identity" in msg["detail"]
+
+
+async def test_unpin_succeeds_for_real_identity(tmp_path, roster):
+ """Full unpin flow: challenge → sign → exec → identity deleted."""
+ session = await _session(tmp_path, roster, operator=True,
+ node_user_id="grenet")
+ sk2, pk2 = _keypair()
+ await roster.pin_identity("target", "target", pk2, pk2, "code")
+ await roster.set_member(GROUP, "target", "member", "active", "local-cli")
+
+ session._do_member_unpin({"user_id": "target"})
+ challenge = _last(session)
+ assert challenge["type"] == "admin_challenge"
+
+ await _sign_and_exec(session, OP_MEMBER_UNPIN, "target",
+ session.sk_op, session._admin_exec_member_unpin)
+ msg = _last(session)
+ assert msg["type"] == "member_unpin_ack"
+ assert msg["user_id"] == "target"
+
+ idents = await roster.list_identities()
+ assert not any(i["user_id"] == "target" for i in idents)
+
+
+# ── denylist_read MNP handler ────────────────────────────────────────────
+
+async def test_denylist_read_returns_entries_for_admin(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=True,
+ node_user_id="grenet")
+ session.denylist.deny_user("bad-user")
+ session._spawn(session._do_denylist_read({}))
+ await _drain(session)
+ msg = _last(session)
+ assert msg["type"] == "denylist_read_ack"
+ assert msg["count"] == 1
+ assert "bad-user" in msg["users"]
+
+
+async def test_denylist_read_refused_for_non_admin(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=False,
+ node_user_id="grenet")
+ session._spawn(session._do_denylist_read({}))
+ await _drain(session)
+ msg = _last(session)
+ assert msg["type"] == "error"
+
+
+# ── denylist_clear MNP handler ───────────────────────────────────────────
+
+async def test_denylist_clear_removes_entry_for_admin(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=True,
+ node_user_id="grenet")
+ session.denylist.deny_user("bad-user")
+ session.denylist.deny_user("other-user")
+ session._spawn(session._do_denylist_clear({"subject": "bad-user"}))
+ await _drain(session)
+ msg = _last(session)
+ assert msg["type"] == "denylist_clear_ack"
+ assert msg["removed"] == 1
+ assert "bad-user" not in session.denylist.entries()["users"]
+ assert "other-user" in session.denylist.entries()["users"]
+
+
+async def test_denylist_clear_all_for_admin(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=True,
+ node_user_id="grenet")
+ session.denylist.deny_user("a")
+ session.denylist.deny_user("b")
+ session.denylist.deny_jti("j")
+ session._spawn(session._do_denylist_clear({"subject": ""}))
+ await _drain(session)
+ msg = _last(session)
+ assert msg["type"] == "denylist_clear_ack"
+ assert msg["removed"] == 3
+
+
+async def test_denylist_clear_refused_for_non_admin(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=False,
+ node_user_id="grenet")
+ session._spawn(session._do_denylist_clear({"subject": "bad-user"}))
+ await _drain(session)
+ msg = _last(session)
+ assert msg["type"] == "error"
+
+
+# ── group_attach MNP handler ────────────────────────────────────────────
+
+async def test_group_attach_issues_challenge(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=True,
+ node_user_id="grenet")
+ session._do_group_attach({"name": "test-group", "shared_dir": "/tmp/share"})
+ msg = _last(session)
+ assert msg["type"] == "admin_challenge"
+
+
+async def test_group_attach_refused_without_authority(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=False)
+ session._do_group_attach({"name": "test-group", "shared_dir": "/tmp/share"})
+ msg = _last(session)
+ assert msg["type"] == "error"
+ assert "authorized" in msg["detail"].lower()
+
+
+async def test_group_attach_refuses_missing_fields(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=True,
+ node_user_id="grenet")
+ session._do_group_attach({"name": "test-group"})
+ msg = _last(session)
+ assert msg["type"] == "error"
+ assert "Missing" in msg["detail"]
+
+
+# ── node_reload MNP handler ─────────────────────────────────────────────
+
+async def test_node_reload_runs_for_admin(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=True,
+ node_user_id="grenet")
+ session._spawn(session._do_node_reload({}))
+ await _drain(session)
+ msg = _last(session)
+ assert msg["type"] == "node_reload_ack"
+ assert msg["status"] == "reloaded"
+ assert len(session.reload_called) == 1
+
+
+async def test_node_reload_refused_for_non_admin(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=False,
+ node_user_id="grenet")
+ session._spawn(session._do_node_reload({}))
+ await _drain(session)
+ msg = _last(session)
+ assert msg["type"] == "error"