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
|