//! Wire-level PEP requests used by Conversations/monocles. This exercises the //! server handlers; it does not claim Android device interoperability validation. use rust_xmpp::{ auth, protocol::ModuleResult, pubsub, storage::{self, LuaValue as V}, }; use std::path::Path; fn request(root: &Path, actor: &str, stanza: &str) -> ModuleResult { let doc = roxmltree::Document::parse(stanza).unwrap(); pubsub::handle(root, "example.test", actor, doc.root_element()) .unwrap() .unwrap() } fn account(root: &Path) { auth::create(root, "example.test", "alice", "secret", "SHA-256").unwrap(); } fn publish(root: &Path, node: &str, id: &str, payload: &str, fields: &str) -> ModuleResult { request( root, "alice@example.test/phone", &format!( "{payload}http://jabber.org/protocol/pubsub#publish-options{fields}" ), ) } fn assert_payload(stanza: &str, payload: &str) { fn same(actual: roxmltree::Node<'_, '_>, expected: roxmltree::Node<'_, '_>) { assert_eq!(actual.tag_name(), expected.tag_name()); let attrs = |n: roxmltree::Node<'_, '_>| { n.attributes() .map(|a| { ( a.namespace().unwrap_or("").to_owned(), a.name().to_owned(), a.value().to_owned(), ) }) .collect::>() }; assert_eq!(attrs(actual), attrs(expected)); let actual: Vec<_> = actual .children() .filter(|n| n.is_element() || n.is_text()) .collect(); let expected: Vec<_> = expected .children() .filter(|n| n.is_element() || n.is_text()) .collect(); assert_eq!(actual.len(), expected.len()); for (a, e) in actual.into_iter().zip(expected) { if e.is_element() { same(a, e); } else { assert_eq!(a.text(), e.text()); } } } let actual = roxmltree::Document::parse(stanza).unwrap(); let expected = roxmltree::Document::parse(payload).unwrap(); let e = expected.root_element(); let a = actual .descendants() .find(|n| n.is_element() && n.tag_name() == e.tag_name()) .unwrap(); same(a, e); } const OPEN: &str = "open"; #[test] fn missing_own_omemo_bundle_returns_recoverable_item_not_found() { let d = tempfile::tempdir().unwrap(); account(d.path()); // Monocles latches pepBroken for other errors in this initial lookup. for node in [ "urn:xmpp:omemo:2:bundles", "eu.siacs.conversations.axolotl.bundles:42", ] { let result = request( d.path(), "alice@example.test/phone", &format!( "" ), ); let doc = roxmltree::Document::parse(&result.replies[0]).unwrap(); assert_eq!(doc.root_element().attribute("type"), Some("error")); assert!( doc.descendants() .any(|n| n.has_tag_name(("urn:ietf:params:xml:ns:xmpp-stanzas", "item-not-found"))) ); } } #[test] fn avatar_and_omemo_publish_options_support_readback_and_caps_notifications() { let d = tempfile::tempdir().unwrap(); account(d.path()); let presence = roxmltree::Document::parse("").unwrap(); let caps = pubsub::on_presence( d.path(), "example.test", "alice@example.test/laptop", presence.root_element(), ) .unwrap(); let caps_doc = roxmltree::Document::parse(&caps.deliveries[0].1).unwrap(); let id = caps_doc.root_element().attribute("id").unwrap(); let answer = format!( "" ); let answer = roxmltree::Document::parse(&answer).unwrap(); pubsub::on_disco_result( d.path(), "example.test", "alice@example.test/laptop", answer.root_element(), ) .unwrap() .unwrap(); for (node, id, payload, notify) in [ ( "urn:xmpp:avatar:data", "avatar-hash", "aW1hZ2U=", false, ), ( "urn:xmpp:avatar:metadata", "avatar-hash", "", true, ), ( "eu.siacs.conversations.axolotl.devicelist", "current", "", true, ), ( "urn:xmpp:omemo:2:bundles", "42", "test-key", false, ), ] { let result = publish(d.path(), node, id, payload, OPEN); assert!( result.replies[0].contains("type='result'"), "{:?}", result.replies ); assert_eq!(result.deliveries.len(), usize::from(notify)); if notify { assert_eq!(result.deliveries[0].0, "alice@example.test/laptop"); assert_payload(&result.deliveries[0].1, payload); } let read = request( d.path(), "bob@remote.test/phone", &format!( "" ), ); assert_payload(&read.replies[0], payload); } } #[test] fn publish_options_match_implicit_notification_defaults_on_old_nodes() { let d = tempfile::tempdir().unwrap(); account(d.path()); let mut nodes = V::table(); let mut node = V::table(); node.set("name", "urn:test:existing".into()); // Native and earlier Rust stores can omit default-valued config fields. node.set("config", V::table()); nodes.set("urn:test:existing", node); storage::save(d.path(), "example.test", Some("alice"), "pep", &nodes).unwrap(); let fields = [ "deliver_notifications", "deliver_payloads", "notify_delete", "notify_retract", ] .into_iter() .map(|key| format!("true")) .collect::(); let result = publish( d.path(), "urn:test:existing", "current", "new", &fields, ); assert!( result.replies[0].contains("type='result'"), "{:?}", result.replies ); let result = publish( d.path(), "urn:test:existing", "current", "bad", "false", ); assert!(result.replies[0].contains("precondition-not-met")); let read = request( d.path(), "alice@example.test/phone", "", ); assert!(read.replies[0].contains(">new")); assert!(!read.replies[0].contains(">bad")); } fn owner_config(root: &Path, actor: &str, node: &str, fields: &str) -> ModuleResult { request( root, actor, &format!( "{fields}" ), ) } #[test] fn migrated_native_configuration_can_roundtrip_access_model_edit() { let d = tempfile::tempdir().unwrap(); account(d.path()); let path = storage::path(d.path(), "example.test", Some("alice"), "pep"); storage::atomic_write(&path, b"return { ['urn:test:migrated']={name='urn:test:migrated';config={access_model='presence';notification_type='headline';itemreply='none';payload_type='urn:test:data';type='urn:stale:alias';publish_model='publishers';max_items=1};subscribers={};affiliations={['alice@example.test']='owner'}}};").unwrap(); let get = request( d.path(), "alice@example.test/phone", "", ); assert!(!get.replies[0].contains("pubsub#payload_type")); assert!(get.replies[0].contains("pubsub#type")); let doc = roxmltree::Document::parse(&get.replies[0]).unwrap(); assert_eq!( doc.descendants() .filter(|n| n.attribute("var") == Some("pubsub#type")) .count(), 1 ); let form = doc .descendants() .find(|n| n.has_tag_name(("jabber:x:data", "x"))) .unwrap(); let form = rust_xmpp::protocol::xml(form) .replace("type='form'", "type='submit'") .replace(">presence<", ">open<"); let set = request( d.path(), "alice@example.test/phone", &format!( "{form}" ), ); assert!( set.replies[0].contains("type='result'"), "{:?}", set.replies ); let metadata = storage::load(d.path(), "example.test", Some("alice"), "pep") .unwrap() .unwrap(); let config = metadata .get("urn:test:migrated") .unwrap() .get("config") .unwrap(); assert_eq!(config.get("access_model").and_then(V::as_str), Some("open")); assert!(config.get("type").is_none()); assert_eq!( config.get("payload_type").and_then(V::as_str), Some("urn:test:data") ); assert_eq!( config.get("notification_type").and_then(V::as_str), Some("headline") ); assert_eq!(config.get("itemreply").and_then(V::as_str), Some("none")); for (key, value) in [ ("notification_type", "chat"), ("itemreply", "everyone"), ("publish_model", "anyone"), ] { let bad = owner_config( d.path(), "alice@example.test/phone", "urn:test:migrated", &format!("{value}"), ); assert!( bad.replies[0].contains("not-acceptable"), "{:?}", bad.replies ); } } #[test] fn native_itemreply_controls_publisher_visibility_in_reads_and_replays() { let d = tempfile::tempdir().unwrap(); account(d.path()); for reply in ["none", "owner", "publisher"] { let fields = format!("{OPEN}{reply}"); let node = format!("urn:test:{reply}"); assert!( publish( d.path(), &node, "current", "value", &fields ) .replies[0] .contains("type='result'") ); let read = request( d.path(), "bob@remote.test/phone", &format!( "" ), ); let replay = request( d.path(), "bob@remote.test/phone", &format!( "" ), ); assert_eq!(replay.deliveries.len(), 1); let event = publish( d.path(), &node, "current", "update", &fields, ); assert_eq!(event.deliveries.len(), 1); for stanza in [ &read.replies[0], &replay.deliveries[0].1, &event.deliveries[0].1, ] { let doc = roxmltree::Document::parse(stanza).unwrap(); let item = doc .descendants() .find(|n| n.is_element() && n.tag_name().name() == "item") .unwrap(); assert_eq!( item.attribute("publisher"), (reply == "publisher").then_some("alice@example.test") ); } } } #[test] fn native_component_notification_type_applies_to_events_and_subscription_replay() { let d = tempfile::tempdir().unwrap(); let call = |actor: &str, xml: &str| { let doc = roxmltree::Document::parse(xml).unwrap(); pubsub::handle_component( d.path(), "pubsub.example.test", actor, &["alice@example.test".into()], doc.root_element(), ) .unwrap() .unwrap() }; for kind in ["normal", "headline"] { let result = call( "alice@example.test/phone", &format!( "{kind}on_sub" ), ); assert!(result.replies[0].contains("type='result'")); let publish = format!( "" ); call("alice@example.test/phone", &publish); let replay = call( "bob@remote.test/phone", &format!( "" ), ); let event = call("alice@example.test/phone", &publish); for result in [replay, event] { assert_eq!(result.deliveries.len(), 1); let doc = roxmltree::Document::parse(&result.deliveries[0].1).unwrap(); assert_eq!(doc.root_element().attribute("type"), Some(kind)); } } } #[test] fn native_open_and_subscriber_publish_models_preserve_outcast_and_blocking_checks() { let d = tempfile::tempdir().unwrap(); account(d.path()); auth::create(d.path(), "example.test", "bob", "secret", "SHA-256").unwrap(); for model in ["open", "subscribers"] { let node = format!("urn:test:{model}"); let fields = format!("{OPEN}{model}"); assert!( publish( d.path(), &node, "current", "", &fields ) .replies[0] .contains("type='result'") ); let write = format!( "" ); let initial = request(d.path(), "bob@example.test/phone", &write); assert_eq!( initial.replies[0].contains("type='result'"), model == "open" ); let sub = format!( "" ); request(d.path(), "bob@example.test/phone", &sub); assert!( request(d.path(), "bob@example.test/phone", &write).replies[0] .contains("type='result'") ); let mut metadata = storage::load(d.path(), "example.test", Some("alice"), "pep") .unwrap() .unwrap(); let mut entry = metadata.get(&node).unwrap().clone(); let mut affiliations = entry.get("affiliations").unwrap().clone(); affiliations.set("bob@example.test", "outcast".into()); entry.set("affiliations", affiliations); metadata.set(&node, entry); storage::save(d.path(), "example.test", Some("alice"), "pep", &metadata).unwrap(); assert!( request(d.path(), "bob@example.test/phone", &write).replies[0].contains("forbidden") ); let mut metadata = storage::load(d.path(), "example.test", Some("alice"), "pep") .unwrap() .unwrap(); let mut entry = metadata.get(&node).unwrap().clone(); entry.set("affiliations", V::table()); metadata.set(&node, entry); storage::save(d.path(), "example.test", Some("alice"), "pep", &metadata).unwrap(); let mut blocked = V::table(); blocked.set("bob@example.test", V::Bool(true)); storage::save( d.path(), "example.test", Some("alice"), "blocklist", &blocked, ) .unwrap(); assert!( request(d.path(), "bob@example.test/phone", &write).replies[0].contains("forbidden") ); storage::save( d.path(), "example.test", Some("alice"), "blocklist", &V::table(), ) .unwrap(); } }