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