""" When a directory is read-only, the controls that write to it go — both of them. There are two ways to put a file into a group and they live 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 paperclip 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_root_writable_policy.py` and `test_security_regressions.py` in the node package. This is about not offering somebody a button whose only outcome is an error message. What the RO/RW refactor changed: there is no group-wide answer any more. Files uploads into *the root being browsed*, so its button follows that root's `writable`. Chat has no folder on screen, so the shell picks one for it. The two therefore read different things on purpose, and the tests below pin that each reads the right one — a stronger claim than the old "both read one boolean", which is why that assertion is gone rather than adapted. """ import re from pathlib import Path import pytest from spa_source import transport_source STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" 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(source: str, name: str) -> str: start = source.index(f"\nfunction {name}(") end = source.find("\nfunction ", start + 1) return source[start:end if end != -1 else len(source)] # ── Both controls ─────────────────────────────────────────────────────────── def test_the_files_toolbar_hides_its_upload_button(): """ Gated on the root being browsed, not on a group-wide answer: with one writable root and one read-only one, a single boolean would offer the button in both and produce a refusal in one of them. """ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel") toolbar = page[page.index("file-toolbar"):] toolbar = toolbar[:toolbar.index("breadcrumbs")] assert "currentRootWritable" in toolbar, ( "the Upload button is offered regardless of the directory's own flag") def test_the_files_upload_button_is_not_offered_at_the_top_of_a_group(): """ The top level is the set of roots, which is the operator's configuration and not a directory on anyone's disk. There is nothing to upload *into* there, and no root name to give the node. """ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel") toolbar = page[page.index("file-toolbar"):] toolbar = toolbar[:toolbar.index("breadcrumbs")] assert "currentPath &&" in toolbar def test_the_new_folder_button_follows_the_same_rule_as_upload(): """ Both write to the operator's disk, so both need a writable root — the node refuses either otherwise. It used to require `isNodeAdmin`, which contradicted the node ("a member who can add a file can organise where it goes") and hid the control from everyone who could have used it. """ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel") decl = page[page.index("const canCreateDir"):] decl = decl[:decl.index(";") + 1] assert "currentRootWritable" in decl assert "currentPath" in decl, ( "the top of a group is the set of roots, not a directory to create in") assert "isNodeAdmin" not in decl def test_an_icon_only_button_still_says_what_it_is(): """ The name moved into a tooltip to save toolbar width. A `title` is invisible to a screen reader on a button with no text, so the label has to be there as well — otherwise the control is simply unnamed for anyone not reading with their eyes. The same goes for the field it opens, which has a placeholder and no visible label. """ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel") opener = page[page.index("canCreateDir && newDirName === null"):] opener = opener[:opener.index("")] assert "title=" in opener and "aria-label=" in opener assert "group.mkdir" in opener field = page[page.index("canCreateDir && newDirName !== null"):] field = field[:field.index("")] assert "aria-label=" in field, ( "the name field is labelled by a placeholder alone, which a screen " "reader does not announce as a name") 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 "attachRoot ?" in composer, ( "the chat attachment is the second way in and is still offered") def test_the_paperclip_says_why_rather_than_vanishing(): """ A control that disappears leaves the reader no way to find out what would bring it back. A group with no writable directory is a state an operator can fix, so it is worth naming. """ chat = _component(CHAT_APP.read_text(encoding="utf-8"), "ChatPanel") composer = chat[chat.index("chat-input-row"):] assert "chat.attach_read_only" in composer # ── One derivation, in the shell ──────────────────────────────────────────── def test_the_attachment_directory_is_decided_once(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 attachRoot = ", app), ( "attachRoot is no longer derived in one place") props = app[app.index("const commonProps = {"):app.index("return html`")] assert "attachRoot," in props or "attachRoot:" in props, ( "attachRoot is not in the shared props object every app receives") def test_an_unavailable_root_is_not_offered_as_a_destination(app): """ `writable` is configuration and stays true while a drive is unplugged or ejected. Offering it anyway produces a refusal from the node with no explanation on screen. """ block = app[app.index("const writableRoots"):] block = block[:block.index("const attachRoot")] assert "available" in block def test_files_uploads_into_the_root_it_is_showing(): """ The client has to name the destination now, because the node cannot choose between several writable roots without guessing — and a guess here means a file landing in a directory nobody was looking at. """ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel") upload = page[page.index("const uploadFile"):] upload = upload[:upload.index("const makeDirectory")] assert "dir: uploadDir" in upload, "the node is left to choose the folder" assert "const uploadDir = currentPath" in upload, ( "the destination is not the folder on screen") assert "root: uploadRoot" in upload, ( "a node too old for `dir` reads `root`, and gets nothing without it") # ── 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 "if (indexMsg.roots) setNodeRoots(indexMsg.roots)" in app, ( "the roots table in the index payload is what carries this") idx = app.index("setNodeRoots(indexMsg.roots)") assert "hubFetch" not in app[idx - 400:idx] def test_there_is_no_group_wide_upload_flag_to_read(app): """ Whether a member may write is a property of each root, and the page must have no second source for it. A group-wide flag beside the per-root answer is a page that can show an Upload button the node will refuse, or hide one it would have allowed — and whichever of the two the code happens to consult first decides. """ assert "member_upload" not in app, ( "the page reads a group-wide upload flag again") assert "r.writable" in app or "writable" in app, ( "the page has to read the per-root answer from somewhere") def test_a_change_reaches_people_already_connected(app): """ The operator may be someone else entirely, ejecting a drive while you have the group open. A file list that survives until the next reconnection is a list somebody clicks. """ assert "transport.onRootsChanged" in app transport = transport_source() assert "root_eject_ack" in transport, "nothing routes the node's notice" def test_the_notice_also_answers_the_operators_own_request(): """ The same message is both a broadcast and the reply to the request that caused it. Every other admin ack can be resolved and dropped, because its caller already knows what it asked for and updates local state from that. These are the ones the node *broadcasts*: every other connected client learns the change from it, and the one that asked is the only one that does not, because its own request swallowed its copy. Found on the root table, then again on Chat's directory — where it meant the pane went on showing an unsaved-looking draft after a save that had worked. """ transport = transport_source() block = transport[transport.index("msg.type.endsWith('_ack')"):] block = block[:block.index("_uploaders")] assert "BROADCAST_ACK_TYPES" in block and "_replayBroadcast" in block, ( "the initiating client resolves the ack and learns nothing from it") # ── Changing it ───────────────────────────────────────────────────────────── def test_changing_a_root_is_signed(): transport = transport_source() for method in ("updateRoot", "ejectRoot", "plugRoot"): body = transport[transport.index(f"async {method}("):] body = body[:body.index("\n async ", 1)] assert "admin_challenge" in body and "_authorizeAdminOp" in body, ( f"{method} is unsigned — any member could use it") def test_only_the_operator_is_offered_the_setting(): panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), "GroupSettingsPanel") section = panel[panel.index("settings_node.shared_directories_title") - 600: panel.index("settings_node.shared_directories_title")] assert "isNodeAdmin &&" in section def test_the_operator_is_offered_it_on_the_web_too(): """ An operator is not necessarily sitting at their node. The first version of this section required the loopback API, which resolves to "not available" in a browser — so it rendered for nobody on the web, while the upload controls it replaced had worked there. """ source = GROUP_SETTINGS.read_text(encoding="utf-8") panel = _component(source, "GroupSettingsPanel") section = panel[panel.index("settings_node.shared_directories_title") - 600: panel.index("settings_node.shared_directories_title")] assert "connected ||" in section, ( "the shared directories section still requires a local node") table = _component(source, "SharedDirectoriesTable") for call in ("transport.updateRoot", "transport.ejectRoot", "transport.plugRoot", "transport.removeRoot", "transport.addRoot"): assert call in table, f"{call} has no MNP route from the table" def test_the_roots_shown_come_from_the_live_connection_when_there_is_one(): """ The loopback list is a second source, and the two drift: it is read once on mount and after a change, while the MNP one is pushed. Preferring MNP also keeps this table on the same data Files reads, so an eject shows in both at the same instant. """ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), "GroupSettingsPanel") assert "const effectiveRoots = (connected && mnpRoots" in panel