Files
rust-xmpp/tests/muc_access.rs
T
wt 7f60661eef - MUC сохраняет согласованный журнал конфигурации, affiliations, nick,
ролей,
  членства и ограниченной истории без MAM. Изменение фиксируется до
  публикации
  ответа; при ошибке записи состояние комнаты откатывается.
- Crash recovery использует последнее сохранённое поколение после
  SIGKILL,
  включая временные комнаты. Heartbeat обновляет живое членство;
  ожидающие записи сохраняют исходный deadline через повторные
  перезапуски.
  `muc_recovery_grace_period`: по умолчанию 300 секунд, пределы
  1..604800.
- Подтверждённые выходы, смена nick, ролей и политик комнаты не
  заменяются
  старой записью восстановления. Native config/state остаются экспортом
  для
  Prosody; согласованное восстановление Rust использует собственный
  sidecar.
- Исправлены MUC probe, неизвестные типы presence, вход без MUC `<x/>`,
  ошибки
  доставки и статус 333. Обычные presence extensions сохраняются в живом
  обмене; поддельная MUC identity фильтруется. `subject` вместе с `body`
  либо
  `thread` не изменяет тему комнаты.
- Подключены `muc_max_rooms`, `restrict_room_creation`, политики
  регистрации
  и зарегистрированного nick, `muc_tombstone_expiry`; добавлены
  встроенные
  административные ad-hoc команды уничтожения комнат и изменения
  affiliation.
- Hub сохраняет resumable XEP-0198 сессии, счётчики и очередь. Resume
  после
  перезапуска доступен на новом TLS/SASL-транспорте через legacy SASL и
  SASL2.
  Действующие приватные SM-записи восстанавливают логические сессии и
  MUC
  до открытия listeners: offline-доставка попадает в очередь,
  синтетический
  join и история повторно не отправляются. SM resume не требует
  дополнительной
  available presence; без SM восстановление MUC требует новой
  аутентификации
  точного полного JID и available presence либо явного MUC-входа.
  Входящий handled-счётчик обновляется после обработки. Сокеты TCP/TLS
  не сохраняются; неопределённая сетевая отправка допускает повторную
  доставку.
- Известные встроенные MUC-возможности Prosody отображаются на
  Rust-реализацию.
  Произвольные Lua/community-модули не исполняются; неизвестные явно
  включённые
  MUC-модули отклоняются при запуске. Общие и host `modules_disabled`
  учитываются
  для vCard и occupant-id.

Порядок восстановления, ограничения хранения и отката на Prosody:
[docs/MUC-RECOVERY.md](docs/MUC-RECOVERY.md).

Проверка: 293 Rust tests, Clippy/fmt/release, полный сетевой прогон, 500
TLS
MUC+SM ресурсов с SIGKILL/resume и native reference — успешно.
Подробности: [docs/VALIDATION-0.8.0.md](docs/VALIDATION-0.8.0.md).
2026-10-20 17:34:43 +07:00

124 lines
4.8 KiB
Rust

