Files
rust-xmpp/tests/pep_client_interop.rs
2026-10-20 15:59:55 +07:00

486 lines
18 KiB
Rust

//! 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!(
"<iq type='set' id='publish'><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='{node}'><item id='{id}'>{payload}</item></publish><publish-options><x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>http://jabber.org/protocol/pubsub#publish-options</value></field>{fields}</x></publish-options></pubsub></iq>"
),
)
}
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::<std::collections::BTreeSet<_>>()
};
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 = "<field var='pubsub#access_model'><value>open</value></field>";
#[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!(
"<iq type='get' id='bundle' to='alice@example.test'><pubsub xmlns='http://jabber.org/protocol/pubsub'><items node='{node}'><item id='42'/></items></pubsub></iq>"
),
);
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("<presence><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' node='urn:test:client' ver='caps'/></presence>").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!(
"<iq type='result' id='{id}'><query xmlns='http://jabber.org/protocol/disco#info'><feature var='urn:xmpp:avatar:metadata+notify'/><feature var='eu.siacs.conversations.axolotl.devicelist+notify'/></query></iq>"
);
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",
"<data xmlns='urn:xmpp:avatar:data'>aW1hZ2U=</data>",
false,
),
(
"urn:xmpp:avatar:metadata",
"avatar-hash",
"<metadata xmlns='urn:xmpp:avatar:metadata'><info id='avatar-hash' bytes='5' type='image/png'/></metadata>",
true,
),
(
"eu.siacs.conversations.axolotl.devicelist",
"current",
"<list xmlns='eu.siacs.conversations.axolotl'><device id='42'/></list>",
true,
),
(
"urn:xmpp:omemo:2:bundles",
"42",
"<bundle xmlns='urn:xmpp:omemo:2'><ik>test-key</ik></bundle>",
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!(
"<iq type='get' id='read' to='alice@example.test'><pubsub xmlns='http://jabber.org/protocol/pubsub'><items node='{node}'><item id='{id}'/></items></pubsub></iq>"
),
);
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!("<field var='pubsub#{key}'><value>true</value></field>"))
.collect::<String>();
let result = publish(
d.path(),
"urn:test:existing",
"current",
"<data xmlns='urn:test'>new</data>",
&fields,
);
assert!(
result.replies[0].contains("type='result'"),
"{:?}",
result.replies
);
let result = publish(
d.path(),
"urn:test:existing",
"current",
"<data xmlns='urn:test'>bad</data>",
"<field var='pubsub#deliver_payloads'><value>false</value></field>",
);
assert!(result.replies[0].contains("precondition-not-met"));
let read = request(
d.path(),
"alice@example.test/phone",
"<iq type='get' id='read'><pubsub xmlns='http://jabber.org/protocol/pubsub'><items node='urn:test:existing'/></pubsub></iq>",
);
assert!(read.replies[0].contains(">new</data>"));
assert!(!read.replies[0].contains(">bad</data>"));
}
fn owner_config(root: &Path, actor: &str, node: &str, fields: &str) -> ModuleResult {
request(
root,
actor,
&format!(
"<iq type='set' id='configure'><pubsub xmlns='http://jabber.org/protocol/pubsub#owner'><configure node='{node}'><x xmlns='jabber:x:data' type='submit'>{fields}</x></configure></pubsub></iq>"
),
)
}
#[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",
"<iq type='get' id='get-config'><pubsub xmlns='http://jabber.org/protocol/pubsub#owner'><configure node='urn:test:migrated'/></pubsub></iq>",
);
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!(
"<iq type='set' id='set-config'><pubsub xmlns='http://jabber.org/protocol/pubsub#owner'><configure node='urn:test:migrated'>{form}</configure></pubsub></iq>"
),
);
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!("<field var='pubsub#{key}'><value>{value}</value></field>"),
);
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}<field var='pubsub#itemreply'><value>{reply}</value></field>");
let node = format!("urn:test:{reply}");
assert!(
publish(
d.path(),
&node,
"current",
"<data xmlns='urn:test'>value</data>",
&fields
)
.replies[0]
.contains("type='result'")
);
let read = request(
d.path(),
"bob@remote.test/phone",
&format!(
"<iq type='get' id='read' to='alice@example.test'><pubsub xmlns='http://jabber.org/protocol/pubsub'><items node='{node}'/></pubsub></iq>"
),
);
let replay = request(
d.path(),
"bob@remote.test/phone",
&format!(
"<iq type='set' id='sub' to='alice@example.test'><pubsub xmlns='http://jabber.org/protocol/pubsub'><subscribe node='{node}' jid='bob@remote.test/phone'/></pubsub></iq>"
),
);
assert_eq!(replay.deliveries.len(), 1);
let event = publish(
d.path(),
&node,
"current",
"<data xmlns='urn:test'>update</data>",
&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!(
"<iq type='set' id='create'><pubsub xmlns='http://jabber.org/protocol/pubsub'><create node='{kind}'/><configure><x xmlns='jabber:x:data' type='submit'><field var='pubsub#notification_type'><value>{kind}</value></field><field var='pubsub#send_last_published_item'><value>on_sub</value></field></x></configure></pubsub></iq>"
),
);
assert!(result.replies[0].contains("type='result'"));
let publish = format!(
"<iq type='set' id='publish'><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='{kind}'><item id='one'><data xmlns='urn:test'/></item></publish></pubsub></iq>"
);
call("alice@example.test/phone", &publish);
let replay = call(
"bob@remote.test/phone",
&format!(
"<iq type='set' id='sub'><pubsub xmlns='http://jabber.org/protocol/pubsub'><subscribe node='{kind}' jid='bob@remote.test/phone'/></pubsub></iq>"
),
);
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}<field var='pubsub#publish_model'><value>{model}</value></field>");
assert!(
publish(
d.path(),
&node,
"current",
"<data xmlns='urn:test'/>",
&fields
)
.replies[0]
.contains("type='result'")
);
let write = format!(
"<iq type='set' id='write' to='alice@example.test'><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='{node}'><item id='bob'><data xmlns='urn:test'/></item></publish></pubsub></iq>"
);
let initial = request(d.path(), "bob@example.test/phone", &write);
assert_eq!(
initial.replies[0].contains("type='result'"),
model == "open"
);
let sub = format!(
"<iq type='set' id='sub' to='alice@example.test'><pubsub xmlns='http://jabber.org/protocol/pubsub'><subscribe node='{node}' jid='bob@example.test/phone'/></pubsub></iq>"
);
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();
}
}