aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_upload_controls_hidden.py
blob: f6f476e341fc9b8935f11acb496c6927ea6c4a33 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
"""
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

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("</button>")]
    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("</span>")]
    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_an_older_node_is_treated_as_permissive(app):
    """
    A node speaking MNP 1.0 sends roots with no `writable` at all, plus the old
    group-wide flag. Reading a missing field as "read-only" would close every
    group on the older half of the network.
    """
    assert "ack.member_upload !== false" in app
    assert "!== false" in app[app.index("ack.member_upload"):
                              app.index("ack.member_upload") + 60]
    block = app[app.index("const legacyNode"):]
    block = block[:block.index("const commonProps")]
    assert "writable === undefined" in block, (
        "nothing distinguishes a 1.0 node from one with no writable roots")


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.read_text(encoding="utf-8")
    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.read_text(encoding="utf-8")
    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.read_text(encoding="utf-8")
    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