diff options
Diffstat (limited to 'packages/meshbay-node/tests/test_app_directories.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_app_directories.py | 104 |
1 files changed, 92 insertions, 12 deletions
diff --git a/packages/meshbay-node/tests/test_app_directories.py b/packages/meshbay-node/tests/test_app_directories.py index 3ede1b6..69b3be5 100644 --- a/packages/meshbay-node/tests/test_app_directories.py +++ b/packages/meshbay-node/tests/test_app_directories.py @@ -26,10 +26,14 @@ from types import SimpleNamespace import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_common.adminop import OP_APP_DIRECTORIES from meshbay_node import ops from meshbay_node.indexer.group_index import GroupIndex from meshbay_node.roots import RootSet from meshbay_node.roster import Roster +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +from conftest import one_root pytestmark = pytest.mark.asyncio @@ -157,11 +161,11 @@ async def test_a_directory_on_an_unplugged_drive_can_still_be_configured(tmp_pat # ── The derived scalar ─────────────────────────────────────────────────────── -async def test_the_legacy_scalar_follows_the_list_in_the_live_context(tmp_path): +async def test_the_list_is_what_the_live_context_carries(tmp_path): """ - `video_root` rides on the handshake ack for MNP 1.0 clients and is read - from the group context. Left behind by a save, it would disagree with the - list until the next restart. + One name per app in the group context, and it is the list. A second name + for the same idea is a second thing a save has to update, and the one it + forgets disagrees until the next restart. """ state, roster = await _state(tmp_path) ctx = state["groups_ctx"][GROUP] @@ -169,26 +173,33 @@ async def test_the_legacy_scalar_follows_the_list_in_the_live_context(tmp_path): await ops.set_app_directories(state, GROUP, "video", ["Media/Films", "Media/Albums"]) assert ctx["video_directories"] == ["Media/Albums", "Media/Films"] - assert ctx["video_root"] == "Media/Albums", ( - "the scalar must be the first of the list, not a stale value") + assert "video_root" not in ctx, ( + "a scalar nothing reads is a scalar that can go stale unnoticed") await ops.set_app_directories(state, GROUP, "video", []) - assert ctx["video_root"] == "" + assert ctx["video_directories"] == [] finally: await roster.close() -async def test_the_photo_alias_stays_a_list_and_chat_stays_a_string(tmp_path): - """The alias table has to carry the shape, not just the name.""" +async def test_chat_is_also_published_under_the_name_the_ack_uses(tmp_path): + """ + Chat has one destination and the handshake ack publishes it as + `chat_directory`, which the paperclip reads. That second name is derived + from the list on every save, never stored beside it. + """ state, roster = await _state(tmp_path) ctx = state["groups_ctx"][GROUP] try: - await ops.set_app_directories(state, GROUP, "photo", - ["Media/Films", "Media/Albums"]) - assert ctx["photo_roots"] == ["Media/Albums", "Media/Films"] await ops.set_app_directory(state, GROUP, "chat", "Media", require_writable=True) assert ctx["chat_directory"] == "Media" + assert ctx["chat_directories"] == ["Media"] + + await ops.set_app_directories(state, GROUP, "photo", + ["Media/Films", "Media/Albums"]) + assert ctx["photo_directories"] == ["Media/Albums", "Media/Films"] + assert "photo_roots" not in ctx finally: await roster.close() @@ -290,3 +301,72 @@ async def test_setting_link_previews_updates_the_live_context(tmp_path): assert state["groups_ctx"][GROUP]["chat_link_preview"] is False finally: await roster.close() + + +# ── The handler, before any signature ──────────────────────────────────────── +# +# `_do_app_directories` refuses three things up front and asks for a signature +# for everything else. The paths are not among the three: `_validate_app_dirs` +# checks those after the signature, deliberately (a settings change is not a +# capability), and `test_app_directories_signed.py` drives that whole path. + +def _handler_session(tmp_path, *, authorized: bool) -> WebRTCPeerSession: + shared = tmp_path / "shared" + (shared / "Films").mkdir(parents=True, exist_ok=True) + index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = {"roots": one_root(shared), "index": index, + "sk_node": index.sk_node} + session._group_id = GROUP + session._user_id = "op" + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + session._has_admin_authority = lambda: authorized + session.issued = [] + session._issue_admin_challenge = lambda op, subject: session.issued.append( + (op, subject)) + return session + + +async def test_an_app_this_node_does_not_know_is_refused(tmp_path): + """A client-supplied key is otherwise a way to write arbitrary rows into + `group_settings`.""" + session = _handler_session(tmp_path, authorized=True) + session._do_app_directories({"app": "../etc", "directories": ["shared"]}) + assert session.issued == [] + assert [m for m in session.sent if m.get("type") == "error"] + + +@pytest.mark.parametrize("dirs", [None, "shared/Films", [1], {"a": 1}]) +async def test_directories_that_are_not_a_list_of_strings_are_refused(tmp_path, dirs): + session = _handler_session(tmp_path, authorized=True) + session._do_app_directories({"app": "video", "directories": dirs}) + assert session.issued == [] + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path): + """No operator key on this node's roster means no signature can ever + arrive, so asking for one would be a dialog that cannot be completed.""" + session = _handler_session(tmp_path, authorized=False) + session._do_app_directories({"app": "video", "directories": ["shared/Films"]}) + assert session.issued == [] + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_the_subject_names_the_app_and_the_cleaned_paths(tmp_path): + """What the operator is shown before signing has to say which application + is about to be pointed where — two apps' challenges are otherwise + indistinguishable — and it must match what the node will store.""" + session = _handler_session(tmp_path, authorized=True) + session._do_app_directories( + {"app": "video", "directories": ["/b/", "a", "a", ""]}) + assert session.issued == [(OP_APP_DIRECTORIES, "video:a,b")] + + +async def test_an_empty_set_is_signable(tmp_path): + """Clearing an app's folders is an instruction like any other.""" + session = _handler_session(tmp_path, authorized=True) + session._do_app_directories({"app": "video", "directories": []}) + assert session.issued == [(OP_APP_DIRECTORIES, "video:")] |