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.key_package_sent_to.insert(id("bob"));
433
434        // Queue a pending encrypted message for bob
435        let pending_msg = Message::new(
436            UserId::new(&id("alice")).unwrap(),
437            UserId::new(&id("bob")).unwrap(),
438            AppId::new("test-app").unwrap(),
439            "queued",
440        );
441        proto.enqueue_pending_decryption(&id("bob"), &pending_msg);
442
443        // Block then unblock — should clear all state
444        proto.block_user(&id("bob")).unwrap();
445        proto.unblock_user(&id("bob")).unwrap();
446
447        assert!(proto.pending_key_packages.get(&id("bob")).is_none());
448        assert!(!proto.key_package_sent_to.contains(&id("bob")));
449        assert!(!proto.confirmed_sessions.contains(&id("bob")));
450        assert!(proto
451            .pending_queue
452            .drain_for_peer(&proto.config.encryption.pending_queue, &id("bob"))
453            .is_empty());
454    }
455
456    #[test]
457    fn test_unblock_then_receive() {
458        use offline_protocol_core::{AppId, Message, UserId};
459        use offline_protocol_transport::{mock::MockTransport, TransportType};
460
461        let mut proto = make_protocol("alice");
462        proto.block_user("bob").unwrap();
463        proto.unblock_user("bob").unwrap();
464
465        let mock = MockTransport::new(TransportType::BLE);
466        mock.start().unwrap();
467
468        let msg = Message::new(
469            UserId::new("bob").unwrap(),
470            UserId::new("alice").unwrap(),
471            AppId::new("test-app").unwrap(),
472            "hello again",
473        );
474        let msg_id = msg.id.clone();
475        mock.queue_message(msg);
476
477        proto
478            .transport_manager_mut()
479            .add_transport(TransportType::BLE, Box::new(mock));
480        proto.start().unwrap();
481
482        // After unblocking, messages should flow normally
483        let received = proto.receive_message();
484        assert!(
485            received.is_some(),
486            "Messages should be received after unblocking"
487        );
488        assert_eq!(received.unwrap().id, msg_id);
489    }
490
491    #[test]
492    fn test_send_to_blocked_user_rejected() {
493        use offline_protocol_transport::{mock::MockTransport, TransportType};
494
495        let mut proto = make_protocol("alice");
496
497        let mock = MockTransport::new(TransportType::BLE);
498        mock.start().unwrap();
499        proto
500            .transport_manager_mut()
501            .add_transport(TransportType::BLE, Box::new(mock));
502        proto.start().unwrap();
503
504        proto.block_user("mallory").unwrap();
505
506        let result = proto.send_message("mallory", "hello", None, None::<String>);
507        assert!(matches!(
508            result,
509            Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
510        ));
511    }
512
513    #[test]
514    fn test_send_via_transport_to_blocked_user_rejected() {
515        use offline_protocol_transport::{mock::MockTransport, TransportType};
516
517        let mut proto = make_protocol("alice");
518
519        let mock = MockTransport::new(TransportType::BLE);
520        mock.start().unwrap();
521        proto
522            .transport_manager_mut()
523            .add_transport(TransportType::BLE, Box::new(mock));
524        proto.start().unwrap();
525
526        proto.block_user("mallory").unwrap();
527
528        let result = proto.send_message_via_transport(
529            "mallory",
530            "hello",
531            None,
532            TransportType::BLE,
533            None::<String>,
534        );
535        assert!(matches!(
536            result,
537            Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
538        ));
539    }
540
541    #[test]
542    fn test_send_media_to_blocked_user_rejected() {
543        use offline_protocol_core::ContentType;
544        use offline_protocol_transport::{mock::MockTransport, TransportType};
545
546        let mut proto = make_protocol("alice");
547
548        let mock = MockTransport::new(TransportType::BLE);
549        mock.start().unwrap();
550        proto
551            .transport_manager_mut()
552            .add_transport(TransportType::BLE, Box::new(mock));
553        proto.start().unwrap();
554
555        proto.block_user("mallory").unwrap();
556
557        let result = proto.send_media(
558            "mallory",
559            vec![0u8; 100],
560            "photo.jpg",
561            ContentType::Image,
562            None,
563        );
564        assert!(matches!(
565            result,
566            Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
567        ));
568    }
569
570    #[test]
571    fn test_pending_decryption_drops_blocked_sender() {
572        use offline_protocol_core::{AppId, Message, UserId};
573
574        let mut proto = make_protocol("alice");
575
576        // Enqueue a pending message from bob before blocking
577        let msg = Message::new(
578            UserId::new("bob").unwrap(),
579            UserId::new("alice").unwrap(),
580            AppId::new("test-app").unwrap(),
581            "secret",
582        );
583        proto.enqueue_pending_decryption("bob", &msg);
584
585        // Now block bob
586        proto.block_user("bob").unwrap();
587
588        // process_pending_decryption should silently discard bob's messages
589        proto.process_pending_decryption("bob");
590
591        // Verify nothing was surfaced to the app
592        let state = crate::protocol::lock_shared_state(&proto.shared_state).unwrap();
593        assert!(
594            state.received_messages.is_empty(),
595            "Pending messages from blocked user should not be surfaced"
596        );
597    }
598
599    #[test]
600    fn test_duplicate_from_blocked_user_no_ack() {
601        use offline_protocol_core::{AppId, Message, UserId};
602        use offline_protocol_transport::{mock::MockTransport, TransportType};
603
604        let mut proto = make_protocol("alice");
605
606        let mock = MockTransport::new(TransportType::BLE);
607        mock.start().unwrap();
608
609        // First, receive a normal message from mallory (before blocking)
610        let msg = Message::new(
611            UserId::new("mallory").unwrap(),
612            UserId::new("alice").unwrap(),
613            AppId::new("test-app").unwrap(),
614            "first message",
615        );
616        let msg_clone = msg.clone();
617        mock.queue_message(msg);
618
619        proto
620            .transport_manager_mut()
621            .add_transport(TransportType::BLE, Box::new(mock));
622        proto.start().unwrap();
623
624        // Receive the first message normally
625        let received = proto.receive_message();
626        assert!(received.is_some());
627
628        // Now block mallory
629        proto.block_user("mallory").unwrap();
630
631        // Re-send the same message (duplicate) — should be silently discarded.
632        // The dedup path should NOT send a re-ACK because the sender is blocked.
633        let mock2 = MockTransport::new(TransportType::BLE);
634        mock2.start().unwrap();
635        mock2.queue_message(msg_clone);
636        proto
637            .transport_manager_mut()
638            .add_transport(TransportType::BLE, Box::new(mock2));
639
640        // This should return None without sending any ACK
641        assert!(proto.receive_message().is_none());
642    }
643
644    #[test]
645    fn test_get_blocked_users_sorted() {
646        let mut proto = make_protocol("alice");
647        proto.block_user("charlie").unwrap();
648        proto.block_user("bob").unwrap();
649        proto.block_user("dave").unwrap();
650
651        let blocked = proto.get_blocked_users();
652        assert_eq!(blocked, vec!["bob", "charlie", "dave"]);
653    }
654
655    #[test]
656    fn test_presence_to_blocked_user_rejected() {
657        use crate::events::PresenceStatus;
658        use offline_protocol_transport::{mock::MockTransport, TransportType};
659
660        let mut proto = make_protocol("alice");
661
662        let mock = MockTransport::new(TransportType::BLE);
663        mock.start().unwrap();
664        proto
665            .transport_manager_mut()
666            .add_transport(TransportType::BLE, Box::new(mock));
667        proto.start().unwrap();
668
669        proto.block_user("mallory").unwrap();
670
671        let result = proto.send_presence_update("mallory", PresenceStatus::Online);
672        assert!(matches!(
673            result,
674            Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
675        ));
676    }
677
678    #[test]
679    fn test_typing_indicator_to_blocked_user_rejected() {
680        use offline_protocol_transport::{mock::MockTransport, TransportType};
681
682        let mut proto = make_protocol("alice");
683
684        let mock = MockTransport::new(TransportType::BLE);
685        mock.start().unwrap();
686        proto
687            .transport_manager_mut()
688            .add_transport(TransportType::BLE, Box::new(mock));
689        proto.start().unwrap();
690
691        proto.block_user("mallory").unwrap();
692
693        let result = proto.send_typing_indicator("mallory", "conv-1", true);
694        assert!(matches!(
695            result,
696            Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
697        ));
698    }
699
700    #[test]
701    fn test_read_receipt_to_blocked_user_rejected() {
702        use offline_protocol_transport::{mock::MockTransport, TransportType};
703
704        let mut proto = make_protocol("alice");
705
706        let mock = MockTransport::new(TransportType::BLE);
707        mock.start().unwrap();
708        proto
709            .transport_manager_mut()
710            .add_transport(TransportType::BLE, Box::new(mock));
711        proto.start().unwrap();
712
713        proto.block_user("mallory").unwrap();
714
715        let result = proto.send_read_receipt("mallory", vec!["msg-1".to_string()]);
716        assert!(matches!(
717            result,
718            Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
719        ));
720    }
721
722    #[test]
723    fn test_block_cap_enforced() {
724        use crate::protocol::types::MAX_BLOCKED_USERS;
725
726        let mut proto = make_protocol("alice");
727        for i in 0..MAX_BLOCKED_USERS {
728            proto.block_user(&format!("user-{i}")).unwrap();
729        }
730        assert_eq!(proto.blocked_users.len(), MAX_BLOCKED_USERS);
731
732        // One more should fail
733        let result = proto.block_user("one-too-many");
734        assert!(result.is_err());
735        assert!(result
736            .unwrap_err()
737            .to_string()
738            .contains("Blocked users limit reached"));
739
740        // But re-blocking an existing user should still succeed (idempotent)
741        proto.block_user("user-0").unwrap();
742    }
743
744    #[test]
745    fn test_on_neighbor_discovered_skips_blocked_user() {
746        let mut proto = make_protocol("alice");
747        proto.block_user("mallory").unwrap();
748
749        // Calling on_neighbor_discovered for a blocked peer should NOT
750        // add them to known_peers or trigger key exchange tracking.
751        proto.on_neighbor_discovered("mallory");
752        assert!(
753            !proto.known_peers.contains_key("mallory"),
754            "Blocked user should not be added to known_peers"
755        );
756        assert!(
757            !proto.key_package_sent_to.contains("mallory"),
758            "Blocked user should not trigger key package exchange"
759        );
760
761        // Non-blocked peer should be tracked normally
762        proto.on_neighbor_discovered("bob");
763        assert!(
764            proto.known_peers.contains_key("bob"),
765            "Non-blocked peer should be tracked"
766        );
767    }
768
769    #[test]
770    fn test_cleanup_cancels_inbound_file_transfers() {
771        use crate::file_transfer::FileChunk;
772        use offline_protocol_core::ContentType;
773
774        let mut proto = make_protocol("alice");
775
776        // Simulate an in-progress inbound file transfer from bob by inserting
777        // a partial assembly and its pending media metadata.
778        let chunk = FileChunk {
779            file_id: "file-from-bob".to_string(),
780            file_name: "photo.jpg".to_string(),
781            file_size: 1000,
782            total_chunks: 10,
783            chunk_index: 0,
784            chunk_data: vec![0u8; 100],
785            file_checksum: "abc123".to_string(),
786        };
787        proto
788            .file_transfer_manager
789            .process_chunk("bob", chunk)
790            .unwrap();
791
792        use crate::protocol::types::PendingMediaMetadataEntry;
793        use std::time::Instant;
794        proto.pending_media_metadata.insert(
795            "file-from-bob".to_string(),
796            PendingMediaMetadataEntry {
797                data_purpose: None,
798                content_type: ContentType::Image,
799                media_metadata: None,
800                last_updated_at: Instant::now(),
801                sender: "bob".to_string(),
802                rich_extras: None,
803                timestamp_ms: 0,
804            },
805        );
806
807        // Block then unblock — cleanup should cancel the transfer
808        proto.block_user("bob").unwrap();
809        proto.unblock_user("bob").unwrap();
810
811        assert!(
812            proto
813                .file_transfer_manager
814                .get_progress("file-from-bob")
815                .is_none(),
816            "Inbound file transfer should be cancelled on unblock cleanup"
817        );
818        assert!(
819            !proto.pending_media_metadata.contains_key("file-from-bob"),
820            "Pending media metadata should be removed on unblock cleanup"
821        );
822    }
823
824    #[test]
825    fn test_rejected_file_chunk_leaves_no_state() {
826        use crate::file_transfer::FileChunk;
827        use offline_protocol_core::{AppId, Message, UserId};
828
829        let mut proto = make_protocol("alice");
830
831        let chunk_message = |chunk: &FileChunk| {
832            Message::new(
833                UserId::new("bob").unwrap(),
834                UserId::new("alice").unwrap(),
835                AppId::new("test-app").unwrap(),
836                chunk.to_json().unwrap(),
837            )
838        };
839
840        // Control: an in-bounds chunk 0 is accepted and records its metadata.
841        let good = FileChunk {
842            file_id: "good-file".to_string(),
843            file_name: "photo.jpg".to_string(),
844            file_size: 11,
845            total_chunks: 2,
846            chunk_index: 0,
847            chunk_data: vec![1u8; 6],
848            file_checksum: "abc".to_string(),
849        };
850        let _ = proto.handle_incoming_file_chunk(&chunk_message(&good));
851        assert!(proto.pending_media_metadata.contains_key("good-file"));
852        assert_eq!(proto.file_transfer_manager.active_transfer_count(), 1);
853
854        // SEC-H2: a chunk with an absurd file_size claim is rejected and
855        // leaves neither an assembly nor pending media metadata behind.
856        let evil = FileChunk {
857            file_id: "evil-file".to_string(),
858            file_name: "evil.bin".to_string(),
859            file_size: u64::MAX,
860            total_chunks: 1,
861            chunk_index: 0,
862            chunk_data: vec![0u8; 16],
863            file_checksum: "def".to_string(),
864        };
865        let _ = proto.handle_incoming_file_chunk(&chunk_message(&evil));
866        assert!(!proto.pending_media_metadata.contains_key("evil-file"));
867        assert_eq!(proto.file_transfer_manager.active_transfer_count(), 1);
868    }
869
870    #[test]
871    fn test_resource_rejected_file_chunk_emits_receive_failed_event() {
872        use crate::events::Event;
873        use crate::file_transfer::FileChunk;
874        use offline_protocol_core::{AppId, Message, UserId};
875        use std::sync::{Arc, Mutex};
876
877        let mut proto = make_protocol("alice");
878        proto.file_transfer_manager = crate::file_transfer::FileTransferManager::with_config(
879            crate::file_transfer::FileTransferConfig {
880                max_concurrent_assemblies: 1,
881                ..crate::file_transfer::FileTransferConfig::default()
882            },
883        );
884
885        let failed_events = Arc::new(Mutex::new(Vec::<Event>::new()));
886        let failed_events_clone = failed_events.clone();
887        proto.on_event(move |event| {
888            if matches!(event, Event::FileReceiveFailed { .. }) {
889                failed_events_clone.lock().unwrap().push(event);
890            }
891        });
892
893        let chunk_message = |chunk: &FileChunk| {
894            Message::new(
895                UserId::new("bob").unwrap(),
896                UserId::new("alice").unwrap(),
897                AppId::new("test-app").unwrap(),
898                chunk.to_json().unwrap(),
899            )
900        };
901        let make_chunk = |file_id: &str| FileChunk {
902            file_id: file_id.to_string(),
903            file_name: "photo.jpg".to_string(),
904            file_size: 1000,
905            total_chunks: 10,
906            chunk_index: 0,
907            chunk_data: vec![0u8; 100],
908            file_checksum: "abc".to_string(),
909        };
910
911        // First transfer occupies the single assembly slot.
912        let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk("file-1")));
913        assert!(failed_events.lock().unwrap().is_empty());
914
915        // The second transfer hits the cap: the app must be told the
916        // transfer is lost (the chunk was already ACKed — it will not be
917        // retransmitted).
918        let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk("file-2")));
919        let events = failed_events.lock().unwrap();
920        assert_eq!(events.len(), 1);
921        match &events[0] {
922            Event::FileReceiveFailed {
923                file_id,
924                file_name,
925                sender,
926                reason,
927            } => {
928                assert_eq!(file_id, "file-2");
929                assert_eq!(file_name, "photo.jpg");
930                assert_eq!(sender, "bob");
931                assert_eq!(reason, "too_many_transfers");
932            }
933            other => panic!("unexpected event: {:?}", other),
934        }
935    }
936
937    #[test]
938    fn test_resource_rejected_transfer_emits_single_failed_event() {
939        use crate::events::Event;
940        use crate::file_transfer::FileChunk;
941        use offline_protocol_core::{AppId, Message, UserId};
942        use std::sync::{Arc, Mutex};
943
944        let mut proto = make_protocol("alice");
945        proto.file_transfer_manager = crate::file_transfer::FileTransferManager::with_config(
946            crate::file_transfer::FileTransferConfig {
947                max_concurrent_assemblies: 1,
948                ..crate::file_transfer::FileTransferConfig::default()
949            },
950        );
951
952        let failed_events = Arc::new(Mutex::new(Vec::<Event>::new()));
953        let failed_events_clone = failed_events.clone();
954        proto.on_event(move |event| {
955            if matches!(event, Event::FileReceiveFailed { .. }) {
956                failed_events_clone.lock().unwrap().push(event);
957            }
958        });
959
960        let chunk_message = |chunk: &FileChunk| {
961            Message::new(
962                UserId::new("bob").unwrap(),
963                UserId::new("alice").unwrap(),
964                AppId::new("test-app").unwrap(),
965                chunk.to_json().unwrap(),
966            )
967        };
968        let make_chunk = |file_id: &str, index: u32| FileChunk {
969            file_id: file_id.to_string(),
970            file_name: "photo.jpg".to_string(),
971            file_size: 1000,
972            total_chunks: 10,
973            chunk_index: index,
974            chunk_data: vec![0u8; 100],
975            file_checksum: "abc".to_string(),
976        };
977
978        // First transfer occupies the single assembly slot.
979        let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk("file-1", 0)));
980
981        // Every chunk of the second transfer was already ACKed and keeps
982        // streaming in after the rejection. The app must be told the
983        // transfer is lost exactly once — not once per remaining chunk.
984        for index in 0..6u32 {
985            let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk("file-2", index)));
986        }
987
988        let events = failed_events.lock().unwrap();
989        assert_eq!(events.len(), 1);
990        match &events[0] {
991            Event::FileReceiveFailed {
992                file_id, reason, ..
993            } => {
994                assert_eq!(file_id, "file-2");
995                assert_eq!(reason, "too_many_transfers");
996            }
997            other => panic!("unexpected event: {:?}", other),
998        }
999        // And no ghost assembly formed for the failed transfer.
1000        assert_eq!(proto.file_transfer_manager.active_transfer_count(), 1);
1001        assert!(proto.file_transfer_manager.get_progress("file-2").is_none());
1002    }
1003
1004    #[test]
1005    fn test_budget_dropped_transfer_emits_no_second_stale_event() {
1006        use crate::events::Event;
1007        use crate::file_transfer::FileChunk;
1008        use crate::protocol::MEDIA_TRANSFER_STALE_TIMEOUT_SECS;
1009        use offline_protocol_core::{AppId, Message, UserId};
1010        use std::sync::{Arc, Mutex};
1011        use std::time::Duration;
1012
1013        let mut proto = make_protocol("alice");
1014        proto.file_transfer_manager = crate::file_transfer::FileTransferManager::with_config(
1015            crate::file_transfer::FileTransferConfig {
1016                max_file_size: 1_000_000,
1017                max_total_buffered_bytes: 1_065_536,
1018                ..crate::file_transfer::FileTransferConfig::default()
1019            },
1020        );
1021
1022        let failed_events = Arc::new(Mutex::new(Vec::<Event>::new()));
1023        let failed_events_clone = failed_events.clone();
1024        proto.on_event(move |event| {
1025            if matches!(event, Event::FileReceiveFailed { .. }) {
1026                failed_events_clone.lock().unwrap().push(event);
1027            }
1028        });
1029
1030        let chunk_message = |chunk: &FileChunk| {
1031            Message::new(
1032                UserId::new("bob").unwrap(),
1033                UserId::new("alice").unwrap(),
1034                AppId::new("test-app").unwrap(),
1035                chunk.to_json().unwrap(),
1036            )
1037        };
1038        let make_chunk =
1039            |file_id: &str, file_size: u64, total: u32, index: u32, len: usize| FileChunk {
1040                file_id: file_id.to_string(),
1041                file_name: "big.bin".to_string(),
1042                file_size,
1043                total_chunks: total,
1044                chunk_index: index,
1045                chunk_data: vec![0u8; len],
1046                file_checksum: "abc".to_string(),
1047            };
1048
1049        // f1 and f2 fill most of the budget; f1's next chunk busts it and
1050        // drops f1 with a buffer_budget_exhausted event.
1051        let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk(
1052            "f1", 1_000_000, 3, 0, 600_000,
1053        )));
1054        let _ = proto
1055            .handle_incoming_file_chunk(&chunk_message(&make_chunk("f2", 400_000, 2, 0, 300_000)));
1056        let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk(
1057            "f1", 1_000_000, 3, 1, 300_000,
1058        )));
1059        assert_eq!(failed_events.lock().unwrap().len(), 1);
1060
1061        // f1's last in-flight chunk must not resurrect it as a
1062        // never-completable assembly.
1063        let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk(
1064            "f1", 1_000_000, 3, 2, 100_000,
1065        )));
1066        assert_eq!(proto.file_transfer_manager.active_transfer_count(), 1);
1067        assert!(proto.file_transfer_manager.get_progress("f1").is_none());
1068
1069        // The stale sweep must not report f1 a second time; only f2 (still
1070        // partially assembled and now stale) fails.
1071        proto
1072            .file_transfer_manager
1073            .backdate_transfers(Duration::from_secs(MEDIA_TRANSFER_STALE_TIMEOUT_SECS + 1));
1074        proto.cleanup_expired_entries();
1075
1076        let events = failed_events.lock().unwrap();
1077        let f1_reasons: Vec<&str> = events
1078            .iter()
1079            .filter_map(|e| match e {
1080                Event::FileReceiveFailed {
1081                    file_id, reason, ..
1082                } if file_id == "f1" => Some(reason.as_str()),
1083                _ => None,
1084            })
1085            .collect();
1086        assert_eq!(f1_reasons, vec!["buffer_budget_exhausted"]);
1087        let f2_reasons: Vec<&str> = events
1088            .iter()
1089            .filter_map(|e| match e {
1090                Event::FileReceiveFailed {
1091                    file_id, reason, ..
1092                } if file_id == "f2" => Some(reason.as_str()),
1093                _ => None,
1094            })
1095            .collect();
1096        assert_eq!(f2_reasons, vec!["stale_timeout"]);
1097    }
1098
1099    #[test]
1100    fn test_malformed_chunk_emits_no_failed_event() {
1101        use crate::events::Event;
1102        use crate::file_transfer::FileChunk;
1103        use offline_protocol_core::{AppId, Message, UserId};
1104        use std::sync::{Arc, Mutex};
1105
1106        let mut proto = make_protocol("alice");
1107
1108        let failed_events = Arc::new(Mutex::new(Vec::<Event>::new()));
1109        let failed_events_clone = failed_events.clone();
1110        proto.on_event(move |event| {
1111            if matches!(event, Event::FileReceiveFailed { .. }) {
1112                failed_events_clone.lock().unwrap().push(event);
1113            }
1114        });
1115
1116        // An old-format sender chunking a max-size file below the 1 KiB
1117        // payload floor claims more chunks than the derived cap. Such
1118        // attacker-shaped input is dropped with only a warning log — no
1119        // FileReceiveFailed, no state.
1120        let chunk = FileChunk {
1121            file_id: "fine-chunked".to_string(),
1122            file_name: "old.bin".to_string(),
1123            file_size: 100 * 1024 * 1024,
1124            total_chunks: 200_000,
1125            chunk_index: 0,
1126            chunk_data: vec![0u8; 512],
1127            file_checksum: "abc".to_string(),
1128        };
1129        let message = Message::new(
1130            UserId::new("bob").unwrap(),
1131            UserId::new("alice").unwrap(),
1132            AppId::new("test-app").unwrap(),
1133            chunk.to_json().unwrap(),
1134        );
1135        let _ = proto.handle_incoming_file_chunk(&message);
1136
1137        assert!(failed_events.lock().unwrap().is_empty());
1138        assert_eq!(proto.file_transfer_manager.active_transfer_count(), 0);
1139        assert!(!proto.pending_media_metadata.contains_key("fine-chunked"));
1140    }
1141
1142    #[test]
1143    fn test_finalize_failure_clears_metadata_and_emits_event() {
1144        use crate::events::Event;
1145        use crate::file_transfer::FileChunk;
1146        use offline_protocol_core::{AppId, Message, UserId};
1147        use sha2::{Digest, Sha256};
1148        use std::sync::{Arc, Mutex};
1149
1150        let mut proto = make_protocol("alice");
1151
1152        let failed_events = Arc::new(Mutex::new(Vec::<Event>::new()));
1153        let failed_events_clone = failed_events.clone();
1154        proto.on_event(move |event| {
1155            if matches!(event, Event::FileReceiveFailed { .. }) {
1156                failed_events_clone.lock().unwrap().push(event);
1157            }
1158        });
1159
1160        // A single chunk whose checksum matches its bytes, but whose claimed
1161        // file_size does not — the transfer completes, then fails the
1162        // finalize size check.
1163        let data = vec![3u8; 100];
1164        let checksum = format!("{:x}", Sha256::digest(&data));
1165        let chunk = FileChunk {
1166            file_id: "bad-file".to_string(),
1167            file_name: "evil.bin".to_string(),
1168            file_size: 2048,
1169            total_chunks: 1,
1170            chunk_index: 0,
1171            chunk_data: data,
1172            file_checksum: checksum,
1173        };
1174        let message = Message::new(
1175            UserId::new("bob").unwrap(),
1176            UserId::new("alice").unwrap(),
1177            AppId::new("test-app").unwrap(),
1178            chunk.to_json().unwrap(),
1179        );
1180        let _ = proto.handle_incoming_file_chunk(&message);
1181
1182        // The failed transfer leaves no assembly and no pending metadata.
1183        assert_eq!(proto.file_transfer_manager.active_transfer_count(), 0);
1184        assert!(!proto.pending_media_metadata.contains_key("bad-file"));
1185
1186        let events = failed_events.lock().unwrap();
1187        assert_eq!(events.len(), 1);
1188        match &events[0] {
1189            Event::FileReceiveFailed {
1190                file_id, reason, ..
1191            } => {
1192                assert_eq!(file_id, "bad-file");
1193                assert_eq!(reason, "integrity_check_failed");
1194            }
1195            other => panic!("unexpected event: {:?}", other),
1196        }
1197    }
1198
1199    #[test]
1200    fn test_stale_transfer_sweep_emits_receive_failed_event() {
1201        use crate::events::Event;
1202        use crate::file_transfer::FileChunk;
1203        use crate::protocol::MEDIA_TRANSFER_STALE_TIMEOUT_SECS;
1204        use offline_protocol_core::{AppId, Message, UserId};
1205        use std::sync::{Arc, Mutex};
1206        use std::time::Duration;
1207
1208        let mut proto = make_protocol("alice");
1209
1210        let failed_events = Arc::new(Mutex::new(Vec::<Event>::new()));
1211        let failed_events_clone = failed_events.clone();
1212        proto.on_event(move |event| {
1213            if matches!(event, Event::FileReceiveFailed { .. }) {
1214                failed_events_clone.lock().unwrap().push(event);
1215            }
1216        });
1217
1218        // A partial inbound transfer from bob (1 of 2 chunks).
1219        let chunk = FileChunk {
1220            file_id: "stale-file".to_string(),
1221            file_name: "photo.jpg".to_string(),
1222            file_size: 200,
1223            total_chunks: 2,
1224            chunk_index: 0,
1225            chunk_data: vec![0u8; 100],
1226            file_checksum: "abc".to_string(),
1227        };
1228        let message = Message::new(
1229            UserId::new("bob").unwrap(),
1230            UserId::new("alice").unwrap(),
1231            AppId::new("test-app").unwrap(),
1232            chunk.to_json().unwrap(),
1233        );
1234        let _ = proto.handle_incoming_file_chunk(&message);
1235        assert_eq!(proto.file_transfer_manager.active_transfer_count(), 1);
1236        assert!(proto.pending_media_metadata.contains_key("stale-file"));
1237
1238        // Age the assembly past the stale timeout and run the periodic
1239        // sweep — the app must hear about the dropped transfer (its chunks
1240        // were ACKed and will never be retransmitted).
1241        proto
1242            .file_transfer_manager
1243            .backdate_transfers(Duration::from_secs(MEDIA_TRANSFER_STALE_TIMEOUT_SECS + 1));
1244        proto.cleanup_expired_entries();
1245
1246        assert_eq!(proto.file_transfer_manager.active_transfer_count(), 0);
1247        assert!(!proto.pending_media_metadata.contains_key("stale-file"));
1248        let events = failed_events.lock().unwrap();
1249        assert_eq!(events.len(), 1);
1250        match &events[0] {
1251            Event::FileReceiveFailed {
1252                file_id,
1253                file_name,
1254                sender,
1255                reason,
1256            } => {
1257                assert_eq!(file_id, "stale-file");
1258                assert_eq!(file_name, "photo.jpg");
1259                assert_eq!(sender, "bob");
1260                assert_eq!(reason, "stale_timeout");
1261            }
1262            other => panic!("unexpected event: {:?}", other),
1263        }
1264    }
1265
1266    #[test]
1267    fn test_connection_request_to_blocked_user_rejected() {
1268        use offline_protocol_transport::{mock::MockTransport, TransportType};
1269
1270        let mut proto = make_protocol("alice");
1271
1272        let mock = MockTransport::new(TransportType::BLE);
1273        mock.start().unwrap();
1274        proto
1275            .transport_manager_mut()
1276            .add_transport(TransportType::BLE, Box::new(mock));
1277        proto.start().unwrap();
1278
1279        proto.block_user("mallory").unwrap();
1280
1281        let result = proto.send_connection_request("mallory", "Alice", None, None);
1282        assert!(matches!(
1283            result,
1284            Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
1285        ));
1286    }
1287
1288    #[test]
1289    fn test_accept_connection_request_to_blocked_user_rejected() {
1290        use offline_protocol_transport::{mock::MockTransport, TransportType};
1291
1292        let mut proto = make_protocol("alice");
1293
1294        let mock = MockTransport::new(TransportType::BLE);
1295        mock.start().unwrap();
1296        proto
1297            .transport_manager_mut()
1298            .add_transport(TransportType::BLE, Box::new(mock));
1299        proto.start().unwrap();
1300
1301        proto.block_user("mallory").unwrap();
1302
1303        let result = proto.accept_connection_request("mallory", "Alice", None);
1304        assert!(matches!(
1305            result,
1306            Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
1307        ));
1308    }
1309
1310    #[test]
1311    fn test_reject_connection_request_to_blocked_user_rejected() {
1312        use offline_protocol_transport::{mock::MockTransport, TransportType};
1313
1314        let mut proto = make_protocol("alice");
1315
1316        let mock = MockTransport::new(TransportType::BLE);
1317        mock.start().unwrap();
1318        proto
1319            .transport_manager_mut()
1320            .add_transport(TransportType::BLE, Box::new(mock));
1321        proto.start().unwrap();
1322
1323        proto.block_user("mallory").unwrap();
1324
1325        let result = proto.reject_connection_request("mallory");
1326        assert!(matches!(
1327            result,
1328            Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
1329        ));
1330    }
1331
1332    #[test]
1333    fn test_restore_blocked_users_skips_invalid_entries() {
1334        use crate::mls::InMemoryStorage;
1335        use offline_protocol_mls::MlsStorage;
1336
1337        let mut proto = make_protocol("alice");
1338        let storage = Arc::new(InMemoryStorage::new());
1339        proto
1340            .enable_message_persistence_for_test(storage.clone())
1341            .unwrap();
1342
1343        // Directly write valid and invalid entries into storage
1344        storage.store("blocked_users", "bob", &[]).unwrap();
1345        storage.store("blocked_users", "", &[]).unwrap(); // invalid: empty user ID
1346
1347        proto.blocked_users.clear();
1348        proto.restore_blocked_users().unwrap();
1349
1350        // "bob" should be restored, "" should be skipped
1351        assert!(proto.is_user_blocked("bob"));
1352        assert!(!proto.is_user_blocked(""));
1353        assert_eq!(proto.blocked_users.len(), 1);
1354    }
1355
1356    #[test]
1357    fn test_session_reset_key_package_clears_stale_session() {
1358        use crate::mls::InMemoryStorage as MlsInMemoryStorage;
1359        use crate::protocol::types::KeyPackagePayload;
1360
1361        // Set up alice with MLS
1362        let mut alice = make_protocol("alice");
1363        let alice_storage = Arc::new(MlsInMemoryStorage::new());
1364        alice.initialize_mls_for_test(alice_storage).unwrap();
1365
1366        // Set up bob with MLS
1367        let mut bob = make_protocol("bob");
1368        let bob_storage = Arc::new(MlsInMemoryStorage::new());
1369        bob.initialize_mls_for_test(bob_storage).unwrap();
1370
1371        // Exchange key packages to establish a session
1372        let alice_key_pkg = {
1373            let mls = alice.mls_manager.as_ref().unwrap();
1374            let manager = mls.read().unwrap();
1375            manager.get_or_create_key_package().unwrap()
1376        };
1377        // Bob imports Alice's key package and creates session
1378        {
1379            let mls = bob.mls_manager.as_ref().unwrap();
1380            let manager = mls.read().unwrap();
1381            manager
1382                .import_key_package(&id("alice"), &alice_key_pkg.key_package_data)
1383                .unwrap();
1384            let welcome = manager.create_session(&id("alice")).unwrap();
1385            // Alice joins via welcome
1386            let alice_mls = alice.mls_manager.as_ref().unwrap();
1387            let alice_manager = alice_mls.read().unwrap();
1388            alice_manager.join_session(&welcome).unwrap();
1389        }
1390
1391        // Verify both have sessions
1392        {
1393            let mls = alice.mls_manager.as_ref().unwrap();
1394            assert!(mls.read().unwrap().has_session(&id("bob")).unwrap());
1395        }
1396        {
1397            let mls = bob.mls_manager.as_ref().unwrap();
1398            assert!(mls.read().unwrap().has_session(&id("alice")).unwrap());
1399        }
1400
1401        // Now simulate: Alice blocks then unblocks Bob.
1402        // Alice's side deletes her session (done by unblock_user internally).
1403        alice.block_user(&id("bob")).unwrap();
1404        alice.unblock_user(&id("bob")).unwrap();
1405
1406        // Verify Alice no longer has a session with Bob
1407        {
1408            let mls = alice.mls_manager.as_ref().unwrap();
1409            assert!(
1410                !mls.read().unwrap().has_session(&id("bob")).unwrap(),
1411                "Alice should have no session after unblock cleanup"
1412            );
1413        }
1414
1415        // Bob still has the stale session
1416        {
1417            let mls = bob.mls_manager.as_ref().unwrap();
1418            assert!(
1419                mls.read().unwrap().has_session(&id("alice")).unwrap(),
1420                "Bob still has the old session before receiving reset"
1421            );
1422        }
1423
1424        // Simulate Bob receiving Alice's key package with session_reset=true
1425        let alice_fresh_key_pkg = {
1426            let mls = alice.mls_manager.as_ref().unwrap();
1427            let manager = mls.read().unwrap();
1428            manager.get_or_create_key_package().unwrap()
1429        };
1430        let reset_payload = KeyPackagePayload {
1431            user_id: id("alice"),
1432            key_package_data: alice_fresh_key_pkg.key_package_data.clone(),
1433            remaining_lifetime_ms: alice_fresh_key_pkg.remaining_lifetime_ms(),
1434            timestamp_ms: 0,
1435            session_reset: true,
1436            wire_versions: Vec::new(),
1437            env_versions: Vec::new(),
1438            rich_versions: Vec::new(),
1439            data_versions: Vec::new(),
1440            ctrl_versions: Vec::new(),
1441            nostr_pubkey: None,
1442        };
1443        let content = serde_json::to_string(&reset_payload).unwrap();
1444
1445        // Bob handles the key package with session_reset=true.
1446        // This deletes the stale session and auto-establishes a fresh one
1447        // using Alice's new key package.
1448        bob.handle_key_package_message(
1449            &id("alice"),
1450            &content,
1451            true,
1452            Some(chrono::Utc::now().timestamp_millis()),
1453        );
1454
1455        // Bob should have a session (the NEW one, auto-established from
1456        // Alice's fresh key package — NOT the stale orphaned session).
1457        {
1458            let mls = bob.mls_manager.as_ref().unwrap();
1459            let manager = mls.read().unwrap();
1460            assert!(
1461                manager.has_session(&id("alice")).unwrap(),
1462                "Bob should have a fresh session after session_reset + auto-establish"
1463            );
1464        }
1465
1466        // The old confirmed_sessions entry should be gone (cleared during
1467        // session delete), proving the stale session was replaced.
1468        assert!(
1469            !bob.confirmed_sessions.contains(&id("alice")),
1470            "Old confirmed session entry should be cleared"
1471        );
1472
1473        // The pending key package should have been consumed by auto-establish
1474        assert!(
1475            !bob.pending_key_packages.contains_key(&id("alice")),
1476            "Key package should be consumed after auto-establish"
1477        );
1478    }
1479}