diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-07 18:03:52 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-07 18:03:52 +0200 |
| commit | e1383e1d545b994f4ad61694f868339defb0bdef (patch) | |
| tree | 67a3b1933f2a9c5caf107d01d9ff91c44a975df6 /packages/meshbay-hub/tests/test_transport_contracts.py | |
| parent | 36cebf25d0e0f24cf63be4380ccb5d03da726a74 (diff) | |
| parent | 8980a8e42d94ab7c0bc9739283d39f938f8402b0 (diff) | |
| download | meshbay-e1383e1d545b994f4ad61694f868339defb0bdef.tar.gz | |
Merge origin/main into the chat encryption work
Both sides landed a breaking MNP change and both called it 2.0, which is right:
the sealed upload, the removal of `stream_seg` and mandatory chat encryption
share one flag day. They are recorded as one version in `__init__.py` rather
than as a race between two.
The resolutions that were decisions rather than mechanics:
* **`MNP_MIN_SUPPORTED` moves to "2.0".** The sealed upload alone was a
*confined* break — a 1.x peer could still connect, browse, download, stream
and chat, with only its uploads refused by `upload_not_sealed` — so the floor
deliberately stayed at "1.0". Mandatory chat encryption ends that
confinement: a 1.x peer can neither produce a sealed chat message nor read
one, so it would connect, look fine, and be unable to say anything. Refusing
it at the handshake is the honest form. The per-message `upload_not_sealed`
path is untouched and still right if the floor is ever lowered.
* **`sendChat` throws on an `error` reply**, from origin, applied to the sealed
send. It matters more after this change, not less: the node now refuses a
stale epoch, a malformed envelope and a device claim that is not the
connection's own, so there are three new ways for a message to be rejected
and none of them may look like a message that was sent.
* **`req_id` supersedes the per-type routing** this branch added for
`chat_keys_resp` and `device_hello_ack`. Both blocks are kept beside the
existing `chat_hist_resp` one, for the same stated reason — a node too old to
stamp — and their comments no longer claim to be the mechanism that closes
the class. `req_id` is.
* **`chat_send_probe.py` is rebuilt on origin's structure**, not beside it: two
scenarios, a stub that stamps `req_id`, `music_meta_req` as the older pending
request. The encrypted path is layered on — a real Ed25519 device key
generated in the page, and a `chat_keys_resp` sealed by the shipped Python,
because a payload the page built itself would prove only that the page agrees
with the page.
* **`test_reply_correlation.py` now sends a sealed message.** Its subject is
which of the two messages leaving that handler carries the id; plaintext chat
was only the fixture, and the node refuses one now.
* `groupbox` keeps both new purposes (`upload`, `chat_keys`); `protocol.py`
keeps origin's removal of `STREAM_SEGMENT` and this branch's correction of
the "Double Ratchet message" comment on `CHAT_MESSAGE`, which was wrong when
it was written and is wrong differently now.
Full suite on the merged tree: 1993 passed, 11 failed — the same 11 that fail
on a pristine checkout (2 Windows service tests, 1 apps-enabled policy, 7
transcode tests that pass in isolation, and the WebRTC invite test that hangs
on its own).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
Diffstat (limited to 'packages/meshbay-hub/tests/test_transport_contracts.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_transport_contracts.py | 50 |
1 files changed, 42 insertions, 8 deletions
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py index b462242..879062b 100644 --- a/packages/meshbay-hub/tests/test_transport_contracts.py +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -307,26 +307,60 @@ def test_no_setter_survives_the_state_it_belonged_to(): # ── Parallel uploads ────────────────────────────────────────────────────────── -def test_an_upload_refusal_names_the_file_it_is_about(transport): +def test_an_upload_refusal_names_the_upload_it_is_about(transport): """Reported 2026-08-16: a second upload started in parallel killed both. - An error used to carry no filename, so the client could not tell whose it - was and failed every upload in flight — one name the node disliked took the - other file with it. The node names the file now, and only that upload stops. + An error used to carry nothing identifying, so the client could not tell + whose it was and failed every upload in flight — one name the node disliked + took the other file with it. + + The node named the *file* until MNP 2.0 and names the `upload_id` now: the + filename moved inside the seal, and echoing it in clear so the two sides + could match on it would give back precisely what sealing the upload is for. + The property is unchanged — one refusal, one failed upload. """ body = transport[transport.index("if (msg.type === 'error' && this._uploaders.size)"):] body = body[:body.index("\n if (msg.type === 'chat_msg'")] - assert "this._uploaders.has(msg.filename)" in body, ( + assert "this._uploaders.has(msg.upload_id)" in body, ( "a named refusal must reach one uploader, not all of them") - assert "if (!msg.filename)" in body, ( + assert "if (!msg.upload_id)" in body, ( "an unnamed error from an older node must still stop everything — " "guessing which upload it belongs to would be worse") -def test_uploads_are_tracked_per_file(transport): +def test_uploads_are_tracked_per_upload(transport): """Acks interleave when two files are in flight.""" assert "this._uploaders = new Map()" in transport - assert "this._uploaders.set(file.name" in transport + assert "this._uploaders.set(uploadId" in transport + # And the "already being uploaded" guard still speaks in filenames, because + # that is what the caller passed and what it would recognise in the error. + assert "this._inFlightUploads.has(file.name)" in transport + + +def test_the_upload_itself_is_sealed(transport): + """ + MNP 2.0. The filename, the destination and the bytes go inside the seal + together — sealing the content and announcing the name beside it would be + theatre — and only what the node routes on stays outside. + """ + start = transport.index(" async uploadFile(file,") + body = transport[start:transport.index("\n /** Create a directory", start)] + assert "sealGroup(" in body and "'file_upload'" in body, ( + "the upload must be sealed under the group key") + assert "openGroup(" in body and "'file_upload_ack'" in body, ( + "the ack carries the stored name and must be opened, not read") + # The message the node actually receives: everything between `this._send({` + # and its close. Read on its own, because the same field names appear a few + # lines above inside `msgpack_encode({...})`, which is the sealed half. + sent = body[body.index("this._send({"):] + sent = sent[:sent.index("});")] + assert "filename" not in sent, "the filename is on the message in clear" + assert "data" not in sent, "the bytes are on the message in clear" + assert "dir" not in sent and "root" not in sent, ( + "the destination is on the message in clear") + assert "...sealed," in sent, "the message must carry the sealed pair" + assert "supportsSealedUpload" in body, ( + "an older node must be refused before a chunk is sent, not after") # ── MNP 1.0: the sealed handshake ack ──────────────────────────────────────── |