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