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<()> {
18 let _ = UserId::new(user_id)
20 .map_err(|_| Error::InvalidConfiguration(format!("Invalid user ID: {}", user_id)))?;
21
22 if user_id == self.local_id {
24 return Err(Error::InvalidConfiguration(
25 "Cannot block own user ID".to_string(),
26 ));
27 }
28
29 if self.blocked_users.len() >= MAX_BLOCKED_USERS && !self.blocked_users.contains(user_id) {
31 return Err(Error::InvalidConfiguration(format!(
32 "Blocked users limit reached ({})",
33 MAX_BLOCKED_USERS
34 )));
35 }
36
37 if !self.blocked_users.insert(user_id.to_string()) {
38 debug!(user_id = %user_id, "User already blocked");
40 return Ok(());
41 }
42
43 self.persist_blocked_user(user_id);
44
45 info!(user_id = %user_id, "User blocked");
46
47 if let Ok(state) = lock_shared_state(&self.shared_state) {
48 state.emit_event(Event::user_blocked(user_id.to_string()));
49 }
50
51 Ok(())
52 }
53
54 pub fn unblock_user(&mut self, user_id: &str) -> Result<()> {
62 let _ = UserId::new(user_id)
64 .map_err(|_| Error::InvalidConfiguration(format!("Invalid user ID: {}", user_id)))?;
65
66 if !self.blocked_users.remove(user_id) {
67 debug!(user_id = %user_id, "User was not blocked");
69 return Ok(());
70 }
71
72 self.delete_blocked_user(user_id);
73 self.cleanup_peer_session_state(user_id);
74
75 if self.known_peers.contains_key(user_id)
79 && self.config.encryption.enabled
80 && self.config.encryption.auto_key_exchange
81 && self.mls_manager.is_some()
82 {
83 if let Err(e) = self.send_key_package_to(user_id, true) {
84 debug!(user_id = %user_id, error = %e, "Failed to send key package after unblock (peer may reconnect later)");
85 }
86 }
87
88 info!(user_id = %user_id, "User unblocked");
89
90 if let Ok(state) = lock_shared_state(&self.shared_state) {
91 state.emit_event(Event::user_unblocked(user_id.to_string()));
92 }
93
94 Ok(())
95 }
96
97 fn cleanup_peer_session_state(&mut self, user_id: &str) {
103 if self.mls_manager.is_some() {
108 if let Err(e) = self.manual_mls_delete_session(user_id) {
109 debug!(user_id = %user_id, error = %e, "No MLS session to clean up for unblocked user");
111 }
112 }
113
114 if self.pending_key_packages.remove(user_id).is_some() {
118 debug!(user_id = %user_id, "Cleared pending key package for unblocked user");
119 }
120 self.delete_peer_key_package_from_storage(user_id);
121 self.peer_compact_envelope.remove(user_id);
122 self.peer_rich_payload.remove(user_id);
123 self.peer_rich_attested.remove(user_id);
124 self.forget_data_sync_peer(user_id);
125 self.transport_manager.clear_peer_nostr_pubkey(user_id);
133 self.delete_peer_capabilities_from_storage(user_id);
134
135 if self.drop_pending_queue_for_peer(user_id, "Peer session state reset on unblock") > 0 {
138 debug!(user_id = %user_id, "Cleared pending encrypted messages for unblocked user");
139 }
140
141 let drained = self
144 .pending_queue
145 .drain_for_peer(&self.config.encryption.pending_queue, user_id);
146 if !drained.is_empty() {
147 debug!(
148 user_id = %user_id,
149 count = drained.len(),
150 "Drained pending decryption queue for unblocked user"
151 );
152 }
153
154 let file_ids_to_cancel: Vec<String> = self
157 .pending_media_metadata
158 .iter()
159 .filter(|(_, entry)| entry.sender == user_id)
160 .map(|(file_id, _)| file_id.clone())
161 .collect();
162 for file_id in &file_ids_to_cancel {
163 self.file_transfer_manager.cancel_transfer(file_id);
164 if let Some(purpose) = self
175 .pending_media_metadata
176 .remove(file_id)
177 .and_then(|entry| entry.data_purpose)
178 {
179 self.report_data_media_transfer_failure(user_id, &purpose, "cancelled");
180 }
181 }
182 if !file_ids_to_cancel.is_empty() {
183 debug!(
184 user_id = %user_id,
185 count = file_ids_to_cancel.len(),
186 "Cancelled in-progress file transfers for unblocked user"
187 );
188 }
189
190 self.key_package_sent_to.remove(user_id);
192 }
193
194 pub fn get_blocked_users(&self) -> Vec<String> {
196 let mut users: Vec<String> = self.blocked_users.iter().cloned().collect();
197 users.sort();
198 users
199 }
200
201 pub fn is_user_blocked(&self, user_id: &str) -> bool {
203 self.blocked_users.contains(user_id)
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use crate::mls::InMemoryStorage;
210 use crate::test_identity::id;
211 use crate::ProtocolConfig;
212 use offline_protocol_transport::Transport;
213 use std::sync::Arc;
214
215 fn make_protocol(user_id: &str) -> crate::OfflineProtocol {
216 let mut config = ProtocolConfig::new("test-app", user_id);
217 config.encryption.require_encryption = false;
221 crate::OfflineProtocol::new(config).unwrap()
222 }
223
224 #[test]
225 fn test_block_unblock_roundtrip() {
226 let mut proto = make_protocol("alice");
227 assert!(!proto.is_user_blocked("bob"));
228 assert!(proto.get_blocked_users().is_empty());
229
230 proto.block_user("bob").unwrap();
231 assert!(proto.is_user_blocked("bob"));
232 let blocked = proto.get_blocked_users();
233 assert_eq!(blocked.len(), 1);
234 assert!(blocked.contains(&"bob".to_string()));
235
236 proto.unblock_user("bob").unwrap();
237 assert!(!proto.is_user_blocked("bob"));
238 assert!(proto.get_blocked_users().is_empty());
239 }
240
241 #[test]
242 fn test_block_self_rejected() {
243 let mut proto = make_protocol("alice");
244 let result = proto.block_user("alice");
245 assert!(result.is_err());
246 assert!(result.unwrap_err().to_string().contains("Cannot block own"));
247 }
248
249 #[test]
250 fn test_block_invalid_user_id() {
251 let mut proto = make_protocol("alice");
252 let result = proto.block_user("");
253 assert!(result.is_err());
254 }
255
256 #[test]
257 fn test_block_idempotent() {
258 let mut proto = make_protocol("alice");
259 proto.block_user("bob").unwrap();
260 proto.block_user("bob").unwrap(); assert!(proto.is_user_blocked("bob"));
262 }
263
264 #[test]
265 fn test_unblock_not_blocked() {
266 let mut proto = make_protocol("alice");
267 proto.unblock_user("bob").unwrap(); }
269
270 #[test]
271 fn test_blocked_user_persistence() {
272 let mut proto = make_protocol("alice");
273 let storage = Arc::new(InMemoryStorage::new());
274 proto.enable_message_persistence_for_test(storage).unwrap();
275
276 proto.block_user("bob").unwrap();
277 assert!(proto.is_user_blocked("bob"));
278
279 proto.blocked_users.clear();
281 assert!(!proto.is_user_blocked("bob"));
282
283 proto.restore_blocked_users().unwrap();
284 assert!(proto.is_user_blocked("bob"));
285 }
286
287 #[test]
288 fn test_receive_drops_blocked_sender() {
289 use offline_protocol_core::{AppId, Message, UserId};
290 use offline_protocol_transport::{mock::MockTransport, TransportType};
291
292 let mut proto = make_protocol("alice");
293 proto.block_user("mallory").unwrap();
294
295 let mock = MockTransport::new(TransportType::BLE);
296 mock.start().unwrap();
297
298 let msg = Message::new(
300 UserId::new("mallory").unwrap(),
301 UserId::new("alice").unwrap(),
302 AppId::new("test-app").unwrap(),
303 "you shouldn't see this",
304 );
305 mock.queue_message(msg);
306
307 proto
308 .transport_manager_mut()
309 .add_transport(TransportType::BLE, Box::new(mock));
310 proto.start().unwrap();
311
312 assert!(proto.receive_message().is_none());
314 }
315
316 #[test]
317 fn test_relay_continues_for_blocked_user() {
318 use crate::events::Event;
319 use offline_protocol_core::{AppId, Message, UserId};
320 use offline_protocol_transport::{mock::MockTransport, TransportType};
321 use std::sync::{Arc, Mutex};
322
323 let mut config = ProtocolConfig::new("test-app", "alice");
324 config.encryption.require_encryption = false;
325 config.mesh_relay.jitter_min = std::time::Duration::from_millis(0);
327 config.mesh_relay.jitter_max = std::time::Duration::from_millis(0);
328 let mut proto = crate::OfflineProtocol::new(config).unwrap();
329 proto.block_user("mallory").unwrap();
330
331 let relay_events = Arc::new(Mutex::new(Vec::<Event>::new()));
332 let relay_events_clone = relay_events.clone();
333 proto.on_event(move |event| {
334 if matches!(event, Event::MessageRelayed { .. }) {
335 relay_events_clone.lock().unwrap().push(event);
336 }
337 });
338
339 let mock = MockTransport::new(TransportType::BLE);
340 mock.start().unwrap();
341 mock.add_connected_peer("dave", -55);
345
346 let msg = Message::new(
349 UserId::new("mallory").unwrap(),
350 UserId::new("charlie").unwrap(),
351 AppId::new("test-app").unwrap(),
352 "relay this",
353 );
354 mock.queue_message(msg);
355
356 proto
357 .transport_manager_mut()
358 .add_transport(TransportType::BLE, Box::new(mock));
359 proto.start().unwrap();
360
361 let received = proto.receive_message();
365 assert!(
366 received.is_none(),
367 "Relay messages for third parties should be forwarded, not returned"
368 );
369 proto.process().unwrap();
370
371 let events = relay_events.lock().unwrap();
374 assert_eq!(
375 events.len(),
376 1,
377 "Blocked-user relay to third party must emit MessageRelayed"
378 );
379 match &events[0] {
380 Event::MessageRelayed {
381 sender, recipient, ..
382 } => {
383 assert_eq!(sender, "mallory");
384 assert_eq!(recipient, "charlie");
385 }
386 _ => panic!("Expected MessageRelayed event"),
387 }
388 }
389
390 #[test]
391 fn test_unblock_clears_session_state() {
392 use crate::mls::InMemoryStorage as MlsInMemoryStorage;
393 use offline_protocol_core::{AppId, Message, UserId};
394
395 let mut proto = make_protocol("alice");
396 let storage = Arc::new(MlsInMemoryStorage::new());
397 proto.initialize_mls_for_test(storage).unwrap();
398
399 use crate::protocol::types::ReceivedKeyPackage;
400
401 proto.pending_key_packages.insert(
403 id("bob"),
404 ReceivedKeyPackage {
405 key_package_data: vec![0x01, 0x02],
406 local_expires_at_ms: u64::MAX,
407 },
408 );
409 proto.key_package_sent_to.insert(id("bob"));
410
411 let pending_msg = Message::new(
413 UserId::new(&id("alice")).unwrap(),
414 UserId::new(&id("bob")).unwrap(),
415 AppId::new("test-app").unwrap(),
416 "queued",
417 );
418 proto.enqueue_pending_decryption(&id("bob"), &pending_msg);
419
420 proto.block_user(&id("bob")).unwrap();
422 proto.unblock_user(&id("bob")).unwrap();
423
424 assert!(proto.pending_key_packages.get(&id("bob")).is_none());
425 assert!(!proto.key_package_sent_to.contains(&id("bob")));
426 assert!(!proto.confirmed_sessions.contains(&id("bob")));
427 assert!(proto
428 .pending_queue
429 .drain_for_peer(&proto.config.encryption.pending_queue, &id("bob"))
430 .is_empty());
431 }
432
433 #[test]
434 fn test_unblock_then_receive() {
435 use offline_protocol_core::{AppId, Message, UserId};
436 use offline_protocol_transport::{mock::MockTransport, TransportType};
437
438 let mut proto = make_protocol("alice");
439 proto.block_user("bob").unwrap();
440 proto.unblock_user("bob").unwrap();
441
442 let mock = MockTransport::new(TransportType::BLE);
443 mock.start().unwrap();
444
445 let msg = Message::new(
446 UserId::new("bob").unwrap(),
447 UserId::new("alice").unwrap(),
448 AppId::new("test-app").unwrap(),
449 "hello again",
450 );
451 let msg_id = msg.id.clone();
452 mock.queue_message(msg);
453
454 proto
455 .transport_manager_mut()
456 .add_transport(TransportType::BLE, Box::new(mock));
457 proto.start().unwrap();
458
459 let received = proto.receive_message();
461 assert!(
462 received.is_some(),
463 "Messages should be received after unblocking"
464 );
465 assert_eq!(received.unwrap().id, msg_id);
466 }
467
468 #[test]
469 fn test_send_to_blocked_user_rejected() {
470 use offline_protocol_transport::{mock::MockTransport, TransportType};
471
472 let mut proto = make_protocol("alice");
473
474 let mock = MockTransport::new(TransportType::BLE);
475 mock.start().unwrap();
476 proto
477 .transport_manager_mut()
478 .add_transport(TransportType::BLE, Box::new(mock));
479 proto.start().unwrap();
480
481 proto.block_user("mallory").unwrap();
482
483 let result = proto.send_message("mallory", "hello", None, None::<String>);
484 assert!(matches!(
485 result,
486 Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
487 ));
488 }
489
490 #[test]
491 fn test_send_via_transport_to_blocked_user_rejected() {
492 use offline_protocol_transport::{mock::MockTransport, TransportType};
493
494 let mut proto = make_protocol("alice");
495
496 let mock = MockTransport::new(TransportType::BLE);
497 mock.start().unwrap();
498 proto
499 .transport_manager_mut()
500 .add_transport(TransportType::BLE, Box::new(mock));
501 proto.start().unwrap();
502
503 proto.block_user("mallory").unwrap();
504
505 let result = proto.send_message_via_transport(
506 "mallory",
507 "hello",
508 None,
509 TransportType::BLE,
510 None::<String>,
511 );
512 assert!(matches!(
513 result,
514 Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
515 ));
516 }
517
518 #[test]
519 fn test_send_media_to_blocked_user_rejected() {
520 use offline_protocol_core::ContentType;
521 use offline_protocol_transport::{mock::MockTransport, TransportType};
522
523 let mut proto = make_protocol("alice");
524
525 let mock = MockTransport::new(TransportType::BLE);
526 mock.start().unwrap();
527 proto
528 .transport_manager_mut()
529 .add_transport(TransportType::BLE, Box::new(mock));
530 proto.start().unwrap();
531
532 proto.block_user("mallory").unwrap();
533
534 let result = proto.send_media(
535 "mallory",
536 vec![0u8; 100],
537 "photo.jpg",
538 ContentType::Image,
539 None,
540 );
541 assert!(matches!(
542 result,
543 Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
544 ));
545 }
546
547 #[test]
548 fn test_pending_decryption_drops_blocked_sender() {
549 use offline_protocol_core::{AppId, Message, UserId};
550
551 let mut proto = make_protocol("alice");
552
553 let msg = Message::new(
555 UserId::new("bob").unwrap(),
556 UserId::new("alice").unwrap(),
557 AppId::new("test-app").unwrap(),
558 "secret",
559 );
560 proto.enqueue_pending_decryption("bob", &msg);
561
562 proto.block_user("bob").unwrap();
564
565 proto.process_pending_decryption("bob");
567
568 let state = crate::protocol::lock_shared_state(&proto.shared_state).unwrap();
570 assert!(
571 state.received_messages.is_empty(),
572 "Pending messages from blocked user should not be surfaced"
573 );
574 }
575
576 #[test]
577 fn test_duplicate_from_blocked_user_no_ack() {
578 use offline_protocol_core::{AppId, Message, UserId};
579 use offline_protocol_transport::{mock::MockTransport, TransportType};
580
581 let mut proto = make_protocol("alice");
582
583 let mock = MockTransport::new(TransportType::BLE);
584 mock.start().unwrap();
585
586 let msg = Message::new(
588 UserId::new("mallory").unwrap(),
589 UserId::new("alice").unwrap(),
590 AppId::new("test-app").unwrap(),
591 "first message",
592 );
593 let msg_clone = msg.clone();
594 mock.queue_message(msg);
595
596 proto
597 .transport_manager_mut()
598 .add_transport(TransportType::BLE, Box::new(mock));
599 proto.start().unwrap();
600
601 let received = proto.receive_message();
603 assert!(received.is_some());
604
605 proto.block_user("mallory").unwrap();
607
608 let mock2 = MockTransport::new(TransportType::BLE);
611 mock2.start().unwrap();
612 mock2.queue_message(msg_clone);
613 proto
614 .transport_manager_mut()
615 .add_transport(TransportType::BLE, Box::new(mock2));
616
617 assert!(proto.receive_message().is_none());
619 }
620
621 #[test]
622 fn test_get_blocked_users_sorted() {
623 let mut proto = make_protocol("alice");
624 proto.block_user("charlie").unwrap();
625 proto.block_user("bob").unwrap();
626 proto.block_user("dave").unwrap();
627
628 let blocked = proto.get_blocked_users();
629 assert_eq!(blocked, vec!["bob", "charlie", "dave"]);
630 }
631
632 #[test]
633 fn test_presence_to_blocked_user_rejected() {
634 use crate::events::PresenceStatus;
635 use offline_protocol_transport::{mock::MockTransport, TransportType};
636
637 let mut proto = make_protocol("alice");
638
639 let mock = MockTransport::new(TransportType::BLE);
640 mock.start().unwrap();
641 proto
642 .transport_manager_mut()
643 .add_transport(TransportType::BLE, Box::new(mock));
644 proto.start().unwrap();
645
646 proto.block_user("mallory").unwrap();
647
648 let result = proto.send_presence_update("mallory", PresenceStatus::Online);
649 assert!(matches!(
650 result,
651 Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
652 ));
653 }
654
655 #[test]
656 fn test_typing_indicator_to_blocked_user_rejected() {
657 use offline_protocol_transport::{mock::MockTransport, TransportType};
658
659 let mut proto = make_protocol("alice");
660
661 let mock = MockTransport::new(TransportType::BLE);
662 mock.start().unwrap();
663 proto
664 .transport_manager_mut()
665 .add_transport(TransportType::BLE, Box::new(mock));
666 proto.start().unwrap();
667
668 proto.block_user("mallory").unwrap();
669
670 let result = proto.send_typing_indicator("mallory", "conv-1", true);
671 assert!(matches!(
672 result,
673 Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
674 ));
675 }
676
677 #[test]
678 fn test_read_receipt_to_blocked_user_rejected() {
679 use offline_protocol_transport::{mock::MockTransport, TransportType};
680
681 let mut proto = make_protocol("alice");
682
683 let mock = MockTransport::new(TransportType::BLE);
684 mock.start().unwrap();
685 proto
686 .transport_manager_mut()
687 .add_transport(TransportType::BLE, Box::new(mock));
688 proto.start().unwrap();
689
690 proto.block_user("mallory").unwrap();
691
692 let result = proto.send_read_receipt("mallory", vec!["msg-1".to_string()]);
693 assert!(matches!(
694 result,
695 Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
696 ));
697 }
698
699 #[test]
700 fn test_block_cap_enforced() {
701 use crate::protocol::types::MAX_BLOCKED_USERS;
702
703 let mut proto = make_protocol("alice");
704 for i in 0..MAX_BLOCKED_USERS {
705 proto.block_user(&format!("user-{i}")).unwrap();
706 }
707 assert_eq!(proto.blocked_users.len(), MAX_BLOCKED_USERS);
708
709 let result = proto.block_user("one-too-many");
711 assert!(result.is_err());
712 assert!(result
713 .unwrap_err()
714 .to_string()
715 .contains("Blocked users limit reached"));
716
717 proto.block_user("user-0").unwrap();
719 }
720
721 #[test]
722 fn test_on_neighbor_discovered_skips_blocked_user() {
723 let mut proto = make_protocol("alice");
724 proto.block_user("mallory").unwrap();
725
726 proto.on_neighbor_discovered("mallory");
729 assert!(
730 !proto.known_peers.contains_key("mallory"),
731 "Blocked user should not be added to known_peers"
732 );
733 assert!(
734 !proto.key_package_sent_to.contains("mallory"),
735 "Blocked user should not trigger key package exchange"
736 );
737
738 proto.on_neighbor_discovered("bob");
740 assert!(
741 proto.known_peers.contains_key("bob"),
742 "Non-blocked peer should be tracked"
743 );
744 }
745
746 #[test]
747 fn test_cleanup_cancels_inbound_file_transfers() {
748 use crate::file_transfer::FileChunk;
749 use offline_protocol_core::ContentType;
750
751 let mut proto = make_protocol("alice");
752
753 let chunk = FileChunk {
756 file_id: "file-from-bob".to_string(),
757 file_name: "photo.jpg".to_string(),
758 file_size: 1000,
759 total_chunks: 10,
760 chunk_index: 0,
761 chunk_data: vec![0u8; 100],
762 file_checksum: "abc123".to_string(),
763 };
764 proto
765 .file_transfer_manager
766 .process_chunk("bob", chunk)
767 .unwrap();
768
769 use crate::protocol::types::PendingMediaMetadataEntry;
770 use std::time::Instant;
771 proto.pending_media_metadata.insert(
772 "file-from-bob".to_string(),
773 PendingMediaMetadataEntry {
774 data_purpose: None,
775 content_type: ContentType::Image,
776 media_metadata: None,
777 last_updated_at: Instant::now(),
778 sender: "bob".to_string(),
779 rich_extras: None,
780 timestamp_ms: 0,
781 },
782 );
783
784 proto.block_user("bob").unwrap();
786 proto.unblock_user("bob").unwrap();
787
788 assert!(
789 proto
790 .file_transfer_manager
791 .get_progress("file-from-bob")
792 .is_none(),
793 "Inbound file transfer should be cancelled on unblock cleanup"
794 );
795 assert!(
796 !proto.pending_media_metadata.contains_key("file-from-bob"),
797 "Pending media metadata should be removed on unblock cleanup"
798 );
799 }
800
801 #[test]
802 fn test_rejected_file_chunk_leaves_no_state() {
803 use crate::file_transfer::FileChunk;
804 use offline_protocol_core::{AppId, Message, UserId};
805
806 let mut proto = make_protocol("alice");
807
808 let chunk_message = |chunk: &FileChunk| {
809 Message::new(
810 UserId::new("bob").unwrap(),
811 UserId::new("alice").unwrap(),
812 AppId::new("test-app").unwrap(),
813 chunk.to_json().unwrap(),
814 )
815 };
816
817 let good = FileChunk {
819 file_id: "good-file".to_string(),
820 file_name: "photo.jpg".to_string(),
821 file_size: 11,
822 total_chunks: 2,
823 chunk_index: 0,
824 chunk_data: vec![1u8; 6],
825 file_checksum: "abc".to_string(),
826 };
827 let _ = proto.handle_incoming_file_chunk(&chunk_message(&good));
828 assert!(proto.pending_media_metadata.contains_key("good-file"));
829 assert_eq!(proto.file_transfer_manager.active_transfer_count(), 1);
830
831 let evil = FileChunk {
834 file_id: "evil-file".to_string(),
835 file_name: "evil.bin".to_string(),
836 file_size: u64::MAX,
837 total_chunks: 1,
838 chunk_index: 0,
839 chunk_data: vec![0u8; 16],
840 file_checksum: "def".to_string(),
841 };
842 let _ = proto.handle_incoming_file_chunk(&chunk_message(&evil));
843 assert!(!proto.pending_media_metadata.contains_key("evil-file"));
844 assert_eq!(proto.file_transfer_manager.active_transfer_count(), 1);
845 }
846
847 #[test]
848 fn test_resource_rejected_file_chunk_emits_receive_failed_event() {
849 use crate::events::Event;
850 use crate::file_transfer::FileChunk;
851 use offline_protocol_core::{AppId, Message, UserId};
852 use std::sync::{Arc, Mutex};
853
854 let mut proto = make_protocol("alice");
855 proto.file_transfer_manager = crate::file_transfer::FileTransferManager::with_config(
856 crate::file_transfer::FileTransferConfig {
857 max_concurrent_assemblies: 1,
858 ..crate::file_transfer::FileTransferConfig::default()
859 },
860 );
861
862 let failed_events = Arc::new(Mutex::new(Vec::<Event>::new()));
863 let failed_events_clone = failed_events.clone();
864 proto.on_event(move |event| {
865 if matches!(event, Event::FileReceiveFailed { .. }) {
866 failed_events_clone.lock().unwrap().push(event);
867 }
868 });
869
870 let chunk_message = |chunk: &FileChunk| {
871 Message::new(
872 UserId::new("bob").unwrap(),
873 UserId::new("alice").unwrap(),
874 AppId::new("test-app").unwrap(),
875 chunk.to_json().unwrap(),
876 )
877 };
878 let make_chunk = |file_id: &str| FileChunk {
879 file_id: file_id.to_string(),
880 file_name: "photo.jpg".to_string(),
881 file_size: 1000,
882 total_chunks: 10,
883 chunk_index: 0,
884 chunk_data: vec![0u8; 100],
885 file_checksum: "abc".to_string(),
886 };
887
888 let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk("file-1")));
890 assert!(failed_events.lock().unwrap().is_empty());
891
892 let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk("file-2")));
896 let events = failed_events.lock().unwrap();
897 assert_eq!(events.len(), 1);
898 match &events[0] {
899 Event::FileReceiveFailed {
900 file_id,
901 file_name,
902 sender,
903 reason,
904 } => {
905 assert_eq!(file_id, "file-2");
906 assert_eq!(file_name, "photo.jpg");
907 assert_eq!(sender, "bob");
908 assert_eq!(reason, "too_many_transfers");
909 }
910 other => panic!("unexpected event: {:?}", other),
911 }
912 }
913
914 #[test]
915 fn test_resource_rejected_transfer_emits_single_failed_event() {
916 use crate::events::Event;
917 use crate::file_transfer::FileChunk;
918 use offline_protocol_core::{AppId, Message, UserId};
919 use std::sync::{Arc, Mutex};
920
921 let mut proto = make_protocol("alice");
922 proto.file_transfer_manager = crate::file_transfer::FileTransferManager::with_config(
923 crate::file_transfer::FileTransferConfig {
924 max_concurrent_assemblies: 1,
925 ..crate::file_transfer::FileTransferConfig::default()
926 },
927 );
928
929 let failed_events = Arc::new(Mutex::new(Vec::<Event>::new()));
930 let failed_events_clone = failed_events.clone();
931 proto.on_event(move |event| {
932 if matches!(event, Event::FileReceiveFailed { .. }) {
933 failed_events_clone.lock().unwrap().push(event);
934 }
935 });
936
937 let chunk_message = |chunk: &FileChunk| {
938 Message::new(
939 UserId::new("bob").unwrap(),
940 UserId::new("alice").unwrap(),
941 AppId::new("test-app").unwrap(),
942 chunk.to_json().unwrap(),
943 )
944 };
945 let make_chunk = |file_id: &str, index: u32| FileChunk {
946 file_id: file_id.to_string(),
947 file_name: "photo.jpg".to_string(),
948 file_size: 1000,
949 total_chunks: 10,
950 chunk_index: index,
951 chunk_data: vec![0u8; 100],
952 file_checksum: "abc".to_string(),
953 };
954
955 let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk("file-1", 0)));
957
958 for index in 0..6u32 {
962 let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk("file-2", index)));
963 }
964
965 let events = failed_events.lock().unwrap();
966 assert_eq!(events.len(), 1);
967 match &events[0] {
968 Event::FileReceiveFailed {
969 file_id, reason, ..
970 } => {
971 assert_eq!(file_id, "file-2");
972 assert_eq!(reason, "too_many_transfers");
973 }
974 other => panic!("unexpected event: {:?}", other),
975 }
976 assert_eq!(proto.file_transfer_manager.active_transfer_count(), 1);
978 assert!(proto.file_transfer_manager.get_progress("file-2").is_none());
979 }
980
981 #[test]
982 fn test_budget_dropped_transfer_emits_no_second_stale_event() {
983 use crate::events::Event;
984 use crate::file_transfer::FileChunk;
985 use crate::protocol::MEDIA_TRANSFER_STALE_TIMEOUT_SECS;
986 use offline_protocol_core::{AppId, Message, UserId};
987 use std::sync::{Arc, Mutex};
988 use std::time::Duration;
989
990 let mut proto = make_protocol("alice");
991 proto.file_transfer_manager = crate::file_transfer::FileTransferManager::with_config(
992 crate::file_transfer::FileTransferConfig {
993 max_file_size: 1_000_000,
994 max_total_buffered_bytes: 1_065_536,
995 ..crate::file_transfer::FileTransferConfig::default()
996 },
997 );
998
999 let failed_events = Arc::new(Mutex::new(Vec::<Event>::new()));
1000 let failed_events_clone = failed_events.clone();
1001 proto.on_event(move |event| {
1002 if matches!(event, Event::FileReceiveFailed { .. }) {
1003 failed_events_clone.lock().unwrap().push(event);
1004 }
1005 });
1006
1007 let chunk_message = |chunk: &FileChunk| {
1008 Message::new(
1009 UserId::new("bob").unwrap(),
1010 UserId::new("alice").unwrap(),
1011 AppId::new("test-app").unwrap(),
1012 chunk.to_json().unwrap(),
1013 )
1014 };
1015 let make_chunk =
1016 |file_id: &str, file_size: u64, total: u32, index: u32, len: usize| FileChunk {
1017 file_id: file_id.to_string(),
1018 file_name: "big.bin".to_string(),
1019 file_size,
1020 total_chunks: total,
1021 chunk_index: index,
1022 chunk_data: vec![0u8; len],
1023 file_checksum: "abc".to_string(),
1024 };
1025
1026 let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk(
1029 "f1", 1_000_000, 3, 0, 600_000,
1030 )));
1031 let _ = proto
1032 .handle_incoming_file_chunk(&chunk_message(&make_chunk("f2", 400_000, 2, 0, 300_000)));
1033 let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk(
1034 "f1", 1_000_000, 3, 1, 300_000,
1035 )));
1036 assert_eq!(failed_events.lock().unwrap().len(), 1);
1037
1038 let _ = proto.handle_incoming_file_chunk(&chunk_message(&make_chunk(
1041 "f1", 1_000_000, 3, 2, 100_000,
1042 )));
1043 assert_eq!(proto.file_transfer_manager.active_transfer_count(), 1);
1044 assert!(proto.file_transfer_manager.get_progress("f1").is_none());
1045
1046 proto
1049 .file_transfer_manager
1050 .backdate_transfers(Duration::from_secs(MEDIA_TRANSFER_STALE_TIMEOUT_SECS + 1));
1051 proto.cleanup_expired_entries();
1052
1053 let events = failed_events.lock().unwrap();
1054 let f1_reasons: Vec<&str> = events
1055 .iter()
1056 .filter_map(|e| match e {
1057 Event::FileReceiveFailed {
1058 file_id, reason, ..
1059 } if file_id == "f1" => Some(reason.as_str()),
1060 _ => None,
1061 })
1062 .collect();
1063 assert_eq!(f1_reasons, vec!["buffer_budget_exhausted"]);
1064 let f2_reasons: Vec<&str> = events
1065 .iter()
1066 .filter_map(|e| match e {
1067 Event::FileReceiveFailed {
1068 file_id, reason, ..
1069 } if file_id == "f2" => Some(reason.as_str()),
1070 _ => None,
1071 })
1072 .collect();
1073 assert_eq!(f2_reasons, vec!["stale_timeout"]);
1074 }
1075
1076 #[test]
1077 fn test_malformed_chunk_emits_no_failed_event() {
1078 use crate::events::Event;
1079 use crate::file_transfer::FileChunk;
1080 use offline_protocol_core::{AppId, Message, UserId};
1081 use std::sync::{Arc, Mutex};
1082
1083 let mut proto = make_protocol("alice");
1084
1085 let failed_events = Arc::new(Mutex::new(Vec::<Event>::new()));
1086 let failed_events_clone = failed_events.clone();
1087 proto.on_event(move |event| {
1088 if matches!(event, Event::FileReceiveFailed { .. }) {
1089 failed_events_clone.lock().unwrap().push(event);
1090 }
1091 });
1092
1093 let chunk = FileChunk {
1098 file_id: "fine-chunked".to_string(),
1099 file_name: "old.bin".to_string(),
1100 file_size: 100 * 1024 * 1024,
1101 total_chunks: 200_000,
1102 chunk_index: 0,
1103 chunk_data: vec![0u8; 512],
1104 file_checksum: "abc".to_string(),
1105 };
1106 let message = Message::new(
1107 UserId::new("bob").unwrap(),
1108 UserId::new("alice").unwrap(),
1109 AppId::new("test-app").unwrap(),
1110 chunk.to_json().unwrap(),
1111 );
1112 let _ = proto.handle_incoming_file_chunk(&message);
1113
1114 assert!(failed_events.lock().unwrap().is_empty());
1115 assert_eq!(proto.file_transfer_manager.active_transfer_count(), 0);
1116 assert!(!proto.pending_media_metadata.contains_key("fine-chunked"));
1117 }
1118
1119 #[test]
1120 fn test_finalize_failure_clears_metadata_and_emits_event() {
1121 use crate::events::Event;
1122 use crate::file_transfer::FileChunk;
1123 use offline_protocol_core::{AppId, Message, UserId};
1124 use sha2::{Digest, Sha256};
1125 use std::sync::{Arc, Mutex};
1126
1127 let mut proto = make_protocol("alice");
1128
1129 let failed_events = Arc::new(Mutex::new(Vec::<Event>::new()));
1130 let failed_events_clone = failed_events.clone();
1131 proto.on_event(move |event| {
1132 if matches!(event, Event::FileReceiveFailed { .. }) {
1133 failed_events_clone.lock().unwrap().push(event);
1134 }
1135 });
1136
1137 let data = vec![3u8; 100];
1141 let checksum = format!("{:x}", Sha256::digest(&data));
1142 let chunk = FileChunk {
1143 file_id: "bad-file".to_string(),
1144 file_name: "evil.bin".to_string(),
1145 file_size: 2048,
1146 total_chunks: 1,
1147 chunk_index: 0,
1148 chunk_data: data,
1149 file_checksum: checksum,
1150 };
1151 let message = Message::new(
1152 UserId::new("bob").unwrap(),
1153 UserId::new("alice").unwrap(),
1154 AppId::new("test-app").unwrap(),
1155 chunk.to_json().unwrap(),
1156 );
1157 let _ = proto.handle_incoming_file_chunk(&message);
1158
1159 assert_eq!(proto.file_transfer_manager.active_transfer_count(), 0);
1161 assert!(!proto.pending_media_metadata.contains_key("bad-file"));
1162
1163 let events = failed_events.lock().unwrap();
1164 assert_eq!(events.len(), 1);
1165 match &events[0] {
1166 Event::FileReceiveFailed {
1167 file_id, reason, ..
1168 } => {
1169 assert_eq!(file_id, "bad-file");
1170 assert_eq!(reason, "integrity_check_failed");
1171 }
1172 other => panic!("unexpected event: {:?}", other),
1173 }
1174 }
1175
1176 #[test]
1177 fn test_stale_transfer_sweep_emits_receive_failed_event() {
1178 use crate::events::Event;
1179 use crate::file_transfer::FileChunk;
1180 use crate::protocol::MEDIA_TRANSFER_STALE_TIMEOUT_SECS;
1181 use offline_protocol_core::{AppId, Message, UserId};
1182 use std::sync::{Arc, Mutex};
1183 use std::time::Duration;
1184
1185 let mut proto = make_protocol("alice");
1186
1187 let failed_events = Arc::new(Mutex::new(Vec::<Event>::new()));
1188 let failed_events_clone = failed_events.clone();
1189 proto.on_event(move |event| {
1190 if matches!(event, Event::FileReceiveFailed { .. }) {
1191 failed_events_clone.lock().unwrap().push(event);
1192 }
1193 });
1194
1195 let chunk = FileChunk {
1197 file_id: "stale-file".to_string(),
1198 file_name: "photo.jpg".to_string(),
1199 file_size: 200,
1200 total_chunks: 2,
1201 chunk_index: 0,
1202 chunk_data: vec![0u8; 100],
1203 file_checksum: "abc".to_string(),
1204 };
1205 let message = Message::new(
1206 UserId::new("bob").unwrap(),
1207 UserId::new("alice").unwrap(),
1208 AppId::new("test-app").unwrap(),
1209 chunk.to_json().unwrap(),
1210 );
1211 let _ = proto.handle_incoming_file_chunk(&message);
1212 assert_eq!(proto.file_transfer_manager.active_transfer_count(), 1);
1213 assert!(proto.pending_media_metadata.contains_key("stale-file"));
1214
1215 proto
1219 .file_transfer_manager
1220 .backdate_transfers(Duration::from_secs(MEDIA_TRANSFER_STALE_TIMEOUT_SECS + 1));
1221 proto.cleanup_expired_entries();
1222
1223 assert_eq!(proto.file_transfer_manager.active_transfer_count(), 0);
1224 assert!(!proto.pending_media_metadata.contains_key("stale-file"));
1225 let events = failed_events.lock().unwrap();
1226 assert_eq!(events.len(), 1);
1227 match &events[0] {
1228 Event::FileReceiveFailed {
1229 file_id,
1230 file_name,
1231 sender,
1232 reason,
1233 } => {
1234 assert_eq!(file_id, "stale-file");
1235 assert_eq!(file_name, "photo.jpg");
1236 assert_eq!(sender, "bob");
1237 assert_eq!(reason, "stale_timeout");
1238 }
1239 other => panic!("unexpected event: {:?}", other),
1240 }
1241 }
1242
1243 #[test]
1244 fn test_connection_request_to_blocked_user_rejected() {
1245 use offline_protocol_transport::{mock::MockTransport, TransportType};
1246
1247 let mut proto = make_protocol("alice");
1248
1249 let mock = MockTransport::new(TransportType::BLE);
1250 mock.start().unwrap();
1251 proto
1252 .transport_manager_mut()
1253 .add_transport(TransportType::BLE, Box::new(mock));
1254 proto.start().unwrap();
1255
1256 proto.block_user("mallory").unwrap();
1257
1258 let result = proto.send_connection_request("mallory", "Alice", None, None);
1259 assert!(matches!(
1260 result,
1261 Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
1262 ));
1263 }
1264
1265 #[test]
1266 fn test_accept_connection_request_to_blocked_user_rejected() {
1267 use offline_protocol_transport::{mock::MockTransport, TransportType};
1268
1269 let mut proto = make_protocol("alice");
1270
1271 let mock = MockTransport::new(TransportType::BLE);
1272 mock.start().unwrap();
1273 proto
1274 .transport_manager_mut()
1275 .add_transport(TransportType::BLE, Box::new(mock));
1276 proto.start().unwrap();
1277
1278 proto.block_user("mallory").unwrap();
1279
1280 let result = proto.accept_connection_request("mallory", "Alice", None);
1281 assert!(matches!(
1282 result,
1283 Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
1284 ));
1285 }
1286
1287 #[test]
1288 fn test_reject_connection_request_to_blocked_user_rejected() {
1289 use offline_protocol_transport::{mock::MockTransport, TransportType};
1290
1291 let mut proto = make_protocol("alice");
1292
1293 let mock = MockTransport::new(TransportType::BLE);
1294 mock.start().unwrap();
1295 proto
1296 .transport_manager_mut()
1297 .add_transport(TransportType::BLE, Box::new(mock));
1298 proto.start().unwrap();
1299
1300 proto.block_user("mallory").unwrap();
1301
1302 let result = proto.reject_connection_request("mallory");
1303 assert!(matches!(
1304 result,
1305 Err(crate::Error::UserBlocked(ref id)) if id == "mallory"
1306 ));
1307 }
1308
1309 #[test]
1310 fn test_restore_blocked_users_skips_invalid_entries() {
1311 use crate::mls::InMemoryStorage;
1312 use offline_protocol_mls::MlsStorage;
1313
1314 let mut proto = make_protocol("alice");
1315 let storage = Arc::new(InMemoryStorage::new());
1316 proto
1317 .enable_message_persistence_for_test(storage.clone())
1318 .unwrap();
1319
1320 storage.store("blocked_users", "bob", &[]).unwrap();
1322 storage.store("blocked_users", "", &[]).unwrap(); proto.blocked_users.clear();
1325 proto.restore_blocked_users().unwrap();
1326
1327 assert!(proto.is_user_blocked("bob"));
1329 assert!(!proto.is_user_blocked(""));
1330 assert_eq!(proto.blocked_users.len(), 1);
1331 }
1332
1333 #[test]
1334 fn test_session_reset_key_package_clears_stale_session() {
1335 use crate::mls::InMemoryStorage as MlsInMemoryStorage;
1336 use crate::protocol::types::KeyPackagePayload;
1337
1338 let mut alice = make_protocol("alice");
1340 let alice_storage = Arc::new(MlsInMemoryStorage::new());
1341 alice.initialize_mls_for_test(alice_storage).unwrap();
1342
1343 let mut bob = make_protocol("bob");
1345 let bob_storage = Arc::new(MlsInMemoryStorage::new());
1346 bob.initialize_mls_for_test(bob_storage).unwrap();
1347
1348 let alice_key_pkg = {
1350 let mls = alice.mls_manager.as_ref().unwrap();
1351 let manager = mls.read().unwrap();
1352 manager.get_or_create_key_package().unwrap()
1353 };
1354 {
1356 let mls = bob.mls_manager.as_ref().unwrap();
1357 let manager = mls.read().unwrap();
1358 manager
1359 .import_key_package(&id("alice"), &alice_key_pkg.key_package_data)
1360 .unwrap();
1361 let welcome = manager.create_session(&id("alice")).unwrap();
1362 let alice_mls = alice.mls_manager.as_ref().unwrap();
1364 let alice_manager = alice_mls.read().unwrap();
1365 alice_manager.join_session(&welcome).unwrap();
1366 }
1367
1368 {
1370 let mls = alice.mls_manager.as_ref().unwrap();
1371 assert!(mls.read().unwrap().has_session(&id("bob")).unwrap());
1372 }
1373 {
1374 let mls = bob.mls_manager.as_ref().unwrap();
1375 assert!(mls.read().unwrap().has_session(&id("alice")).unwrap());
1376 }
1377
1378 alice.block_user(&id("bob")).unwrap();
1381 alice.unblock_user(&id("bob")).unwrap();
1382
1383 {
1385 let mls = alice.mls_manager.as_ref().unwrap();
1386 assert!(
1387 !mls.read().unwrap().has_session(&id("bob")).unwrap(),
1388 "Alice should have no session after unblock cleanup"
1389 );
1390 }
1391
1392 {
1394 let mls = bob.mls_manager.as_ref().unwrap();
1395 assert!(
1396 mls.read().unwrap().has_session(&id("alice")).unwrap(),
1397 "Bob still has the old session before receiving reset"
1398 );
1399 }
1400
1401 let alice_fresh_key_pkg = {
1403 let mls = alice.mls_manager.as_ref().unwrap();
1404 let manager = mls.read().unwrap();
1405 manager.get_or_create_key_package().unwrap()
1406 };
1407 let reset_payload = KeyPackagePayload {
1408 user_id: id("alice"),
1409 key_package_data: alice_fresh_key_pkg.key_package_data.clone(),
1410 remaining_lifetime_ms: alice_fresh_key_pkg.remaining_lifetime_ms(),
1411 timestamp_ms: 0,
1412 session_reset: true,
1413 wire_versions: Vec::new(),
1414 env_versions: Vec::new(),
1415 rich_versions: Vec::new(),
1416 data_versions: Vec::new(),
1417 nostr_pubkey: None,
1418 };
1419 let content = serde_json::to_string(&reset_payload).unwrap();
1420
1421 bob.handle_key_package_message(&id("alice"), &content, true);
1425
1426 {
1429 let mls = bob.mls_manager.as_ref().unwrap();
1430 let manager = mls.read().unwrap();
1431 assert!(
1432 manager.has_session(&id("alice")).unwrap(),
1433 "Bob should have a fresh session after session_reset + auto-establish"
1434 );
1435 }
1436
1437 assert!(
1440 !bob.confirmed_sessions.contains(&id("alice")),
1441 "Old confirmed session entry should be cleared"
1442 );
1443
1444 assert!(
1446 !bob.pending_key_packages.contains_key(&id("alice")),
1447 "Key package should be consumed after auto-establish"
1448 );
1449 }
1450}