aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_audio_root_policy.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-24 23:34:57 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-24 23:34:57 +0200
commitaeeaf0d537a1942010caf222b72333841b80b7f8 (patch)
treec1db2dab14a56e68edcf73de146cb52c1159fa1a /packages/meshbay-node/tests/test_audio_root_policy.py
parent1e6abbb8db76cdabd53b85d938ee0e76486f5ae4 (diff)
downloadmeshbay-aeeaf0d537a1942010caf222b72333841b80b7f8.tar.gz
test(node): a real signed audio_root save through the full challenge path
Every existing audio_root test either called ops.set_audio_root directly or mocked out _issue_admin_challenge — none of them exercised real signature verification, _do_admin_response, or the shared groups_ctx/ roster wiring _run_op depends on. Worth ruling out a break somewhere in that real path specifically: a report described a save that looked like it worked (the Music tab showed content right after) not surviving a reload. Drives the real _do_audio_root -> admin_challenge -> sign -> _do_admin_response -> _admin_exec_audio_root path with a genuine Ed25519 operator key, then opens a *separate* Roster instance against the same db file — the direct question a "worked, then reverted" report raises: does the value actually land durably, in a form any later connection reads back correctly. It does; this passes. The one thing missing from the session fixture to get this far was peer-registry self-registration (a real session adds itself on handshake completion — without it, the final ack has nowhere to go, including back to the requester).
Diffstat (limited to 'packages/meshbay-node/tests/test_audio_root_policy.py')
-rw-r--r--packages/meshbay-node/tests/test_audio_root_policy.py126
1 files changed, 123 insertions, 3 deletions
diff --git a/packages/meshbay-node/tests/test_audio_root_policy.py b/packages/meshbay-node/tests/test_audio_root_policy.py
index 73bbe1f..576c08a 100644
--- a/packages/meshbay-node/tests/test_audio_root_policy.py
+++ b/packages/meshbay-node/tests/test_audio_root_policy.py
@@ -6,15 +6,18 @@ group_settings table, added later once a real messy library showed
musicbay.md's original "no root, whole shared tree" call was wrong.
"""
+import base64
from pathlib import Path
import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-from meshbay_common.adminop import OP_AUDIO_ROOT
+from meshbay_common.adminop import OP_AUDIO_ROOT, admin_transcript
+from meshbay_common.crypto import pk_to_b64
+from meshbay_common.join import ROLE_OPERATOR
from meshbay_node.indexer.group_index import GroupIndex
-from meshbay_node.roster import Roster
+from meshbay_node.roster import Roster, open_roster
from meshbay_node.transport.webrtc_server import WebRTCPeerSession
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from conftest import one_root
@@ -135,3 +138,120 @@ async def test_the_setting_lives_on_the_node_and_survives_a_restart(tmp_path):
assert await reopened.audio_root("g2") == "", "one group's setting must not answer for another"
finally:
await reopened.close()
+
+
+# ── End to end through the real signed-op path ──────────────────────────────
+#
+# Everything above either calls ops.set_audio_root directly or mocks out
+# _issue_admin_challenge — neither one ever exercises real signature
+# verification (_verify_admin_sig, _do_admin_response) or the shared
+# groups_ctx/roster wiring _run_op depends on. Found live: a save that
+# looked like it worked (the Music tab showed content right afterward) did
+# not survive a reload — worth ruling out a break somewhere in that real
+# path specifically, not just in the pure-Python setter. Session shape
+# mirrors test_admin_ops_mnp.py's _session helper.
+
+GROUP = "g" * 32
+
+
+def _keypair():
+ sk = Ed25519PrivateKey.generate()
+ return sk, pk_to_b64(sk.public_key())
+
+
+async def _full_session(tmp_path: Path, roster) -> tuple[WebRTCPeerSession, Ed25519PrivateKey]:
+ shared = tmp_path / "shared"
+ (shared / "Music").mkdir(parents=True)
+ index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
+ roots = one_root(shared)
+
+ sk_op, pk_op = _keypair()
+ 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", "audio_root": ""}
+ state = {
+ "groups_ctx": {GROUP: group_ctx},
+ "roster": roster,
+ "node_user_id": "node-user",
+ }
+
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = {
+ "roots": roots, "index": index, "sk_node": index.sk_node,
+ "roster": roster, "groups": {GROUP: group_ctx},
+ "has_admin_authority": True,
+ "daemon_state": state,
+ }
+ session._group_id = GROUP
+ session._user_id = "grenet"
+ session._pk_user = ""
+ session._admin_ops = {}
+ session.sent = []
+ session._send = session.sent.append
+ session._audit = lambda *a, **k: None
+ session.spawned = []
+ session._spawn = session.spawned.append
+ # A real session registers itself here on handshake completion
+ # (`self._peer_registry()[self._user_id] = self`) — without it, the
+ # broadcast loop in _admin_exec_audio_root (and every other admin op)
+ # has nobody to send the final ack to, including the requester itself.
+ group_ctx["_peers"] = {"grenet": session}
+ session._peer_registry = lambda: group_ctx["_peers"]
+ return session, sk_op
+
+
+async def _drain(session):
+ for coro in session.spawned:
+ await coro
+ session.spawned.clear()
+
+
+async def test_a_real_signed_save_persists_and_survives_a_fresh_roster_read(tmp_path):
+ """
+ The exact question a "worked, then reverted after reload" report raises:
+ does the value set through the real challenge/response path actually
+ land in the database, in a form any later connection — this one, or a
+ freshly-opened Roster after a restart — reads back correctly?
+ """
+ roster = await open_roster(tmp_path)
+ try:
+ session, sk_op = await _full_session(tmp_path, roster)
+
+ session._do_audio_root({"path": "shared/Music"})
+ challenge = session.sent[-1]
+ assert challenge["type"] == "admin_challenge", challenge
+
+ transcript = admin_transcript(
+ op=OP_AUDIO_ROOT, node_pk_b64=session._node_pk_b64(), group_id=GROUP,
+ subject="shared/Music", nonce=base64.b64decode(challenge["nonce"]),
+ ts=challenge["ts"])
+ session._do_admin_response({
+ "op_id": challenge["op_id"],
+ "signature": base64.b64encode(sk_op.sign(transcript)).decode(),
+ })
+ await _drain(session)
+
+ ack = session.sent[-1]
+ assert ack["type"] == "audio_root_ack", ack
+ assert ack["path"] == "shared/Music"
+
+ assert session._ctx["groups"][GROUP]["audio_root"] == "shared/Music", (
+ "the live in-memory context must reflect the new root immediately")
+ assert await roster.audio_root(GROUP) == "shared/Music", (
+ "the same Roster instance must read back what it just wrote")
+ finally:
+ await roster.close()
+
+ # A fresh connection (or a restarted daemon) never touches the Roster
+ # instance above at all — it opens its own. This is the check that
+ # actually answers "does it survive a reload".
+ reopened = await open_roster(tmp_path)
+ try:
+ assert await reopened.audio_root(GROUP) == "shared/Music", (
+ "a freshly-opened Roster against the same db file must see the "
+ "committed value — anything else means the write was never "
+ "durable in the first place")
+ finally:
+ await reopened.close()