diff options
Diffstat (limited to 'packages/meshbay-node/tests/test_app_directories_signed.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_app_directories_signed.py | 148 |
1 files changed, 148 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_app_directories_signed.py b/packages/meshbay-node/tests/test_app_directories_signed.py new file mode 100644 index 0000000..eda09d8 --- /dev/null +++ b/packages/meshbay-node/tests/test_app_directories_signed.py @@ -0,0 +1,148 @@ +""" +Pointing an application at folders, through the real signed-op path. + +`app_directories` is one operator instruction for every application, keyed by +the app's own name. What only this file can check is the path from the message +to the database: everything else either calls `ops.set_app_directories` +directly or mocks out `_issue_admin_challenge`, and neither one exercises real +signature verification (`_verify_admin_sig`, `_do_admin_response`) or the shared +groups_ctx/roster wiring `_run_op` depends on. + +Found live, on the per-app op this replaced: a save that looked like it worked — +the Music tab showed content right afterwards — did not survive a reload. Worth +ruling out a break in that real path specifically, and not just in the setter. + +Note what is deliberately *not* checked before the challenge: whether the path +exists. `_do_app_directories` validates the app name and the shape of +`directories`, then asks for a signature; `ops._validate_app_dirs` refuses a +path outside the group's roots afterwards. A settings change is not a +capability, so refusing after the signature costs a round trip and nothing else. +""" + +import base64 +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from meshbay_common.adminop import OP_APP_DIRECTORIES, 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 open_roster +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +from conftest import one_root + +pytestmark = pytest.mark.asyncio + +# 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", "music_directories": []} + 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_app_directories( + {"app": "music", "directories": ["shared/Music"]}) + challenge = session.sent[-1] + assert challenge["type"] == "admin_challenge", challenge + + transcript = admin_transcript( + op=OP_APP_DIRECTORIES, node_pk_b64=session._node_pk_b64(), + group_id=GROUP, subject="music: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"] == "app_directories_ack", ack + assert ack["app"] == "music" + assert ack["directories"] == ["shared/Music"] + + assert (session._ctx["groups"][GROUP]["music_directories"] + == ["shared/Music"]), ( + "the live in-memory context must reflect the new folder at once") + assert await roster.app_directories(GROUP, "music") == ["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.app_directories(GROUP, "music") == ["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() |