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