""" When the operator closes uploading, the controls go — both of them. There are two ways to put a file into a group and they are in different components: the Upload button in the Files toolbar, and the paperclip in the chat composer. Hiding one and forgetting the other is the obvious mistake, and the second one is the easier to forget because it does not look like an upload. Nothing here is a security property. **The node refuses the upload** — that is `test_member_upload_policy.py` in the node package. This is about not offering somebody a button whose only outcome is an error message. """ import re from pathlib import Path import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" # The group-page refactor split what used to be one app.js into one file per # "application" plus the group shell. mayUpload itself is still derived once, # in the shell (group-page.js) — Files and Chat each moved to their own file # and receive it as a prop, the same shape ChatPanel already took. APP = STATIC / "app.js" GROUP_PAGE = STATIC / "group-page.js" FILES_APP = STATIC / "files-app.js" CHAT_APP = STATIC / "chat-app.js" GROUP_SETTINGS = STATIC / "group-settings.js" TRANSPORT = STATIC / "transport.js" pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable") @pytest.fixture(scope="module") def app() -> str: return GROUP_PAGE.read_text(encoding="utf-8") def _component(app: str, name: str) -> str: start = app.index(f"\nfunction {name}(") end = app.find("\nfunction ", start + 1) return app[start:end if end != -1 else len(app)] # ── Both controls ─────────────────────────────────────────────────────────── def test_the_files_toolbar_hides_its_upload_button(): page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel") toolbar = page[page.index("file-toolbar"):] toolbar = toolbar[:toolbar.index("group.mkdir")] assert "mayUpload &&" in toolbar, "the Upload button is offered regardless" def test_the_chat_composer_hides_its_paperclip(): chat = _component(CHAT_APP.read_text(encoding="utf-8"), "ChatPanel") composer = chat[chat.index("chat-input-row"):] assert "mayUpload &&" in composer, ( "the chat attachment is the second way in and is still offered") def test_both_read_the_same_answer(app): """Two derivations would eventually disagree, and the disagreement would be one of them offering an upload the node refuses.""" assert re.search(r"const mayUpload = memberUpload \|\| isNodeAdmin;", app), ( "mayUpload is no longer derived in one place") # Files and Chat both receive it from the same `commonProps` object the # shell spreads into whichever app tab is active — one derivation feeding # one object, rather than two hand-written prop attributes that could # drift apart. props = app[app.index("const commonProps = {"):app.index("return html`")] assert "mayUpload," in props or "mayUpload:" in props, ( "mayUpload is not in the shared props object every app receives") def test_the_operator_keeps_their_own_controls(app): assert "memberUpload || isNodeAdmin" in app, ( "turning uploads off would hide the operator's own upload button") # ── Learning the answer ───────────────────────────────────────────────────── def test_the_answer_comes_from_the_node(app): """Not from the hub, which has no say in what may be written to someone else's disk, and no way to be believed about it.""" assert "ack.member_upload !== false" in app, ( "the handshake ack is what carries this") assert "hubFetch" not in app[app.index("ack.member_upload") - 400: app.index("ack.member_upload")] def test_an_older_node_is_treated_as_permissive(app): """A node that predates the setting sends no such field. Reading a missing field as "off" would close every group on the older half of the network.""" assert "!== false" in app[app.index("ack.member_upload"): app.index("ack.member_upload") + 60] def test_a_change_reaches_people_already_connected(app): """The operator may be someone else entirely, changing it while you have the group open. A button that survives until the next reconnection is a button somebody presses.""" assert "transport.onUploadPolicy" in app transport = TRANSPORT.read_text(encoding="utf-8") assert "member_upload_ack" in transport, "nothing routes the node's notice" def test_the_notice_still_answers_the_operators_own_request(app): """The same message is both a broadcast and the reply to the request that caused it — returning early on it would leave that request hanging until it timed out.""" transport = TRANSPORT.read_text(encoding="utf-8") # Scoped to member_upload_ack's own handler, not everything up to the next # occurrence of "index_sync" — other handlers with their own, legitimate # early `return` (index_progress, set_scan_settings_ack: neither is ever a # reply anyone awaits) now sit between the two in the file. block = transport[transport.index("member_upload_ack"):] block = block[:block.index("apps_enabled_ack")] assert "return" not in block # ── Changing it ───────────────────────────────────────────────────────────── def test_changing_it_is_signed(app): transport = TRANSPORT.read_text(encoding="utf-8") method = transport[transport.index("async setMemberUpload("):] method = method[:method.index("\n async ", 1)] assert "admin_challenge" in method and "_authorizeAdminOp" in method, ( "an unsigned instruction would let any member turn uploads back on") def test_only_the_operator_is_offered_the_setting(): panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), "GroupSettingsPanel") section = panel[panel.index("members.uploads_title") - 400: panel.index("members.uploads_title")] assert "isNodeAdmin && connected" in section