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