use rust_xmpp::{muc::Muc, protocol::ModuleResult}; use serde_json::json; const ROOM: &str = "room@rooms.example.org"; fn send(m: &mut Muc, jid: &str, packet: &str) -> ModuleResult { let doc = roxmltree::Document::parse(packet).unwrap(); m.handle(jid, doc.root_element()).unwrap().unwrap() } fn setup(defaults: serde_json::Value) -> (tempfile::TempDir, Muc) { let root = tempfile::tempdir().unwrap(); let mut m = Muc::new(root.path().into(), "rooms.example.org".into()); m.set_defaults(defaults); send( &mut m, "alice@example.org/r", &format!(""), ); (root, m) } #[test] fn restricted_private_messages_allow_self_and_optional_moderators() { let (_root, mut m) = setup(json!({"allow_pm":"none"})); send( &mut m, "bob@example.org/r", &format!(""), ); let denied = send( &mut m, "bob@example.org/r", &format!("blocked"), ); assert!(denied.replies[0].contains("policy-violation")); assert!(denied.deliveries.is_empty()); let own = send( &mut m, "bob@example.org/r", &format!("self"), ); assert_eq!(own.deliveries.len(), 1); let config = send( &mut m, "alice@example.org/r", &format!( "1" ), ); assert!(config.replies[0].contains("type='result'")); let allowed = send( &mut m, "bob@example.org/r", &format!("allowed"), ); assert_eq!(allowed.deliveries.len(), 1); } #[test] fn broadcast_roles_hide_visitors_and_configuration_announces_transition() { let (_root, mut m) = setup(json!({"moderated":true,"presence_broadcast":{"moderator":true}})); let join = send( &mut m, "bob@example.org/r", &format!(""), ); assert!(join.deliveries.is_empty()); assert!( join.replies .iter() .any(|p| p.contains("/Bob") && p.contains("110")) ); let update = send( &mut m, "alice@example.org/r", &format!( "moderatorvisitor" ), ); assert!( update .deliveries .iter() .any(|(to, p)| to == "alice@example.org/r" && p.contains("/Bob") && !p.contains("unavailable")) ); } #[test] fn language_config_sections_discovery_and_native_affiliation_hats() { let (root, mut m) = setup(json!({"persistent":true})); let config = send( &mut m, "alice@example.org/r", &format!( "de" ), ); assert!(config.replies[0].contains("type='result'")); let path = rust_xmpp::storage::path(root.path(), "rooms.example.org", Some("room"), "config"); let mut frozen = rust_xmpp::storage::parse(&std::fs::read(&path).unwrap()).unwrap(); let hats=rust_xmpp::storage::parse(br#"return {['alice@example.org']={hats={['urn:staff']={active=true,title='Staff'},['urn:off']={active=false,title='Hidden'}}}}"#).unwrap(); frozen.set("_affiliation_data", hats); std::fs::write(path, rust_xmpp::storage::serialize(&frozen).unwrap()).unwrap(); let mut m = Muc::new(root.path().into(), "rooms.example.org".into()); let join = send( &mut m, "alice@example.org/r", &format!( "" ), ); let presence = join.replies.iter().find(|p| p.contains("110")).unwrap(); assert!(presence.contains("urn:staff")); assert!(!presence.contains("urn:off")); assert!(!presence.contains("fake")); let disco = send( &mut m, "alice@example.org/r", &format!( "" ), ); assert!(disco.replies[0].contains("urn:xmpp:hats:0")); assert!(disco.replies[0].contains("de")); let form = send( &mut m, "alice@example.org/r", &format!( "" ), ); assert!(form.replies[0].contains("Room information")); assert!(form.replies[0].contains("muc#roomconfig_lang")); } #[test] fn messages_cannot_spoof_room_identity_hats_or_occupant_identifiers() { let (_root, mut m) = setup(json!({})); send( &mut m, "bob@example.org/r", &format!(""), ); for (kind, to) in [ ("groupchat", ROOM.to_string()), ("chat", format!("{ROOM}/Bob")), ] { let result = send( &mut m, "alice@example.org/r", &format!( "safe" ), ); assert!(!result.deliveries.is_empty()); for (_, p) in result.deliveries { assert!(!p.contains("forged")); assert!(p.contains("occupant-id")); } } } #[test] fn disabled_optional_vcard_and_occupant_ids_are_not_advertised_or_emitted() { let root = tempfile::tempdir().unwrap(); let mut m = Muc::new(root.path().into(), "rooms.example.org".into()); m.set_component_options(json!({"muc_vcard":false,"muc_occupant_id":false}), false); let join = send( &mut m, "alice@example.org/r", &format!(""), ); assert!(join.replies.iter().all(|p| !p.contains("occupant-id"))); let disco = send( &mut m, "alice@example.org/r", &format!( "" ), ); assert!(!disco.replies[0].contains("occupant-id")); assert!(!disco.replies[0].contains("vcard-temp")); let vcard = send( &mut m, "alice@example.org/r", &format!(""), ); assert!(vcard.replies[0].contains("feature-not-implemented")); } #[test] fn mam_all_rooms_presence_logging_public_queries_and_optional_totals() { let root = tempfile::tempdir().unwrap(); let mut m = Muc::new(root.path().into(), "rooms.example.org".into()); m.set_component_options(json!({"muc_log_all_rooms":true,"muc_log_by_default":false,"muc_log_presences":true,"muc_log_include_total":false}),true); send( &mut m, "alice@example.org/r", &format!(""), ); send( &mut m, "alice@example.org/r", &format!("retained"), ); let query = send( &mut m, "outsider@example.org/r", &format!(""), ); let text = query.replies.join(""); assert!(text.contains("retained")); let mut saw_presence = false; rust_xmpp::archive::scan_store( root.path(), "rooms.example.org", Some("room"), "muc_log", |entry| { saw_presence |= entry.xml.contains("")); assert!(!text.contains(" index=")); send( &mut m, "alice@example.org/r", &format!( "" ), ); let mut count = 0; rust_xmpp::archive::scan_store( root.path(), "rooms.example.org", Some("room"), "muc_log", |_| { count += 1; Ok(()) }, ) .unwrap(); assert_eq!(count, 0); } #[test] fn offline_reserved_affiliates_follow_anonymity_and_roominfo_counts_nicknames() { let (root, _m) = setup( json!({"persistent":true,"whois":"moderators","presence_broadcast":{"none":true,"visitor":true,"participant":true,"moderator":true},"description":"Room description"}), ); let path = rust_xmpp::storage::path(root.path(), "rooms.example.org", Some("room"), "config"); let mut frozen = rust_xmpp::storage::parse(&std::fs::read(&path).unwrap()).unwrap(); frozen.set("offline@example.org", "member".into()); frozen.set("unnamed@example.org", "member".into()); frozen.set( "_affiliation_data", rust_xmpp::storage::parse( b"return {['offline@example.org']={reserved_nickname='Offline'}}", ) .unwrap(), ); std::fs::write(path, rust_xmpp::storage::serialize(&frozen).unwrap()).unwrap(); let mut m = Muc::new(root.path().into(), "rooms.example.org".into()); let guest = send( &mut m, "bob@example.org/r", &format!(""), ); let offline = guest .replies .iter() .find(|p| p.contains("/Offline")) .unwrap(); assert!(offline.contains("unavailable")); assert!(!offline.contains("offline@example.org")); assert!( guest .replies .iter() .all(|p| !p.contains("unnamed@example.org")) ); let owner = send( &mut m, "alice@example.org/r", &format!(""), ); assert!( owner .replies .iter() .any(|p| p.contains("unnamed@example.org")) ); send( &mut m, "bob@example.org/second", &format!(""), ); let disco = send( &mut m, "bob@example.org/r", &format!( "" ), ); assert!(disco.replies[0].contains("muc_semianonymous")); assert!(disco.replies[0].contains("2")); assert!(disco.replies[0].contains("Room description")); } #[test] fn recovery_claim_is_durable_and_unrelated_disconnect_keeps_temporary_claims() { let (root, mut first) = setup(json!({"persistent":false})); first.checkpoint_live().unwrap(); drop(first); let mut recovered = Muc::new(root.path().into(), "rooms.example.org".into()); recovered.prepare_recovery().unwrap(); recovered.disconnect("unrelated@example.org/r"); let restored = recovered .restore_for_session("alice@example.org/r") .unwrap(); assert!( restored .replies .iter() .any(|p| p.contains("110") && p.contains("role='moderator'")) ); drop(recovered); // No new checkpoint: an already consumed claim cannot resurrect stale role. let mut again = Muc::new(root.path().into(), "rooms.example.org".into()); again.prepare_recovery().unwrap(); let absent = again.restore_for_session("alice@example.org/r").unwrap(); assert!(absent.replies.is_empty()); assert!(absent.deliveries.is_empty()); } #[test] fn large_optional_history_does_not_prevent_checkpointing_membership() { let (_root, mut m) = setup(json!({})); send( &mut m, "alice@example.org/r", &format!( "{}", "x".repeat(20 * 1024) ), ); m.checkpoint_live().unwrap(); } #[test] fn explicit_new_nickname_consumes_old_proof_and_pending_nicks_are_reserved() { let (root, mut first) = setup(json!({"persistent":false})); send( &mut first, "bob@example.org/r", &format!(""), ); first.checkpoint_live().unwrap(); drop(first); let mut recovered = Muc::new(root.path().into(), "rooms.example.org".into()); recovered.prepare_recovery().unwrap(); let stolen = send( &mut recovered, "mallory@example.org/r", &format!(""), ); assert!(stolen.replies.iter().any(|p| p.contains("conflict"))); let explicit = send( &mut recovered, "alice@example.org/r", &format!(""), ); assert!(explicit.replies.iter().any(|p| p.contains("110"))); // Temporary room config and Bob's claim survive Alice's ordinary join. drop(recovered); let mut again = Muc::new(root.path().into(), "rooms.example.org".into()); again.prepare_recovery().unwrap(); assert!( again .restore_for_session("alice@example.org/r") .unwrap() .replies .is_empty() ); assert!( again .restore_for_session("bob@example.org/r") .unwrap() .replies .iter() .any(|p| p.contains("/Bob") && p.contains("110")) ); }