aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_node_status.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/tests/test_node_status.py')
-rw-r--r--packages/meshbay-node/tests/test_node_status.py305
1 files changed, 38 insertions, 267 deletions
diff --git a/packages/meshbay-node/tests/test_node_status.py b/packages/meshbay-node/tests/test_node_status.py
index bd63f8e..45a9710 100644
--- a/packages/meshbay-node/tests/test_node_status.py
+++ b/packages/meshbay-node/tests/test_node_status.py
@@ -1,24 +1,19 @@
"""
-node_status, root_add, root_remove over MNP.
+The node management panel's server-side behaviour.
-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.
+The `_is_node_admin` hint, root removal over MNP, and the operations the Node
+page and the CLI reach over loopback: listing groups, adding and updating
+roots, the roster, the denylist and unpinning. Their own MNP messages
+(`node_status`, `root_add`, `roster_read`, …) are gone since MNP 6.0.
"""
-import base64
from dataclasses import asdict
from pathlib import Path
import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-from meshbay_common.adminop import (
- OP_MEMBER_UNPIN,
- admin_transcript,
-)
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.roots import RootSet
@@ -133,19 +128,6 @@ async def _session(
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):
@@ -167,62 +149,6 @@ async def test_is_node_admin_rejects_missing_node_user_id(tmp_path, roster):
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")
- 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):
@@ -266,7 +192,7 @@ async def test_add_root_creates_directory_and_returns_info(tmp_path):
conf = tmp_path / "node.toml"
conf.write_text(f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n'
- f' [[groups.roots]]\n path = "{shared}"\n', encoding="utf-8")
+ f' [[groups.roots]]\n path = "{shared.as_posix()}"\n', encoding="utf-8")
node_cfg = NodeConfig.__new__(NodeConfig)
node_cfg.groups = [cfg]
@@ -308,7 +234,7 @@ async def test_remove_root_requires_at_least_one_remaining(tmp_path):
conf = tmp_path / "node.toml"
conf.write_text(f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n'
- f' [[groups.roots]]\n path = "{shared}"\n', encoding="utf-8")
+ f' [[groups.roots]]\n path = "{shared.as_posix()}"\n', encoding="utf-8")
index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
roots = one_root(shared)
state = {
@@ -343,8 +269,9 @@ async def test_removing_a_writable_root_is_allowed(tmp_path):
conf = tmp_path / "node.toml"
conf.write_text(
f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n'
- f' [[groups.roots]]\n path = "{d1}"\n name = "incoming"\n writable = true\n\n'
- f' [[groups.roots]]\n path = "{d2}"\n name = "shared"\n', encoding="utf-8")
+ f' [[groups.roots]]\n path = "{d1.as_posix()}"\n name = "incoming"\n'
+ ' writable = true\n\n'
+ f' [[groups.roots]]\n path = "{d2.as_posix()}"\n name = "shared"\n', encoding="utf-8")
roots = RootSet.build([asdict(r) for r in cfg.roots])
index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
state = {
@@ -380,7 +307,7 @@ async def test_update_root_rewrites_the_flags_in_node_toml(tmp_path):
f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n'
f' [[groups.roots]]\n'
f' # the operator explained this one to themselves\n'
- f' path = "{d1}"\n name = "media"\n', encoding="utf-8")
+ f' path = "{d1.as_posix()}"\n name = "media"\n', encoding="utf-8")
roots = RootSet.build([asdict(r) for r in cfg.roots])
index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
state = {
@@ -429,7 +356,7 @@ async def test_update_root_replaces_a_legacy_upload_line(tmp_path):
conf = tmp_path / "node.toml"
conf.write_text(
f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n'
- f' [[groups.roots]]\n path = "{d1}"\n name = "media"\n'
+ f' [[groups.roots]]\n path = "{d1.as_posix()}"\n name = "media"\n'
f' upload = true\n', encoding="utf-8")
roots = RootSet.build([asdict(r) for r in cfg.roots])
index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
@@ -462,8 +389,8 @@ async def test_remove_root_succeeds_with_two_roots(tmp_path):
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', encoding="utf-8")
+ f' [[groups.roots]]\n path = "{d1.as_posix()}"\n name = "dir1"\n\n'
+ f' [[groups.roots]]\n path = "{d2.as_posix()}"\n name = "dir2"\n', encoding="utf-8")
roots = RootSet.build([asdict(r) for r in cfg.roots])
index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
state = {
@@ -478,32 +405,6 @@ async def test_remove_root_succeeds_with_two_roots(tmp_path):
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):
@@ -529,52 +430,32 @@ async def test_root_remove_refuses_missing_fields(tmp_path, roster):
assert "Missing" in _last(session)["detail"]
-# ── roster_read MNP handler ──────────────────────────────────────────────
+# ── The roster, the denylist and unpinning (loopback, CLI) ──────────────────
+#
+# Their MNP messages are gone (MNP 6.0, no client sent them); the operations are
+# what the Node page and the CLI call.
-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):
+async def test_read_roster_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()
+ _, 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()
+ _, 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
+ result = await ops.read_roster(session.state, GROUP)
+ assert "identities" in result
+ assert not [m for m in result["members"] if m["user_id"] == "ghost"], (
+ "ghost members must be filtered out")
+ assert len([m for m in result["members"] if m["user_id"] == "real"]) == 1
async def test_unpin_fails_for_ghost_member(tmp_path, roster):
@@ -582,145 +463,35 @@ async def test_unpin_fails_for_ghost_member(tmp_path, roster):
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"
+ with pytest.raises(ops.OpError, match="No such pinned identity"):
+ await ops.unpin_member(session.state, "ghost")
- 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):
+async def test_read_denylist_lists_entries(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"]
+ result = await ops.read_denylist(session.state)
+ assert result["count"] == 1
+ assert "bad-user" in result["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):
+async def test_clear_denylist_removes_one_entry(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
+ result = await ops.clear_denylist(session.state, subject="bad-user")
+ assert result["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):
+async def test_clear_denylist_all(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"
+ result = await ops.clear_denylist(session.state, subject="")
+ assert result["removed"] == 3