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