diff options
Diffstat (limited to 'packages/meshbay-node/tests/test_chat_multidevice.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_chat_multidevice.py | 160 |
1 files changed, 160 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_chat_multidevice.py b/packages/meshbay-node/tests/test_chat_multidevice.py new file mode 100644 index 0000000..d718b2a --- /dev/null +++ b/packages/meshbay-node/tests/test_chat_multidevice.py @@ -0,0 +1,160 @@ +""" +One account, several devices, on one node. + +Device linking (2026-08-18) made `identities` a table keyed by +`(user_id, pk_ed25519)`, so a person legitimately holds several keys here. The +chat path never followed: the peer registry was keyed by `user_id`, so the +second connection of one account **evicted the first**, and the broadcast loop +skipped recipients by account, so a person's own other devices never received +what they said. + +Neither shows up as an error anywhere. The first is a message that silently +reaches nobody after a second device connects and disconnects; the second is a +phone that never shows what was typed on the laptop. Both are +`docs/chat-sender-keys.md` F7, and both are the same "keyed by account where it +should be keyed by connection" mistake as `pin_identity`'s old INSERT OR REPLACE. +""" + +from pathlib import Path + +import base64 +import hashlib + +from aiortc import RTCPeerConnection +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +from conftest import one_root + + +def _sealed(device_raw: bytes, text: bytes = b"ciphertext") -> dict: + """A well-formed sealed envelope. + + The bytes are not a real ciphertext and do not need to be: the node never + opens one. What it *does* check is the envelope's shape and that the device + is the connection's own, and going through the real `_do_chat_message` + rather than around it is the point — these tests are about delivery, and + delivery now runs after that check. + """ + return {"format": 1, "epoch": 1, "device": device_raw, "ct": text, + "nonce": b"\x02" * 12, "sig": b"\x03" * 64} + + +def _session(ctx: dict, user_id: str, group_id: str) -> WebRTCPeerSession: + """A peer session with only what the chat path touches wired up. + + Built through the real `__init__` — with a bare RTCPeerConnection, which + costs under a millisecond and opens no socket — so `_registry_key` is the + one production assigns. Constructing it in the test instead would make + these tests agree with the fix by construction, which is precisely the + trap the repo's own notes record. + """ + session = WebRTCPeerSession(pc=RTCPeerConnection(), node_ctx=ctx) + session._group_id = group_id + session._user_id = user_id + session._username = user_id + # Each connection is a distinct device of that account — which is the whole + # subject here, and what `_check_chat_envelope` compares a message against. + session._pinned_pk = base64.b64encode(_device_raw(session)).decode() + session._device_confirmed = True + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + session._spawn = lambda coro: coro.close() + return session + + +def _ctx(tmp_path: Path, group_id: str) -> dict: + index = GroupIndex(group_id=group_id, sk_node=Ed25519PrivateKey.generate()) + root = tmp_path / "shared" + root.mkdir(exist_ok=True) + return {"groups": {group_id: { + "index": index, "roots": one_root(root), "chat_store": None, + }}} + + +GROUP = "g" * 32 + + +def _device_raw(session) -> bytes: + """A stable 32-byte stand-in for this connection's device key. + + Derived from the registry key, so two sessions of one account get two + devices — which is exactly the situation being tested, and a shared one + would make the envelope check pass for the wrong reason. + """ + return hashlib.sha256(session._registry_key.encode()).digest() + + +def test_two_devices_of_one_account_both_stay_registered(tmp_path): + """ + F7: keyed by `user_id`, the second device overwrote the first, and closing + either then removed the other's entry — so one person's two devices could + never both be reachable. + """ + ctx = _ctx(tmp_path, GROUP) + laptop = _session(ctx, "alice", GROUP) + phone = _session(ctx, "alice", GROUP) + + laptop._register_peer() + phone._register_peer() + + registry = laptop._peer_registry() + assert len(registry) == 2, ( + "one account's two devices must both be in the registry; keyed by " + "user_id the second silently replaced the first") + assert set(registry.values()) == {laptop, phone} + + +def test_a_message_reaches_the_senders_other_device(tmp_path): + """ + F7, the visible half: the broadcast excluded recipients whose `user_id` + matched the sender's, so everything typed on the laptop was missing from + the phone — with no error and no way to notice but to hold both. + """ + ctx = _ctx(tmp_path, GROUP) + laptop = _session(ctx, "alice", GROUP) + phone = _session(ctx, "alice", GROUP) + bob = _session(ctx, "bob", GROUP) + for s in (laptop, phone, bob): + s._register_peer() + laptop._user_names = lambda: ctx["groups"][GROUP].setdefault("_names", {}) + + laptop._do_chat_message(_sealed(_device_raw(laptop), b"hello-ciphertext")) + + def chats(session): + return [m for m in session.sent if m.get("type") == "chat_msg"] + + assert len(chats(phone)) == 1, ( + "the sender's other device is an ordinary recipient — it composed " + "nothing and has no local echo to fall back on") + assert chats(phone)[0]["ct"] == b"hello-ciphertext" + assert len(chats(bob)) == 1 + assert chats(laptop) == [], "the composing connection must not echo to itself" + + +def test_closing_one_device_leaves_the_other_connected(tmp_path): + """ + The teardown half. `close()` popped `self._user_id`, so the phone + disconnecting unregistered the laptop, which then received nothing for the + rest of its session while still reading as connected. + """ + ctx = _ctx(tmp_path, GROUP) + laptop = _session(ctx, "alice", GROUP) + phone = _session(ctx, "alice", GROUP) + for s in (laptop, phone): + s._register_peer() + + phone._unregister_peer() + + assert laptop._registry_key in laptop._peer_registry() + assert len(laptop._peer_registry()) == 1 + + +def test_registry_key_is_per_connection_not_per_account(tmp_path): + """The property the two tests above depend on, asserted directly.""" + ctx = _ctx(tmp_path, GROUP) + a = _session(ctx, "alice", GROUP) + b = _session(ctx, "alice", GROUP) + assert a._registry_key != b._registry_key |