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