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