Skip to main content

offline_protocol/protocol/
blocking.rs

1//! User blocking: silent block list that filters incoming messages, control
2//! messages, discovery events, and connection requests from blocked users.
3
4use super::{lock_shared_state, OfflineProtocol};
5use crate::protocol::types::MAX_BLOCKED_USERS;
6use crate::{Error, Event, Result};
7use offline_protocol_core::UserId;
8use tracing::{debug, info};
9
10impl OfflineProtocol {
11    /// Blocks a user. Messages from this user will be silently dropped
12    /// (no ACK sent, no event emitted). The blocked user receives no
13    /// notification.
14    ///
15    /// Blocking is idempotent — calling this for an already-blocked user
16    /// succeeds silently.
17    pub fn block_user(&mut self, user_id: &str) -> Result<()> {
18        // Validate user_id format
19        let _ = UserId::new(user_id)
20            .map_err(|_| Error::InvalidConfiguration(format!("Invalid user ID: {}", user_id)))?;
21
22        // Cannot block self
23        if user_id == self.local_id {
24            return Err(Error::InvalidConfiguration(
25                "Cannot block own user ID".to_string(),
26            ));
27        }
28
29        // Enforce capacity limit
30        if self.blocked_users.len() >= MAX_BLOCKED_USERS && !self.blocked_users.contains(user_id) {
31            return Err(Error::InvalidConfiguration(format!(
32                "Blocked users limit reached ({})",
33                MAX_BLOCKED_USERS
34            )));
35        }
36
37        if !self.blocked_users.insert(user_id.to_string()) {
38            // Already blocked — idempotent success
39            debug!(user_id = %user_id, "User already blocked");
40            return Ok(());
41        }
42
43        self.persist_blocked_user(user_id);
44
45        info!(user_id = %user_id, "User blocked");
46
47        if let Ok(state) = lock_shared_state(&self.shared_state) {
48            state.emit_event(Event::user_blocked(user_id.to_string()));
49        }
50
51        Ok(())
52    }
53
54    /// Unblocks a previously blocked user.
55    ///
56    /// Clears any stale MLS session state, pending key packages, and queued
57    /// messages for the peer so that a fresh key exchange can occur on
58    /// re-discovery.
59    ///
60    /// Unblocking a non-blocked user succeeds silently.
61    pub fn unblock_user(&mut self, user_id: &str) -> Result<()> {
62        // Validate user_id format
63        let _ = UserId::new(user_id)
64            .map_err(|_| Error::InvalidConfiguration(format!("Invalid user ID: {}", user_id)))?;
65
66        if !self.blocked_users.remove(user_id) {
67            // Not blocked — idempotent success
68            debug!(user_id = %user_id, "User was not blocked");
69            return Ok(());
70        }
71
72        self.delete_blocked_user(user_id);
73        self.cleanup_peer_session_state(user_id);
74
75        // If the peer is currently nearby and MLS is active, proactively send a
76        // fresh key package so both sides can re-establish the session without
77        // waiting for a new neighbor discovery cycle.
78        if self.known_peers.contains_key(user_id)
79            && self.config.encryption.enabled
80            && self.config.encryption.auto_key_exchange
81            && self.mls_manager.is_some()
82        {
83            if let Err(e) = self.send_key_package_to(user_id, true) {
84                debug!(user_id = %user_id, error = %e, "Failed to send key package after unblock (peer may reconnect later)");
85            }
86        }
87
88        info!(user_id = %user_id, "User unblocked");
89
90        if let Ok(state) = lock_shared_state(&self.shared_state) {
91            state.emit_event(Event::user_unblocked(user_id.to_string()));
92        }
93
94        Ok(())
95    }
96
97    /// Removes all MLS session state for a peer so the next encounter starts
98    /// with a clean slate.
99    ///
100    /// Best-effort: individual cleanup failures are logged but do not prevent
101    /// the unblock from succeeding.
102    fn cleanup_peer_session_state(&mut self, user_id: &str) {
103        // 1. Delete the MLS session (+ confirmed_sessions, confirmation
104        //    tracking, welcome lifecycles, persisted session/welcome state).
105        //    Only needed when MLS is initialized; steps 2-5 operate on
106        //    in-memory maps that always exist regardless of MLS state.
107        if self.mls_manager.is_some() {
108            if let Err(e) = self.manual_mls_delete_session(user_id) {
109                // Not an error — there may simply be no session to delete.
110                debug!(user_id = %user_id, error = %e, "No MLS session to clean up for unblocked user");
111            }
112        }
113
114        // 2. Discard any received key package we were holding for them, and
115        //    the advertised capability record (memory + durable): the clean
116        //    slate re-learns capabilities from the fresh exchange in step 6.
117        if self.pending_key_packages.remove(user_id).is_some() {
118            debug!(user_id = %user_id, "Cleared pending key package for unblocked user");
119        }
120        self.delete_peer_key_package_from_storage(user_id);
121        self.peer_compact_envelope.remove(user_id);
122        self.peer_rich_payload.remove(user_id);
123        self.peer_rich_attested.remove(user_id);
124        // The Nostr sealing key lives in the transport, not in one of the sets
125        // above, so it needs its own clear — the durable record is deleted by
126        // `delete_peer_capabilities_from_storage` below, and leaving the
127        // in-memory copy behind would keep sealing to a key the clean slate
128        // just declared unknown. Reverting to the peer's publicly computable
129        // key is the correct fallback: still sealed, and readable by them
130        // whatever install they are now on.
131        self.transport_manager.clear_peer_nostr_pubkey(user_id);
132        self.delete_peer_capabilities_from_storage(user_id);
133
134        // 3. Drop queued outbound messages that were waiting for session
135        //    establishment with this peer.
136        if self.drop_pending_queue_for_peer(user_id, "Peer session state reset on unblock") > 0 {
137            debug!(user_id = %user_id, "Cleared pending encrypted messages for unblocked user");
138        }
139
140        // 4. Drain any inbound messages sitting in the pending decryption
141        //    queue (encrypted messages received before the session was ready).
142        let drained = self
143            .pending_queue
144            .drain_for_peer(&self.config.encryption.pending_queue, user_id);
145        if !drained.is_empty() {
146            debug!(
147                user_id = %user_id,
148                count = drained.len(),
149                "Drained pending decryption queue for unblocked user"
150            );
151        }
152
153        // 5. Cancel any in-progress inbound file transfers from this peer
154        //    and remove their pending media metadata.
155        let file_ids_to_cancel: Vec<String> = self
156            .pending_media_metadata
157            .iter()
158            .filter(|(_, entry)| entry.sender == user_id)
159            .map(|(file_id, _)| file_id.clone())
160            .collect();
161        for file_id in &file_ids_to_cancel {
162            self.file_transfer_manager.cancel_transfer(file_id);
163            self.pending_media_metadata.remove(file_id);
164        }
165        if !file_ids_to_cancel.is_empty() {
166            debug!(
167                user_id = %user_id,
168                count = file_ids_to_cancel.len(),
169                "Cancelled in-progress file transfers for unblocked user"
170            );
171        }
172
173        // 6. Allow a fresh key exchange on re-discovery.
174        self.key_package_sent_to.remove(user_id);
175    }
176
177    /// Returns the list of currently blocked user IDs, sorted for stable output.
178    pub fn get_blocked_users(&self) -> Vec<String> {
179        let mut users: Vec<String> = self.blocked_users.iter().cloned().collect();
180        users.sort();
181        users
182    }
183
184    /// Checks whether a user is currently blocked.
185    pub fn is_user_blocked(&self, user_id: &str) -> bool {
186        self.blocked_users.contains(user_id)
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use crate::mls::InMemoryStorage;
193    use crate::test_identity::id;
194    use crate::ProtocolConfig;
195    use offline_protocol_transport::Transport;
196    use std::sync::Arc;
197
198    fn make_protocol(user_id: &str) -> crate::OfflineProtocol {
199        let mut config = ProtocolConfig::new("test-app", user_id);
200        // These tests exercise blocking and inbound file-transfer machinery
201        // over legacy plaintext chunks; opt out of the fail-closed default
202        // (SEC-M3) so the receive gate accepts them.
203        config.encryption.require_encryption = false;
204        crate::OfflineProtocol::new(config).unwrap()
205    }
206
207    #[test]
208    fn test_block_unblock_roundtrip() {
209        let mut proto = make_protocol("alice");
210        assert!(!proto.is_user_blocked("bob"));
211        assert!(proto.get_blocked_users().is_empty());
212
213        proto.block_user("bob").unwrap();
214        assert!(proto.is_user_blocked("bob"));
215        let blocked = proto.get_blocked_users();
216        assert_eq!(blocked.len(), 1);
217        assert!(blocked.contains(&"bob".to_string()));
218
219        proto.unblock_user("bob").unwrap();
220        assert!(!proto.is_user_blocked("bob"));
221        assert!(proto.get_blocked_users().is_empty());
222    }
223
224    #[test]
225    fn test_block_self_rejected() {
226        let mut proto = make_protocol("alice");
227        let result = proto.block_user("alice");
228        assert!(result.is_err());
229        assert!(result.unwrap_err().to_string().contains("Cannot block own"));
230    }
231
232    #[test]
233    fn test_block_invalid_user_id() {
234        let mut proto = make_protocol("alice");
235        let result = proto.block_user("");
236        assert!(result.is_err());
237    }
238
239    #[test]
240    fn test_block_idempotent() {
241        let mut proto = make_protocol("alice");
242        proto.block_user("bob").unwrap();
243        proto.block_user("bob").unwrap(); // should succeed silently
244        assert!(proto.is_user_blocked("bob"));
245    }
246
247    #[test]
248    fn test_unblock_not_blocked() {
249        let mut proto = make_protocol("alice");
250        proto.unblock_user("bob").unwrap(); // should succeed silently
251    }
252
253    #[test]
254    fn test_blocked_user_persistence() {
255        let mut proto = make_protocol("alice");
256        let storage = Arc::new(InMemoryStorage::new());
257        proto.enable_message_persistence_for_test(storage).unwrap();
258
259        proto.block_user("bob").unwrap();
260        assert!(proto.is_user_blocked("bob"));
261
262        // Clear in-memory set, then restore from storage
263        proto.blocked_users.clear();
264        assert!(!proto.is_user_blocked("bob"));
265
266        proto.restore_blocked_users().unwrap();
267        assert!(proto.is_user_blocked("bob"));
268    }
269
270    #[test]
271    fn test_receive_drops_blocked_sender() {
272        use offline_protocol_core::{AppId, Message, UserId};
273        use offline_protocol_transport::{mock::MockTransport, TransportType};
274
275        let mut proto = make_protocol("alice");
276        proto.block_user("mallory").unwrap();
277
278        let mock = MockTransport::new(TransportType::BLE);
279        mock.start().unwrap();
280
281        // Queue a message from the blocked user addressed to us
282        let msg = Message::new(
283            UserId::new("mallory").unwrap(),
284            UserId::new("alice").unwrap(),
285            AppId::new("test-app").unwrap(),
286            "you shouldn't see this",
287        );
288        mock.queue_message(msg);
289
290        proto
291            .transport_manager_mut()
292            .add_transport(TransportType::BLE, Box::new(mock));
293        proto.start().unwrap();
294
295        // receive_message should return None — the blocked message is silently dropped
296        assert!(proto.receive_message().is_none());
297    }
298
299    #[test]
300    fn test_relay_continues_for_blocked_user() {
301        use crate::events::Event;
302        use offline_protocol_core::{AppId, Message, UserId};
303        use offline_protocol_transport::{mock::MockTransport, TransportType};
304        use std::sync::{Arc, Mutex};
305
306        let mut config = ProtocolConfig::new("test-app", "alice");
307        config.encryption.require_encryption = false;
308        // Flush forwarding in the same breath as receiving it.
309        config.mesh_relay.jitter_min = std::time::Duration::from_millis(0);
310        config.mesh_relay.jitter_max = std::time::Duration::from_millis(0);
311        let mut proto = crate::OfflineProtocol::new(config).unwrap();
312        proto.block_user("mallory").unwrap();
313
314        let relay_events = Arc::new(Mutex::new(Vec::<Event>::new()));
315        let relay_events_clone = relay_events.clone();
316        proto.on_event(move |event| {
317            if matches!(event, Event::MessageRelayed { .. }) {
318                relay_events_clone.lock().unwrap().push(event);
319            }
320        });
321
322        let mock = MockTransport::new(TransportType::BLE);
323        mock.start().unwrap();
324        // Blocking someone stops us talking to them; it does not stop this
325        // device carrying the crowd's traffic, so a neighbor must be present
326        // for that forwarding to be observable.
327        mock.add_connected_peer("dave", -55);
328
329        // Message from blocked user but addressed to a THIRD party — should NOT be blocked.
330        // With relay enabled, the message is forwarded and NOT returned to the app layer.
331        let msg = Message::new(
332            UserId::new("mallory").unwrap(),
333            UserId::new("charlie").unwrap(),
334            AppId::new("test-app").unwrap(),
335            "relay this",
336        );
337        mock.queue_message(msg);
338
339        proto
340            .transport_manager_mut()
341            .add_transport(TransportType::BLE, Box::new(mock));
342        proto.start().unwrap();
343
344        // The message is for a third party — it gets relayed (forwarded) and not
345        // returned to the local app. receive_message returns None because the loop
346        // calls `continue` after relaying.
347        let received = proto.receive_message();
348        assert!(
349            received.is_none(),
350            "Relay messages for third parties should be forwarded, not returned"
351        );
352        proto.process().unwrap();
353
354        // Verify that a MessageRelayed event was emitted — blocking must not
355        // suppress relay forwarding for messages addressed to third parties.
356        let events = relay_events.lock().unwrap();
357        assert_eq!(
358            events.len(),
359            1,
360            "Blocked-user relay to third party must emit MessageRelayed"
361        );
362        match &events[0] {
363            Event::MessageRelayed {
364                sender, recipient, ..
365            } => {
366                assert_eq!(sender, "mallory");
367                assert_eq!(recipient, "charlie");
368            }
369            _ => panic!("Expected MessageRelayed event"),
370        }
371    }
372
373    #[test]
374    fn test_unblock_clears_session_state() {
375        use crate::mls::InMemoryStorage as MlsInMemoryStorage;
376        use offline_protocol_core::{AppId, Message, UserId};
377
378        let mut proto = make_protocol("alice");
379        let storage = Arc::new(MlsInMemoryStorage::new());
380        proto.initialize_mls_for_test(storage).unwrap();
381
382        use crate::protocol::types::ReceivedKeyPackage;
383
384        // Simulate receiving a key package from bob (pending state)
385        proto.pending_key_packages.insert(
386            id("bob"),
387            ReceivedKeyPackage {
388                key_package_data: vec![0x01, 0x02],
389                local_expires_at_ms: u64::MAX,
390            },
391        );
392        proto.key_package_sent_to.insert(id("bob"));
393
394        // Queue a pending encrypted message for bob
395        let pending_msg = Message::new(
396            UserId::new(&id("alice")).unwrap(),
397            UserId::new(&id("bob")).unwrap(),
398            AppId::new("test-app").unwrap(),
399            "queued",
400        );
401        proto.enqueue_pending_decryption(&id("bob"), &pending_msg);
402
403        // Block then unblock — should clear all state
404        proto.block_user(&id("bob")).unwrap();
405        proto.unblock_user(&id("bob")).unwrap();
406
407        assert!(proto.pending_key_packages.get(&id("bob")).is_none());
408        assert!(!proto.key_package_sent_to.contains(&id("bob")));
409        assert!(!proto.confirmed_sessions.contains(&id("bob")));
410        assert!(proto
411            .pending_queue
412            .drain_for_peer(&proto.config.encryption.pending_queue, &id("bob"))
413            .is_empty());
414    }
415
416    #[test]
417    fn test_unblock_then_receive() {
418        use offline_protocol_core::{AppId, Message, UserId};
419        use offline_protocol_transport::{mock::MockTransport, TransportType};
420
421        let mut proto = make_protocol("alice");
422        proto.block_user("bob").unwrap();
423        proto.unblock_user("bob").unwrap();
424
425        let mock = MockTransport::new(TransportType::BLE);
426        mock.start().unwrap();
427
428        let msg = Message::new(
429            UserId::new("bob").unwrap(),
430            UserId::new("alice").unwrap(),
431            AppId::new("test-app").unwrap(),
432            "hello again",
433        );
434        let msg_id = msg.id.clone();
435        mock.queue_message(msg);
436
437        proto
438            .transport_manager_mut()
439            .add_transport(TransportType::BLE, Box::new(mock));
440        proto.start().unwrap();
441
442        // After unblocking, messages should flow normally
443        let received = proto.receive_message();
444        assert!(
445            received.is_some(),
446            "Messages should be received after unblocking"
447        );
448        assert_eq!(received.unwrap().id, msg_id);
449    }
450
451    #[test]
452    fn test_send_to_blocked_user_rejected() {
453        use offline_protocol_transport::{mock::MockTransport, TransportType};
454
455        let mut proto = make_protocol("alice");
456
457        let mock = MockTransport::new(TransportType::BLE);
458        mock.start().unwrap();
459        proto
460            .transport_manager_mut()
461            .add_transport(TransportType::BLE, Box::new(mock));
462        proto.start().unwrap();
463
464        proto.block_user("mallory").unwrap();
465
466        let result = proto.send_message("mallory", "hello", None, None::<String>);
467        assert!(matches!(
468            result,
469            Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
470        ));
471    }
472
473    #[test]
474    fn test_send_via_transport_to_blocked_user_rejected() {
475        use offline_protocol_transport::{mock::MockTransport, TransportType};
476
477        let mut proto = make_protocol("alice");
478
479        let mock = MockTransport::new(TransportType::BLE);
480        mock.start().unwrap();
481        proto
482            .transport_manager_mut()
483            .add_transport(TransportType::BLE, Box::new(mock));
484        proto.start().unwrap();
485
486        proto.block_user("mallory").unwrap();
487
488        let result = proto.send_message_via_transport(
489            "mallory",
490            "hello",
491            None,
492            TransportType::BLE,
493            None::<String>,
494        );
495        assert!(matches!(
496            result,
497            Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
498        ));
499    }
500
501    #[test]
502    fn test_send_media_to_blocked_user_rejected() {
503        use offline_protocol_core::ContentType;
504        use offline_protocol_transport::{mock::MockTransport, TransportType};
505
506        let mut proto = make_protocol("alice");
507
508        let mock = MockTransport::new(TransportType::BLE);
509        mock.start().unwrap();
510        proto
511            .transport_manager_mut()
512            .add_transport(TransportType::BLE, Box::new(mock));
513        proto.start().unwrap();
514
515        proto.block_user("mallory").unwrap();
516
517        let result = proto.send_media(
518            "mallory",
519            vec![0u8; 100],
520            "photo.jpg",
521            ContentType::Image,
522            None,
523        );
524        assert!(matches!(
525            result,
526            Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
527        ));
528    }
529
530    #[test]
531    fn test_pending_decryption_drops_blocked_sender() {
532        use offline_protocol_core::{AppId, Message, UserId};
533
534        let mut proto = make_protocol("alice");
535
536        // Enqueue a pending message from bob before blocking
537        let msg = Message::new(
538            UserId::new("bob").unwrap(),
539            UserId::new("alice").unwrap(),
540            AppId::new("test-app").unwrap(),
541            "secret",
542        );
543        proto.enqueue_pending_decryption("bob", &msg);
544
545        // Now block bob
546        proto.block_user("bob").unwrap();
547
548        // process_pending_decryption should silently discard bob's messages
549        proto.process_pending_decryption("bob");
550
551        // Verify nothing was surfaced to the app
552        let state = crate::protocol::lock_shared_state(&proto.shared_state).unwrap();
553        assert!(
554            state.received_messages.is_empty(),
555            "Pending messages from blocked user should not be surfaced"
556        );
557    }
558
559    #[test]
560    fn test_duplicate_from_blocked_user_no_ack() {
561        use offline_protocol_core::{AppId, Message, UserId};
562        use offline_protocol_transport::{mock::MockTransport, TransportType};
563
564        let mut proto = make_protocol("alice");
565
566        let mock = MockTransport::new(TransportType::BLE);
567        mock.start().unwrap();
568
569        // First, receive a normal message from mallory (before blocking)
570        let msg = Message::new(
571            UserId::new("mallory").unwrap(),
572            UserId::new("alice").unwrap(),
573            AppId::new("test-app").unwrap(),
574            "first message",
575        );
576        let msg_clone = msg.clone();
577        mock.queue_message(msg);
578
579        proto
580            .transport_manager_mut()
581            .add_transport(TransportType::BLE, Box::new(mock));
582        proto.start().unwrap();
583
584        // Receive the first message normally
585        let received = proto.receive_message();
586        assert!(received.is_some());
587
588        // Now block mallory
589        proto.block_user("mallory").unwrap();
590
591        // Re-send the same message (duplicate) — should be silently discarded.
592        // The dedup path should NOT send a re-ACK because the sender is blocked.
593        let mock2 = MockTransport::new(TransportType::BLE);
594        mock2.start().unwrap();
595        mock2.queue_message(msg_clone);
596        proto
597            .transport_manager_mut()
598            .add_transport(TransportType::BLE, Box::new(mock2));
599
600        // This should return None without sending any ACK
601        assert!(proto.receive_message().is_none());
602    }
603
604    #[test]
605    fn test_get_blocked_users_sorted() {
606        let mut proto = make_protocol("alice");
607        proto.block_user("charlie").unwrap();
608        proto.block_user("bob").unwrap();
609        proto.block_user("dave").unwrap();
610
611        let blocked = proto.get_blocked_users();
612        assert_eq!(blocked, vec!["bob", "charlie", "dave"]);
613    }
614
615    #[test]
616    fn test_presence_to_blocked_user_rejected() {
617        use crate::events::PresenceStatus;
618        use offline_protocol_transport::{mock::MockTransport, TransportType};
619
620        let mut proto = make_protocol("alice");
621
622        let mock = MockTransport::new(TransportType::BLE);
623        mock.start().unwrap();
624        proto
625            .transport_manager_mut()
626            .add_transport(TransportType::BLE, Box::new(mock));
627        proto.start().unwrap();
628
629        proto.block_user("mallory").unwrap();
630
631        let result = proto.send_presence_update("mallory", PresenceStatus::Online);
632        assert!(matches!(
633            result,
634            Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
635        ));
636    }
637
638    #[test]
639    fn test_typing_indicator_to_blocked_user_rejected() {
640        use offline_protocol_transport::{mock::MockTransport, TransportType};
641
642        let mut proto = make_protocol("alice");
643
644        let mock = MockTransport::new(TransportType::BLE);
645        mock.start().unwrap();
646        proto
647            .transport_manager_mut()
648            .add_transport(TransportType::BLE, Box::new(mock));
649        proto.start().unwrap();
650
651        proto.block_user("mallory").unwrap();
652
653        let result = proto.send_typing_indicator("mallory", "conv-1", true);
654        assert!(matches!(
655            result,
656            Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
657        ));
658    }
659
660    #[test]
661    fn test_read_receipt_to_blocked_user_rejected() {
662        use offline_protocol_transport::{mock::MockTransport, TransportType};
663
664        let mut proto = make_protocol("alice");
665
666        let mock = MockTransport::new(TransportType::BLE);
667        mock.start().unwrap();
668        proto
669            .transport_manager_mut()
670            .add_transport(TransportType::BLE, Box::new(mock));
671        proto.start().unwrap();
672
673        proto.block_user("mallory").unwrap();
674
675        let result = proto.send_read_receipt("mallory", vec!["msg-1".to_string()]);
676        assert!(matches!(
677            result,
678            Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
679        ));
680    }
681
682    #[test]
683    fn test_block_cap_enforced() {
684        use crate::protocol::types::MAX_BLOCKED_USERS;
685
686        let mut proto = make_protocol("alice");
687        for i in 0..MAX_BLOCKED_USERS {
688            proto.block_user(&format!("user-{i}")).unwrap();
689        }
690        assert_eq!(proto.blocked_users.len(), MAX_BLOCKED_USERS);
691
692        // One more should fail
693        let result = proto.block_user("one-too-many");
694        assert!(result.is_err());
695        assert!(result
696            .unwrap_err()
697            .to_string()
698            .contains("Blocked users limit reached"));
699
700        // But re-blocking an existing user should still succeed (idempotent)
701        proto.block_user("user-0").unwrap();
702    }
703
704    #[test]
705    fn test_on_neighbor_discovered_skips_blocked_user() {
706        let mut proto = make_protocol("alice");
707        proto.block_user("mallory").unwrap();
708
709        // Calling on_neighbor_discovered for a blocked peer should NOT
710        // add them to known_peers or trigger key exchange tracking.
711        proto.on_neighbor_discovered("mallory");
712        assert!(
713            !proto.known_peers.contains_key("mallory"),
714            "Blocked user should not be added to known_peers"
715        );
716        assert!(
717            !proto.key_package_sent_to.contains("mallory"),
718            "Blocked user should not trigger key package exchange"
719        );
720
721        // Non-blocked peer should be tracked normally
722        proto.on_neighbor_discovered("bob");
723        assert!(
724            proto.known_peers.contains_key("bob"),
725            "Non-blocked peer should be tracked"
726        );
727    }
728
729    #[test]
730    fn test_cleanup_cancels_inbound_file_transfers() {
731        use crate::file_transfer::FileChunk;
732        use offline_protocol_core::ContentType;
733
734        let mut proto = make_protocol("alice");
735
736        // Simulate an in-progress inbound file transfer from bob by inserting
737        // a partial assembly and its pending media metadata.
738        let chunk = FileChunk {
739            file_id: "file-from-bob".to_string(),
740            file_name: "photo.jpg".to_string(),
741            file_size: 1000,
742            total_chunks: 10,
743            chunk_index: 0,
744            chunk_data: vec![0u8; 100],
745            file_checksum: "abc123".to_string(),
746        };
747        proto
748            .file_transfer_manager
749            .process_chunk("bob", chunk)
750            .unwrap();
751
752        use crate::protocol::types::PendingMediaMetadataEntry;
753        use std::time::Instant;
754        proto.pending_media_metadata.insert(
755            "file-from-bob".to_string(),
756            PendingMediaMetadataEntry {
757                content_type: ContentType::Image,
758                media_metadata: None,
759                last_updated_at: Instant::now(),
760                sender: "bob".to_string(),
761                rich_extras: None,
762                timestamp_ms: 0,
763            },
764        );
765
766        // Block then unblock — cleanup should cancel the transfer
767        proto.block_user("bob").unwrap();
768        proto.unblock_user("bob").unwrap();
769
770        assert!(
771            proto
772                .file_transfer_manager
773                .get_progress("file-from-bob")
774                .is_none(),
775            "Inbound file transfer should be cancelled on unblock cleanup"
776        );
777        assert!(
778            !proto.pending_media_metadata.contains_key("file-from-bob"),
779            "Pending media metadata should be removed on unblock cleanup"
780        );
781    }
782
783    #[test]
784    fn test_rejected_file_chunk_leaves_no_state() {
785        use crate::file_transfer::FileChunk;
786        use offline_protocol_core::{AppId, Message, UserId};
787
788        let mut proto = make_protocol("alice");
789
790        let chunk_message = |chunk: &FileChunk| {
791            Message::new(
792                UserId::new("bob").unwrap(),
793                UserId::new("alice").unwrap(),
794                AppId::new("test-app").unwrap(),
795                chunk.to_json().unwrap(),
796            )
797        };
798
799        // Control: an in-bounds chunk 0 is accepted and records its metadata.
800        let good = FileChunk {
801            file_id: "good-file".to_string(),
802            file_name: "photo.jpg".to_string(),
803            file_size: 11,
804            total_chunks: 2,
805            chunk_index: 0,
806            chunk_data: vec![1u8; 6],
807            file_checksum: "abc".to_string(),
808        };
809        let _ = proto.handle_incoming_file_chunk(&chunk_message(&good));
810        assert!(proto.pending_media_metadata.contains_key("good-file"));
811        assert_eq!(proto.file_transfer_manager.active_transfer_count(), 1);
812
813        // SEC-H2: a chunk with an absurd file_size claim is rejected and
814        // leaves neither an assembly nor pending media metadata behind.
815        let evil = FileChunk {
816            file_id: "evil-file".to_string(),
817            file_name: "evil.bin".to_string(),
818            file_size: u64::MAX,
819            total_chunks: 1,
820            chunk_index: 0,
821            chunk_data: vec![0u8; 16],
822            file_checksum: "def".to_string(),
823        };
824        let _ = proto.handle_incoming_file_chunk(&chunk_message(&evil));
825        assert!(!proto.pending_media_metadata.contains_key("evil-file"));
826        assert_eq!(proto.file_transfer_manager.active_transfer_count(), 1);
827    }
828
829    #[test]
830    fn test_resource_rejected_file_chunk_emits_receive_failed_event() {
831        use crate::events::Event;
832        use crate::file_transfer::FileChunk;
833        use offline_protocol_core::{AppId, Message, UserId};
834        use std::sync::{Arc, Mutex};
835
836        let mut proto = make_protocol("alice");
837        proto.file_transfer_manager = crate::file_transfer::FileTransferManager::with_config(
838            crate::file_transfer::FileTransferConfig {
839                max_concurrent_assemblies: 1,
840                ..crate::file_transfer::FileTransferConfig::default()
841            },
842        );
843
844        let failed_events = Arc::new(Mutex::new(Vec::<Event>::new()));
845        let failed_events_clone = failed_events.clone();
846        proto.on_event(move |event| {
847            if matches!(event, Event::FileReceiveFailed { .. }) {
848                failed_events_clone.lock().unwrap().push(event);
849            }
850        });
851
852        let chunk_message = |chunk: &FileChunk| {
853            Message::new(
854                UserId::new("bob").unwrap(),
855                UserId::new("alice").unwrap(),
856                AppId::new("test-app").unwrap(),
857                chunk.to_json().unwrap(),
858            )
859        };
860        let make_chunk = |file_id: &str| FileChunk {
861            file_id: file_id.to_string(),
862            file_name: "photo.jpg".to_string(),
863            file_size: 1000,
864            total_chunks: 10,
865            chunk_index: 0,
866            chunk_data: vec![0u8; 100],
867            file_checksum: "abc".to_string(),
868        };
869
870        // First transfer occupies the single assembly slot.
871        let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk("file-1")));
872        assert!(failed_events.lock().unwrap().is_empty());
873
874        // The second transfer hits the cap: the app must be told the
875        // transfer is lost (the chunk was already ACKed — it will not be
876        // retransmitted).
877        let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk("file-2")));
878        let events = failed_events.lock().unwrap();
879        assert_eq!(events.len(), 1);
880        match &events[0] {
881            Event::FileReceiveFailed {
882                file_id,
883                file_name,
884                sender,
885                reason,
886            } => {
887                assert_eq!(file_id, "file-2");
888                assert_eq!(file_name, "photo.jpg");
889                assert_eq!(sender, "bob");
890                assert_eq!(reason, "too_many_transfers");
891            }
892            other => panic!("unexpected event: {:?}", other),
893        }
894    }
895
896    #[test]
897    fn test_resource_rejected_transfer_emits_single_failed_event() {
898        use crate::events::Event;
899        use crate::file_transfer::FileChunk;
900        use offline_protocol_core::{AppId, Message, UserId};
901        use std::sync::{Arc, Mutex};
902
903        let mut proto = make_protocol("alice");
904        proto.file_transfer_manager = crate::file_transfer::FileTransferManager::with_config(
905            crate::file_transfer::FileTransferConfig {
906                max_concurrent_assemblies: 1,
907                ..crate::file_transfer::FileTransferConfig::default()
908            },
909        );
910
911        let failed_events = Arc::new(Mutex::new(Vec::<Event>::new()));
912        let failed_events_clone = failed_events.clone();
913        proto.on_event(move |event| {
914            if matches!(event, Event::FileReceiveFailed { .. }) {
915                failed_events_clone.lock().unwrap().push(event);
916            }
917        });
918
919        let chunk_message = |chunk: &FileChunk| {
920            Message::new(
921                UserId::new("bob").unwrap(),
922                UserId::new("alice").unwrap(),
923                AppId::new("test-app").unwrap(),
924                chunk.to_json().unwrap(),
925            )
926        };
927        let make_chunk = |file_id: &str, index: u32| FileChunk {
928            file_id: file_id.to_string(),
929            file_name: "photo.jpg".to_string(),
930            file_size: 1000,
931            total_chunks: 10,
932            chunk_index: index,
933            chunk_data: vec![0u8; 100],
934            file_checksum: "abc".to_string(),
935        };
936
937        // First transfer occupies the single assembly slot.
938        let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk("file-1", 0)));
939
940        // Every chunk of the second transfer was already ACKed and keeps
941        // streaming in after the rejection. The app must be told the
942        // transfer is lost exactly once — not once per remaining chunk.
943        for index in 0..6u32 {
944            let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk("file-2", index)));
945        }
946
947        let events = failed_events.lock().unwrap();
948        assert_eq!(events.len(), 1);
949        match &events[0] {
950            Event::FileReceiveFailed {
951                file_id, reason, ..
952            } => {
953                assert_eq!(file_id, "file-2");
954                assert_eq!(reason, "too_many_transfers");
955            }
956            other => panic!("unexpected event: {:?}", other),
957        }
958        // And no ghost assembly formed for the failed transfer.
959        assert_eq!(proto.file_transfer_manager.active_transfer_count(), 1);
960        assert!(proto.file_transfer_manager.get_progress("file-2").is_none());
961    }
962
963    #[test]
964    fn test_budget_dropped_transfer_emits_no_second_stale_event() {
965        use crate::events::Event;
966        use crate::file_transfer::FileChunk;
967        use crate::protocol::MEDIA_TRANSFER_STALE_TIMEOUT_SECS;
968        use offline_protocol_core::{AppId, Message, UserId};
969        use std::sync::{Arc, Mutex};
970        use std::time::Duration;
971
972        let mut proto = make_protocol("alice");
973        proto.file_transfer_manager = crate::file_transfer::FileTransferManager::with_config(
974            crate::file_transfer::FileTransferConfig {
975                max_file_size: 1_000_000,
976                max_total_buffered_bytes: 1_065_536,
977                ..crate::file_transfer::FileTransferConfig::default()
978            },
979        );
980
981        let failed_events = Arc::new(Mutex::new(Vec::<Event>::new()));
982        let failed_events_clone = failed_events.clone();
983        proto.on_event(move |event| {
984            if matches!(event, Event::FileReceiveFailed { .. }) {
985                failed_events_clone.lock().unwrap().push(event);
986            }
987        });
988
989        let chunk_message = |chunk: &FileChunk| {
990            Message::new(
991                UserId::new("bob").unwrap(),
992                UserId::new("alice").unwrap(),
993                AppId::new("test-app").unwrap(),
994                chunk.to_json().unwrap(),
995            )
996        };
997        let make_chunk =
998            |file_id: &str, file_size: u64, total: u32, index: u32, len: usize| FileChunk {
999                file_id: file_id.to_string(),
1000                file_name: "big.bin".to_string(),
1001                file_size,
1002                total_chunks: total,
1003                chunk_index: index,
1004                chunk_data: vec![0u8; len],
1005                file_checksum: "abc".to_string(),
1006            };
1007
1008        // f1 and f2 fill most of the budget; f1's next chunk busts it and
1009        // drops f1 with a buffer_budget_exhausted event.
1010        let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk(
1011            "f1", 1_000_000, 3, 0, 600_000,
1012        )));
1013        let _ = proto
1014            .handle_incoming_file_chunk(&chunk_message(&make_chunk("f2", 400_000, 2, 0, 300_000)));
1015        let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk(
1016            "f1", 1_000_000, 3, 1, 300_000,
1017        )));
1018        assert_eq!(failed_events.lock().unwrap().len(), 1);
1019
1020        // f1's last in-flight chunk must not resurrect it as a
1021        // never-completable assembly.
1022        let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk(
1023            "f1", 1_000_000, 3, 2, 100_000,
1024        )));
1025        assert_eq!(proto.file_transfer_manager.active_transfer_count(), 1);
1026        assert!(proto.file_transfer_manager.get_progress("f1").is_none());
1027
1028        // The stale sweep must not report f1 a second time; only f2 (still
1029        // partially assembled and now stale) fails.
1030        proto
1031            .file_transfer_manager
1032            .backdate_transfers(Duration::from_secs(MEDIA_TRANSFER_STALE_TIMEOUT_SECS + 1));
1033        proto.cleanup_expired_entries();
1034
1035        let events = failed_events.lock().unwrap();
1036        let f1_reasons: Vec<&str> = events
1037            .iter()
1038            .filter_map(|e| match e {
1039                Event::FileReceiveFailed {
1040                    file_id, reason, ..
1041                } if file_id == "f1" => Some(reason.as_str()),
1042                _ => None,
1043            })
1044            .collect();
1045        assert_eq!(f1_reasons, vec!["buffer_budget_exhausted"]);
1046        let f2_reasons: Vec<&str> = events
1047            .iter()
1048            .filter_map(|e| match e {
1049                Event::FileReceiveFailed {
1050                    file_id, reason, ..
1051                } if file_id == "f2" => Some(reason.as_str()),
1052                _ => None,
1053            })
1054            .collect();
1055        assert_eq!(f2_reasons, vec!["stale_timeout"]);
1056    }
1057
1058    #[test]
1059    fn test_malformed_chunk_emits_no_failed_event() {
1060        use crate::events::Event;
1061        use crate::file_transfer::FileChunk;
1062        use offline_protocol_core::{AppId, Message, UserId};
1063        use std::sync::{Arc, Mutex};
1064
1065        let mut proto = make_protocol("alice");
1066
1067        let failed_events = Arc::new(Mutex::new(Vec::<Event>::new()));
1068        let failed_events_clone = failed_events.clone();
1069        proto.on_event(move |event| {
1070            if matches!(event, Event::FileReceiveFailed { .. }) {
1071                failed_events_clone.lock().unwrap().push(event);
1072            }
1073        });
1074
1075        // An old-format sender chunking a max-size file below the 1 KiB
1076        // payload floor claims more chunks than the derived cap. Such
1077        // attacker-shaped input is dropped with only a warning log — no
1078        // FileReceiveFailed, no state.
1079        let chunk = FileChunk {
1080            file_id: "fine-chunked".to_string(),
1081            file_name: "old.bin".to_string(),
1082            file_size: 100 * 1024 * 1024,
1083            total_chunks: 200_000,
1084            chunk_index: 0,
1085            chunk_data: vec![0u8; 512],
1086            file_checksum: "abc".to_string(),
1087        };
1088        let message = Message::new(
1089            UserId::new("bob").unwrap(),
1090            UserId::new("alice").unwrap(),
1091            AppId::new("test-app").unwrap(),
1092            chunk.to_json().unwrap(),
1093        );
1094        let _ = proto.handle_incoming_file_chunk(&message);
1095
1096        assert!(failed_events.lock().unwrap().is_empty());
1097        assert_eq!(proto.file_transfer_manager.active_transfer_count(), 0);
1098        assert!(!proto.pending_media_metadata.contains_key("fine-chunked"));
1099    }
1100
1101    #[test]
1102    fn test_finalize_failure_clears_metadata_and_emits_event() {
1103        use crate::events::Event;
1104        use crate::file_transfer::FileChunk;
1105        use offline_protocol_core::{AppId, Message, UserId};
1106        use sha2::{Digest, Sha256};
1107        use std::sync::{Arc, Mutex};
1108
1109        let mut proto = make_protocol("alice");
1110
1111        let failed_events = Arc::new(Mutex::new(Vec::<Event>::new()));
1112        let failed_events_clone = failed_events.clone();
1113        proto.on_event(move |event| {
1114            if matches!(event, Event::FileReceiveFailed { .. }) {
1115                failed_events_clone.lock().unwrap().push(event);
1116            }
1117        });
1118
1119        // A single chunk whose checksum matches its bytes, but whose claimed
1120        // file_size does not — the transfer completes, then fails the
1121        // finalize size check.
1122        let data = vec![3u8; 100];
1123        let checksum = format!("{:x}", Sha256::digest(&data));
1124        let chunk = FileChunk {
1125            file_id: "bad-file".to_string(),
1126            file_name: "evil.bin".to_string(),
1127            file_size: 2048,
1128            total_chunks: 1,
1129            chunk_index: 0,
1130            chunk_data: data,
1131            file_checksum: checksum,
1132        };
1133        let message = Message::new(
1134            UserId::new("bob").unwrap(),
1135            UserId::new("alice").unwrap(),
1136            AppId::new("test-app").unwrap(),
1137            chunk.to_json().unwrap(),
1138        );
1139        let _ = proto.handle_incoming_file_chunk(&message);
1140
1141        // The failed transfer leaves no assembly and no pending metadata.
1142        assert_eq!(proto.file_transfer_manager.active_transfer_count(), 0);
1143        assert!(!proto.pending_media_metadata.contains_key("bad-file"));
1144
1145        let events = failed_events.lock().unwrap();
1146        assert_eq!(events.len(), 1);
1147        match &events[0] {
1148            Event::FileReceiveFailed {
1149                file_id, reason, ..
1150            } => {
1151                assert_eq!(file_id, "bad-file");
1152                assert_eq!(reason, "integrity_check_failed");
1153            }
1154            other => panic!("unexpected event: {:?}", other),
1155        }
1156    }
1157
1158    #[test]
1159    fn test_stale_transfer_sweep_emits_receive_failed_event() {
1160        use crate::events::Event;
1161        use crate::file_transfer::FileChunk;
1162        use crate::protocol::MEDIA_TRANSFER_STALE_TIMEOUT_SECS;
1163        use offline_protocol_core::{AppId, Message, UserId};
1164        use std::sync::{Arc, Mutex};
1165        use std::time::Duration;
1166
1167        let mut proto = make_protocol("alice");
1168
1169        let failed_events = Arc::new(Mutex::new(Vec::<Event>::new()));
1170        let failed_events_clone = failed_events.clone();
1171        proto.on_event(move |event| {
1172            if matches!(event, Event::FileReceiveFailed { .. }) {
1173                failed_events_clone.lock().unwrap().push(event);
1174            }
1175        });
1176
1177        // A partial inbound transfer from bob (1 of 2 chunks).
1178        let chunk = FileChunk {
1179            file_id: "stale-file".to_string(),
1180            file_name: "photo.jpg".to_string(),
1181            file_size: 200,
1182            total_chunks: 2,
1183            chunk_index: 0,
1184            chunk_data: vec![0u8; 100],
1185            file_checksum: "abc".to_string(),
1186        };
1187        let message = Message::new(
1188            UserId::new("bob").unwrap(),
1189            UserId::new("alice").unwrap(),
1190            AppId::new("test-app").unwrap(),
1191            chunk.to_json().unwrap(),
1192        );
1193        let _ = proto.handle_incoming_file_chunk(&message);
1194        assert_eq!(proto.file_transfer_manager.active_transfer_count(), 1);
1195        assert!(proto.pending_media_metadata.contains_key("stale-file"));
1196
1197        // Age the assembly past the stale timeout and run the periodic
1198        // sweep — the app must hear about the dropped transfer (its chunks
1199        // were ACKed and will never be retransmitted).
1200        proto
1201            .file_transfer_manager
1202            .backdate_transfers(Duration::from_secs(MEDIA_TRANSFER_STALE_TIMEOUT_SECS + 1));
1203        proto.cleanup_expired_entries();
1204
1205        assert_eq!(proto.file_transfer_manager.active_transfer_count(), 0);
1206        assert!(!proto.pending_media_metadata.contains_key("stale-file"));
1207        let events = failed_events.lock().unwrap();
1208        assert_eq!(events.len(), 1);
1209        match &events[0] {
1210            Event::FileReceiveFailed {
1211                file_id,
1212                file_name,
1213                sender,
1214                reason,
1215            } => {
1216                assert_eq!(file_id, "stale-file");
1217                assert_eq!(file_name, "photo.jpg");
1218                assert_eq!(sender, "bob");
1219                assert_eq!(reason, "stale_timeout");
1220            }
1221            other => panic!("unexpected event: {:?}", other),
1222        }
1223    }
1224
1225    #[test]
1226    fn test_connection_request_to_blocked_user_rejected() {
1227        use offline_protocol_transport::{mock::MockTransport, TransportType};
1228
1229        let mut proto = make_protocol("alice");
1230
1231        let mock = MockTransport::new(TransportType::BLE);
1232        mock.start().unwrap();
1233        proto
1234            .transport_manager_mut()
1235            .add_transport(TransportType::BLE, Box::new(mock));
1236        proto.start().unwrap();
1237
1238        proto.block_user("mallory").unwrap();
1239
1240        let result = proto.send_connection_request("mallory", "Alice", None, None);
1241        assert!(matches!(
1242            result,
1243            Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
1244        ));
1245    }
1246
1247    #[test]
1248    fn test_accept_connection_request_to_blocked_user_rejected() {
1249        use offline_protocol_transport::{mock::MockTransport, TransportType};
1250
1251        let mut proto = make_protocol("alice");
1252
1253        let mock = MockTransport::new(TransportType::BLE);
1254        mock.start().unwrap();
1255        proto
1256            .transport_manager_mut()
1257            .add_transport(TransportType::BLE, Box::new(mock));
1258        proto.start().unwrap();
1259
1260        proto.block_user("mallory").unwrap();
1261
1262        let result = proto.accept_connection_request("mallory", "Alice", None);
1263        assert!(matches!(
1264            result,
1265            Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
1266        ));
1267    }
1268
1269    #[test]
1270    fn test_reject_connection_request_to_blocked_user_rejected() {
1271        use offline_protocol_transport::{mock::MockTransport, TransportType};
1272
1273        let mut proto = make_protocol("alice");
1274
1275        let mock = MockTransport::new(TransportType::BLE);
1276        mock.start().unwrap();
1277        proto
1278            .transport_manager_mut()
1279            .add_transport(TransportType::BLE, Box::new(mock));
1280        proto.start().unwrap();
1281
1282        proto.block_user("mallory").unwrap();
1283
1284        let result = proto.reject_connection_request("mallory");
1285        assert!(matches!(
1286            result,
1287            Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
1288        ));
1289    }
1290
1291    #[test]
1292    fn test_restore_blocked_users_skips_invalid_entries() {
1293        use crate::mls::InMemoryStorage;
1294        use offline_protocol_mls::MlsStorage;
1295
1296        let mut proto = make_protocol("alice");
1297        let storage = Arc::new(InMemoryStorage::new());
1298        proto
1299            .enable_message_persistence_for_test(storage.clone())
1300            .unwrap();
1301
1302        // Directly write valid and invalid entries into storage
1303        storage.store("blocked_users", "bob", &[]).unwrap();
1304        storage.store("blocked_users", "", &[]).unwrap(); // invalid: empty user ID
1305
1306        proto.blocked_users.clear();
1307        proto.restore_blocked_users().unwrap();
1308
1309        // "bob" should be restored, "" should be skipped
1310        assert!(proto.is_user_blocked("bob"));
1311        assert!(!proto.is_user_blocked(""));
1312        assert_eq!(proto.blocked_users.len(), 1);
1313    }
1314
1315    #[test]
1316    fn test_session_reset_key_package_clears_stale_session() {
1317        use crate::mls::InMemoryStorage as MlsInMemoryStorage;
1318        use crate::protocol::types::KeyPackagePayload;
1319
1320        // Set up alice with MLS
1321        let mut alice = make_protocol("alice");
1322        let alice_storage = Arc::new(MlsInMemoryStorage::new());
1323        alice.initialize_mls_for_test(alice_storage).unwrap();
1324
1325        // Set up bob with MLS
1326        let mut bob = make_protocol("bob");
1327        let bob_storage = Arc::new(MlsInMemoryStorage::new());
1328        bob.initialize_mls_for_test(bob_storage).unwrap();
1329
1330        // Exchange key packages to establish a session
1331        let alice_key_pkg = {
1332            let mls = alice.mls_manager.as_ref().unwrap();
1333            let manager = mls.read().unwrap();
1334            manager.get_or_create_key_package().unwrap()
1335        };
1336        // Bob imports Alice's key package and creates session
1337        {
1338            let mls = bob.mls_manager.as_ref().unwrap();
1339            let manager = mls.read().unwrap();
1340            manager
1341                .import_key_package(&id("alice"), &alice_key_pkg.key_package_data)
1342                .unwrap();
1343            let welcome = manager.create_session(&id("alice")).unwrap();
1344            // Alice joins via welcome
1345            let alice_mls = alice.mls_manager.as_ref().unwrap();
1346            let alice_manager = alice_mls.read().unwrap();
1347            alice_manager.join_session(&welcome).unwrap();
1348        }
1349
1350        // Verify both have sessions
1351        {
1352            let mls = alice.mls_manager.as_ref().unwrap();
1353            assert!(mls.read().unwrap().has_session(&id("bob")).unwrap());
1354        }
1355        {
1356            let mls = bob.mls_manager.as_ref().unwrap();
1357            assert!(mls.read().unwrap().has_session(&id("alice")).unwrap());
1358        }
1359
1360        // Now simulate: Alice blocks then unblocks Bob.
1361        // Alice's side deletes her session (done by unblock_user internally).
1362        alice.block_user(&id("bob")).unwrap();
1363        alice.unblock_user(&id("bob")).unwrap();
1364
1365        // Verify Alice no longer has a session with Bob
1366        {
1367            let mls = alice.mls_manager.as_ref().unwrap();
1368            assert!(
1369                !mls.read().unwrap().has_session(&id("bob")).unwrap(),
1370                "Alice should have no session after unblock cleanup"
1371            );
1372        }
1373
1374        // Bob still has the stale session
1375        {
1376            let mls = bob.mls_manager.as_ref().unwrap();
1377            assert!(
1378                mls.read().unwrap().has_session(&id("alice")).unwrap(),
1379                "Bob still has the old session before receiving reset"
1380            );
1381        }
1382
1383        // Simulate Bob receiving Alice's key package with session_reset=true
1384        let alice_fresh_key_pkg = {
1385            let mls = alice.mls_manager.as_ref().unwrap();
1386            let manager = mls.read().unwrap();
1387            manager.get_or_create_key_package().unwrap()
1388        };
1389        let reset_payload = KeyPackagePayload {
1390            user_id: id("alice"),
1391            key_package_data: alice_fresh_key_pkg.key_package_data.clone(),
1392            remaining_lifetime_ms: alice_fresh_key_pkg.remaining_lifetime_ms(),
1393            timestamp_ms: 0,
1394            session_reset: true,
1395            wire_versions: Vec::new(),
1396            env_versions: Vec::new(),
1397            rich_versions: Vec::new(),
1398            nostr_pubkey: None,
1399        };
1400        let content = serde_json::to_string(&reset_payload).unwrap();
1401
1402        // Bob handles the key package with session_reset=true.
1403        // This deletes the stale session and auto-establishes a fresh one
1404        // using Alice's new key package.
1405        bob.handle_key_package_message(&id("alice"), &content, true);
1406
1407        // Bob should have a session (the NEW one, auto-established from
1408        // Alice's fresh key package — NOT the stale orphaned session).
1409        {
1410            let mls = bob.mls_manager.as_ref().unwrap();
1411            let manager = mls.read().unwrap();
1412            assert!(
1413                manager.has_session(&id("alice")).unwrap(),
1414                "Bob should have a fresh session after session_reset + auto-establish"
1415            );
1416        }
1417
1418        // The old confirmed_sessions entry should be gone (cleared during
1419        // session delete), proving the stale session was replaced.
1420        assert!(
1421            !bob.confirmed_sessions.contains(&id("alice")),
1422            "Old confirmed session entry should be cleared"
1423        );
1424
1425        // The pending key package should have been consumed by auto-establish
1426        assert!(
1427            !bob.pending_key_packages.contains_key(&id("alice")),
1428            "Key package should be consumed after auto-establish"
1429        );
1430    }
1431}