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
|
"""
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
|