""" 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 finding F7 (`docs/MESHBAY_DESIGN.md` §13.6), 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