1use 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 pub fn block_user(&mut self, user_id: &str) -> Result<()> {
19 let _ = UserId::new(user_id)
21 .map_err(|_| Error::InvalidConfiguration(format!("Invalid user ID: {}", user_id)))?;
22
23 if user_id == self.local_id {
25 return Err(Error::InvalidConfiguration(
26 "Cannot block own user ID".to_string(),
27 ));
28 }
29
30 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 debug!(user_id = %user_id, "User already blocked");
41 return Ok(());
42 }
43
44 self.persist_blocked_user(user_id);
45
46 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 pub fn unblock_user(&mut self, user_id: &str) -> Result<()> {
80 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 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 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 fn cleanup_peer_session_state(&mut self, user_id: &str) {
121 if self.mls_manager.is_some() {
126 if let Err(e) = self.manual_mls_delete_session(user_id) {
127 debug!(user_id = %user_id, error = %e, "No MLS session to clean up for unblocked user");
129 }
130 }
131
132 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 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 self.peer_svc_sealed.remove(user_id);
154 self.forget_data_sync_peer(user_id);
155 self.transport_manager.clear_peer_nostr_pubkey(user_id);
163 self.delete_peer_capabilities_from_storage(user_id);
164
165 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 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 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 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 self.key_package_sent_to.remove(user_id);
220 }
221
222 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 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 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(); 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(); }
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 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 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 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 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 mock.add_connected_peer("dave", -55);
373
374 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 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 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 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 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 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 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 #[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 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 proto.block_user("bob").unwrap();
646
647 proto.process_pending_decryption("bob");
649
650 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 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 let received = proto.receive_message();
685 assert!(received.is_some());
686
687 proto.block_user("mallory").unwrap();
689
690 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 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 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 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 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 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 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 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 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 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 let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk("file-1")));
973 assert!(failed_events.lock().unwrap().is_empty());
974
975 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 let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk("file-1", 0)));
1040
1041 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 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 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 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 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 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 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 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 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 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 storage.store("blocked_users", "bob", &[]).unwrap();
1405 storage.store("blocked_users", "", &[]).unwrap(); proto.blocked_users.clear();
1408 proto.restore_blocked_users().unwrap();
1409
1410 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 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 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 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 {
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 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 {
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 alice.block_user(&id("bob")).unwrap();
1464 alice.unblock_user(&id("bob")).unwrap();
1465
1466 {
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 {
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 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.handle_key_package_message(
1511 &id("alice"),
1512 &content,
1513 true,
1514 Some(chrono::Utc::now().timestamp_millis()),
1515 );
1516
1517 {
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 assert!(
1531 !bob.confirmed_sessions.contains(&id("alice")),
1532 "Old confirmed session entry should be cleared"
1533 );
1534
1535 assert!(
1537 !bob.pending_key_packages.contains_key(&id("alice")),
1538 "Key package should be consumed after auto-establish"
1539 );
1540 }
1541}