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