use rust_xmpp::{
muc::Muc,
protocol::ModuleResult,
storage::{self, LuaValue as V},
};
fn send(muc: &mut Muc, jid: &str, packet: &str) -> ModuleResult {
let doc = roxmltree::Document::parse(packet).unwrap();
muc.handle(jid, doc.root_element()).unwrap().unwrap()
}
fn join(muc: &mut Muc, jid: &str, nick: &str) -> ModuleResult {
send(
muc,
jid,
&format!(
"<presence to='r@rooms.test/{nick}'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
)
}
fn role(muc: &mut Muc, actor: &str, nick: &str, role: &str) -> ModuleResult {
send(
muc,
actor,
&format!(
"<iq type='set' to='r@rooms.test'><query xmlns='http://jabber.org/protocol/muc#admin'><item nick='{nick}' role='{role}'/></query></iq>"
),
)
}
#[test]
fn moderators_cannot_kick_or_demote_other_moderators() {
let temp = tempfile::tempdir().unwrap();
let mut muc = Muc::new(temp.path().into(), "rooms.test".into());
muc.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
join(&mut muc, "owner@test/r", "Owner");
join(&mut muc, "bob@test/r", "Bob");
join(&mut muc, "carol@test/r", "Carol");
for nick in ["Bob", "Carol"] {
assert!(
role(&mut muc, "owner@test/r", nick, "moderator").replies[0].contains("type='result'")
);
}
for denied_role in ["none", "visitor", "participant"] {
let result = role(&mut muc, "bob@test/r", "Carol", denied_role);
assert!(result.replies[0].contains("not-allowed"));
assert!(result.deliveries.is_empty());
}
// An owner can still revoke an unaffiliated moderator's role.
let result = role(&mut muc, "owner@test/r", "Carol", "participant");
assert!(result.replies[0].contains("type='result'"));
assert!(
result
.deliveries
.iter()
.any(|(jid, packet)| jid == "carol@test/r" && packet.contains("role='participant'"))
);
assert!(role(&mut muc, "carol@test/r", "Bob", "none").replies[0].contains("forbidden"));
}
#[test]
fn binary_occupant_id_salt_survives_saves_and_restarts() {
let temp = tempfile::tempdir().unwrap();
let salt = V::String(vec![0xff, 0, 0x80, b's']);
let mut raw = storage::parse(
b"return {_jid='r@rooms.test'; _data={persistent=true}; ['owner@test']='owner'}",
)
.unwrap();
let mut data = raw.get("_data").unwrap().clone();
data.set("occupant_id_salt", salt.clone());
raw.set("_data", data);
storage::save(temp.path(), "rooms.test", Some("r"), "config", &raw).unwrap();
let mut ids = Vec::new();
for _ in 0..2 {
let mut muc = Muc::new(temp.path().into(), "rooms.test".into());
muc.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
let result = join(&mut muc, "owner@test/r", "Owner");
let packet = result
.replies
.iter()
.find(|p| p.starts_with("<presence"))
.unwrap();
let doc = roxmltree::Document::parse(packet).unwrap();
let id = doc
.descendants()
.find(|n| n.has_tag_name(("urn:xmpp:occupant-id:0", "occupant-id")))
.unwrap()
.attribute("id")
.unwrap()
.to_owned();
ids.push(id);
let saved = storage::load(temp.path(), "rooms.test", Some("r"), "config")
.unwrap()
.unwrap();
assert_eq!(
saved.get("_data").unwrap().get("occupant_id_salt"),
Some(&salt)
);
}
assert_eq!(ids[0], ids[1]);
}
#[test]
fn nested_affiliations_do_not_fall_back_to_obsolete_flat_entries() {
let temp = tempfile::tempdir().unwrap();
let raw = storage::parse(b"return {_jid='r@rooms.test'; _data={persistent=true; members_only=true}; _affiliations={['owner@test']='owner'; ['member@test']='member'}; ['member@test']='owner'}").unwrap();
storage::save(temp.path(), "rooms.test", Some("r"), "config", &raw).unwrap();
let mut muc = Muc::new(temp.path().into(), "rooms.test".into());
muc.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
join(&mut muc, "owner@test/r", "Owner");
let result = send(
&mut muc,
"owner@test/r",
"<iq type='set' to='r@rooms.test'><query xmlns='http://jabber.org/protocol/muc#admin'><item jid='member@test' affiliation='none'/></query></iq>",
);
assert!(result.replies[0].contains("type='result'"));
assert!(join(&mut muc, "member@test/r", "Member").replies[0].contains("registration-required"));
drop(muc);
let mut muc = Muc::new(temp.path().into(), "rooms.test".into());
muc.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
assert!(join(&mut muc, "member@test/r", "Member").replies[0].contains("registration-required"));
}