summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_spa_ordering.py
blob: 087298f782ef8aa04cb236141e1df358b76ce12a (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
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
"""
Ordering guards for the SPA's connect() flow.

These are source-level checks, which is not how one would normally test
behaviour. They exist because a specific class of bug shipped to a live browser
twice and no other test could see it: `connect()` is a long sequence in which
later steps read values earlier steps set, and the Python end-to-end client in
QE/deploy/ cannot catch a mistake there — it is a different implementation,
written in the right order by construction, so it passes while the browser fails.

Concretely: join_request signs a transcript over the node key and the node nonce,
and runs *before* the GEK proof, because a first-time member has no GEK to prove.
Both values were being read further down, next to the proof that also uses them,
so every invited member hit "Handshake incomplete — reconnect and retry".

If you restructure connect(), these will fail. Check the invariant still holds —
that nothing reads a value assigned later — and then move the markers.
"""

from pathlib import Path

import pytest
from spa_source import transport_source

STATIC = (Path(__file__).resolve().parents[1]
          / "src" / "meshbay_hub" / "static")
TRANSPORT = STATIC / "transport.js"

pytestmark = pytest.mark.skipif(
    not TRANSPORT.exists(), reason="SPA sources not present")


def _positions(*needles: str) -> list[int]:
    source = transport_source()
    out = []
    for needle in needles:
        idx = source.find(needle)
        assert idx != -1, f"{needle!r} is gone from transport.js — update this test"
        out.append(idx)
    return out


def test_challenge_values_are_captured_before_joining():
    """
    joinGroup() signs over node_pk and nonce_node, so both must be recorded when
    the challenge arrives — not later, beside the proof.
    """
    # Deliberately loose markers: what matters is where the assignment happens,
    # not how it is spelled, so a reordering fails on the ordering assertion
    # below rather than on a missing string.
    node_pk, nonce_node, join_call = _positions(
        "this.nodePk = reply.node_pk",
        "this._nonceNode = ",
        "await this.joinGroup(",
    )
    assert node_pk < join_call, (
        "node_pk is read from the challenge after joinGroup() runs — the join "
        "would sign a transcript naming nothing")
    assert nonce_node < join_call, (
        "nonce_node is captured after joinGroup() runs — the join would not be "
        "bound to this connection")


def test_join_happens_before_the_gek_proof():
    """
    The whole point of joining in the pre-proof window: someone who has never
    held the group key cannot produce a proof, so the key has to arrive first.
    """
    join_call, proof = _positions(
        "await this.joinGroup(",
        "await C.handshakeProof(",
    )
    assert join_call < proof, (
        "the join must happen before the GEK proof — a first-time member has no "
        "key to prove with")


def test_keys_are_recovered_before_the_join_is_attempted():
    """
    A second browser holds nothing but a password. It recovers its identity keys
    from the node's encrypted keypair bundle, and only then can it sign a join —
    so the recovery has to come first. Getting this order wrong is invisible on
    the browser that registered, and breaks every other one.
    """
    recover, join_call = _positions(
        "type: 'keypair_bundle_fetch'",
        "await this.joinGroup(",
    )
    assert recover < join_call, (
        "the keypair bundle must be fetched before joinGroup() — otherwise a "
        "browser that did not register has no key to sign the join with")


def test_the_ack_still_verifies_the_announced_node_key():
    """
    Taking node_pk from the challenge is only safe because the ack proves it and
    the client compares the two. Losing that check would leave the announcement
    trusted on its own.
    """
    source = transport_source()
    assert "Node identity changed during the handshake" in source, (
        "the challenge's node_pk must be checked against the ack's")
    assert "verifyNodeSignature" in source, (
        "the ack's signature over the handshake transcript must still be verified")


# ── Component boundaries ────────────────────────────────────────────────────
#
# A second class of bug this file exists for. Moving a block between components
# is a plain cut and paste, and nothing checks that the paste landed somewhere
# the names it uses exist: the invite form and the member list were cut out of
# MembersPanel and pasted into AdminPage, which left a standard member seeing an
# empty Members tab, the group owner seeing only a pairing form, and the hub's
# Users tab referencing `doInvite`, `members` and `adminId` — none of which are
# defined there.

# The group-page refactor split what used to be one app.js into one file per
# "application" (chat-app.js, files-app.js, video-player.js) plus
# group-settings.js and the group shell itself, group-page.js.
# `_component` below is told which file to read a given top-level
# component from.
APP = STATIC / "app.js"
COMPONENT_FILES = {
    "GroupSettingsPanel": STATIC / "group-settings.js",
    "GroupPage": STATIC / "group-page.js",
    "ChatPanel": STATIC / "chat-app.js",
    "FilesPanel": STATIC / "files-app.js",
    "VideoPlayer": STATIC / "video-player.js",
    "AdminPage": STATIC / "admin-page.js",
}


def _component(name: str) -> str:
    """The source of one top-level `function Name(...)`, up to the next one."""
    source = COMPONENT_FILES.get(name, APP).read_text()
    start = source.find(f"\nfunction {name}(")
    if start == -1:
        start = source.find(f"\nexport function {name}(")
    assert start != -1, f"{name} is gone — update this test"
    end = source.find("\nfunction ", start + 1)
    if end == -1:
        end = source.find("\nexport function ", start + 1)
    return source[start:end if end != -1 else len(source)]


def test_the_group_settings_panel_renders_what_it_owns():
    panel = _component("GroupSettingsPanel")
    assert "members.map(" in panel, "the member list is not rendered"
    assert "onSubmit=${doInvite}" in panel, "the invite form is not rendered"
    assert "onSubmit=${doPair}" in panel, "the pairing form is not rendered"
    assert "device.mine_title" in panel, "the devices section is not rendered"


def test_the_roster_comes_last():
    """
    It is the only part of this tab with no upper bound. Two hundred members
    would put every form and every control below the fold, which is what the
    order is for — asked for in those terms.
    """
    panel = _component("GroupSettingsPanel")
    listing = panel.index("members.map(")
    for name, marker in (("the invite form", "onSubmit=${doInvite}"),
                         ("the pairing form", "onSubmit=${doPair}"),
                         ("the devices section", "device.mine_title"),
                         ("leaving and deleting", "group.delete_group_confirm")):
        assert panel.index(marker) < listing, f"{name} belongs above the roster"


def test_leaving_a_group_lives_with_the_group_settings():
    """It used to sit in the page header beside the group's name, which is
    neither where it belongs nor where anyone looked for it."""
    panel = _component("GroupSettingsPanel")
    assert "group.leave_confirm" in panel and "group.delete_group_confirm" in panel
    page = _component("GroupPage")
    assert "group.leave_confirm" not in page, "still in the header as well"


def test_leaving_does_not_require_the_node_to_be_up():
    """
    Moving these into a tab that only rendered on a live connection would have
    made them unreachable exactly when a node is down — which is when someone
    most wants to leave. Membership is hub-side; the tab bar does not wait for
    the node.
    """
    page = _component("GroupPage")
    tabs = page[page.index("group-tabs"):]
    tabs = tabs[:tabs.index("</div>")]
    before = page[:page.index("group-tabs")]
    guard = before[before.rindex("${"):]
    assert "status === 'connected'" not in guard, (
        "the tab bar is gated on the connection, so a group on an offline node "
        "cannot be left")


def test_the_node_dependent_sections_say_when_the_node_is_down():
    """The other half of that: inviting needs the node to wrap the group key,
    so it must explain itself rather than silently doing nothing."""
    panel = _component("GroupSettingsPanel")
    assert "connected &&" in panel and "!connected &&" in panel


def test_admin_page_does_not_borrow_the_group_settings_state():
    admin = _component("AdminPage")
    for name in ("doInvite", "adminId", "inviteCode", "setInviteUser"):
        assert name not in admin, \
            f"AdminPage references {name}, which only exists in GroupSettingsPanel"


# ── Upload pipelining ───────────────────────────────────────────────────────
#
# The upload loop waited for the node to acknowledge each 48 KB chunk before
# reading the next one, which caps throughput at one chunk per round trip no
# matter how much bandwidth there is — and keeps SCTP's congestion window shut,
# so the transport never speeds up either. Measured over a 100 ms path: 0.16 MB/s
# waiting for every ack, 3.47 MB/s with 32 chunks in flight.

def test_the_uploader_keeps_several_chunks_in_flight():
    src = transport_source()
    body = src[src.index("async uploadFile("):]
    body = body[:body.index("\n  async ", 1)]
    assert "UPLOAD_WINDOW" in body, "the send window is gone — uploads are serial again"
    assert "bufferedAmount" in body, (
        "without backpressure the file lands in the send buffer in seconds and "
        "the progress bar becomes fiction")


def test_no_caller_waits_for_one_chunk_at_a_time():
    app = APP.read_text()
    assert "uploadChunk(" not in app, (
        "a per-chunk await is back in the SPA; use transport.uploadFile()")


# ── Transfers outlive the page ──────────────────────────────────────────────
#
# Downloads and uploads used to be state inside GroupPage, so leaving a group
# unmounted the component, its cleanup closed the DataChannel, and a half-written
# file was all you had. The store in transfers.js owns them now; these check the
# two ends of that, since neither shows up in any Python test.

def test_leaving_a_group_hands_the_transport_over_rather_than_closing_it():
    app = _component("GroupPage")
    cleanup = app[app.index("    return () => {\n      cancelled = true;"):]
    cleanup = cleanup[:cleanup.index("\n  }, [groupId")]
    assert "releaseWhenIdle" in cleanup, (
        "the group page closes its transport directly again — a running "
        "download would die with the page")
    assert ".close()" not in cleanup


def test_signing_out_stops_them():
    app = APP.read_text()
    logout = app[app.index("    logout: () => {"):]
    logout = logout[:logout.index("navigate('/login')")]
    assert "transfers.reset()" in logout, (
        "logout must cancel transfers: they run on tokens that stop being ours")


def test_the_files_panel_no_longer_carries_its_own_progress_bars():
    """They moved next to the bell, where they stay visible across the app."""
    app = APP.read_text()
    for gone in ("setUlState", "setDlState", "dl-bar"):
        assert gone not in app, f"{gone} survived the move to the transfer widget"


# ── Downloading a selection ─────────────────────────────────────────────────

def test_a_multi_file_download_waits_for_each_picker():
    """
    A browser allows one file picker at a time. Firing every download at once
    meant the first opened a dialog and the rest were rejected — two files
    selected, one file downloaded.
    """
    app = STATIC.joinpath("files-app.js").read_text()
    # Anchored on the loop rather than on the markup around it: the toolbar
    # moved from a dropdown to icon buttons and took the old wrapper with it,
    # while the property under test — one picker at a time — did not change.
    # The loop is over `files` since the toolbar and the right-click menu
    # share one action list.
    block = app[app.index("for (const e of files)"):]
    block = block[:block.index("\n")]
    assert "await downloadFile(e)" in block, (
        "downloads are fired without awaiting again; only the first will ask "
        "for a save location and the others will be rejected")


def test_links_in_chat_are_built_as_elements_not_markup():
    """
    A message is something another member wrote. It becomes an anchor element,
    never HTML, and only for http(s) — otherwise javascript: would be one
    message away from running here.
    """
    app = STATIC.joinpath("chat-app.js").read_text()
    fn = app[app.index("function linkify("):]
    fn = fn[:fn.index("\nfunction ", 1)]
    assert "innerHTML" not in fn and "dangerouslySetInnerHTML" not in fn
    assert 'rel="noopener noreferrer"' in fn
    assert "https?" in app[app.index("const URL_RE"):app.index("function linkify(")]