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
|
"""
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_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 "root: uploadRoot" in upload, "the node is left to choose"
assert "currentPath.split('/')[0]" in upload
# ── 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. The root
acks carry a whole table only the node can compute — availability, the name
it settled on, the eject a failed plug left in place — so resolving one
without handing it on left the operator who clicked Eject as the only
client that never saw it happen.
"""
transport = TRANSPORT.read_text(encoding="utf-8")
block = transport[transport.index("msg.type.endsWith('_ack')"):]
block = block[:block.index("_uploaders")]
assert "ROOT_ACK_TYPES" in block and "_onRootsChanged" 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
|