Files
rust-xmpp/tests/muc_crash_state.rs
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

229 lines
8.5 KiB
Rust

//! Dropping Muc without checkpoint_live models an abrupt process loss: only
//! checkpoints committed before returning the accepted stanza can survive.
use rust_xmpp::{muc::Muc, protocol::ModuleResult, storage};
use serde_json::json;
const HOST: &str = "rooms.example.org";
const ROOM: &str = "crash@rooms.example.org";
const A: &str = "alice@example.org/desktop";
const B: &str = "bob@example.org/phone";
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 muc(root: &std::path::Path) -> Muc {
let mut m = Muc::new(root.into(), HOST.into());
m.set_component_options(json!({"muc_room_locking":false,"max_history_messages":3,"muc_room_default_history_length":3}), false);
m
}
fn join(m: &mut Muc, jid: &str, nick: &str) -> ModuleResult {
send(
m,
jid,
&format!(
"<presence to='{ROOM}/{nick}'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
)
}
fn has_self(out: &ModuleResult) -> bool {
out.replies.iter().any(|p| p.contains("code='110'"))
}
#[test]
fn temporary_membership_and_full_bounded_history_survive_ungraceful_loss() {
let root = tempfile::tempdir().unwrap();
let mut first = muc(root.path());
assert!(has_self(&join(&mut first, A, "Alice")));
assert!(has_self(&join(&mut first, B, "Bob")));
for text in ["old-zero", "recent-one", "recent-two", "recent-three"] {
let out = send(
&mut first,
A,
&format!("<message to='{ROOM}' type='groupchat'><body>{text}</body></message>"),
);
assert!(!out.deliveries.is_empty());
}
drop(first);
let mut next = muc(root.path());
next.prepare_recovery().unwrap();
assert!(
next.restore_for_session("alice@example.org/other")
.unwrap()
.replies
.is_empty()
);
let a = next.restore_for_session(A).unwrap();
assert!(has_self(&a));
// A dormant Bob is never an available routing target before authenticating.
let out = send(
&mut next,
A,
&format!("<message to='{ROOM}' type='groupchat'><body>after-restart</body></message>"),
);
assert!(out.deliveries.iter().all(|(jid, _)| jid != B));
let b = next.restore_for_session(B).unwrap();
let history = b.replies.join("");
assert!(has_self(&b));
assert!(
!history.contains("old-zero") && !history.contains("recent-one"),
"{history}"
);
assert!(
history.contains("recent-two")
&& history.contains("recent-three")
&& history.contains("after-restart")
);
assert!(history.contains("urn:xmpp:delay"));
drop(next);
let mut third = muc(root.path());
third.prepare_recovery().unwrap();
assert!(has_self(&third.restore_for_session(A).unwrap()));
assert!(has_self(&third.restore_for_session(B).unwrap()));
}
#[test]
fn acknowledged_kick_and_room_destruction_cannot_resurrect_membership() {
let root = tempfile::tempdir().unwrap();
let mut first = muc(root.path());
join(&mut first, A, "Alice");
join(&mut first, B, "Bob");
let kicked = send(
&mut first,
A,
&format!(
"<iq to='{ROOM}' type='set' id='kick'><query xmlns='http://jabber.org/protocol/muc#admin'><item nick='Bob' role='none'/></query></iq>"
),
);
assert!(kicked.replies.iter().any(|p| p.contains("type='result'")));
drop(first);
let mut next = muc(root.path());
next.prepare_recovery().unwrap();
assert!(next.restore_for_session(B).unwrap().replies.is_empty());
assert!(has_self(&next.restore_for_session(A).unwrap()));
let destroyed = send(
&mut next,
A,
&format!(
"<iq to='{ROOM}' type='set' id='destroy'><query xmlns='http://jabber.org/protocol/muc#owner'><destroy/></query></iq>"
),
);
assert!(
destroyed
.replies
.iter()
.any(|p| p.contains("type='result'"))
);
drop(next);
let mut third = muc(root.path());
third.prepare_recovery().unwrap();
assert!(third.restore_for_session(A).unwrap().replies.is_empty());
assert!(third.restore_for_session(B).unwrap().replies.is_empty());
}
#[test]
fn dormant_deadline_stays_fixed_when_another_occupant_recovers_and_sends() {
let root = tempfile::tempdir().unwrap();
let mut first = muc(root.path());
join(&mut first, A, "Alice");
join(&mut first, B, "Bob");
drop(first);
let initial = storage::load(root.path(), HOST, None, "rust_muc_checkpoint")
.unwrap()
.unwrap();
let initial_at = initial
.get("rooms")
.unwrap()
.get(ROOM)
.unwrap()
.get("saved_at")
.unwrap()
.as_i64()
.unwrap();
let mut next = muc(root.path());
next.prepare_recovery().unwrap();
next.restore_for_session(A).unwrap();
send(
&mut next,
A,
&format!("<message to='{ROOM}' type='groupchat'><body>traffic</body></message>"),
);
drop(next);
storage::update(root.path(), HOST, None, "rust_muc_checkpoint", |v| {
let mut rooms = v.get("rooms").unwrap().clone();
let mut room = rooms.get(ROOM).unwrap().clone();
assert_eq!(room.get("saved_at").unwrap().as_i64().unwrap(), initial_at);
room.set("saved_at", storage::LuaValue::Integer(initial_at - 301));
rooms.set(ROOM, room);
v.set("rooms", rooms);
Ok(())
})
.unwrap();
let mut third = muc(root.path());
third.prepare_recovery().unwrap();
assert!(third.restore_for_session(B).unwrap().replies.is_empty());
}
#[test]
fn durable_disconnect_removes_last_temporary_room_without_shutdown_export() {
let root = tempfile::tempdir().unwrap();
let mut first = muc(root.path());
join(&mut first, A, "Alice");
first.disconnect_durable(A).unwrap();
drop(first);
let mut next = muc(root.path());
next.prepare_recovery().unwrap();
assert!(next.restore_for_session(A).unwrap().replies.is_empty());
}
#[test]
fn journal_failure_returns_no_routing_and_rolls_back_role_mutation() {
let root = tempfile::tempdir().unwrap();
let mut first = muc(root.path());
join(&mut first, A, "Alice");
join(&mut first, B, "Bob");
let path = storage::path(root.path(), HOST, None, "rust_muc_checkpoint");
let checkpoint = std::fs::read(&path).unwrap();
std::fs::remove_file(&path).unwrap();
std::fs::create_dir(&path).unwrap();
let packet = format!(
"<iq to='{ROOM}' type='set' id='kick'><query xmlns='http://jabber.org/protocol/muc#admin'><item nick='Bob' role='none'/></query></iq>"
);
let doc = roxmltree::Document::parse(&packet).unwrap();
assert!(first.handle(A, doc.root_element()).is_err());
std::fs::remove_dir(&path).unwrap();
storage::atomic_write(&path, &checkpoint).unwrap();
let out = send(
&mut first,
B,
&format!("<message to='{ROOM}' type='groupchat'><body>still-participant</body></message>"),
);
assert!(
out.deliveries
.iter()
.any(|(jid, p)| jid == A && p.contains("still-participant"))
);
}
#[test]
fn safe_presence_checkpoint_sanitization_is_idempotent() {
use rust_xmpp::muc_recovery::sanitize_presence_payload;
let payload = "<show xmlns='jabber:client'>away</show><status xmlns='jabber:client'>Ready &amp; waiting</status><x xmlns='http://jabber.org/protocol/muc'><password>secret</password></x>";
let safe = sanitize_presence_payload(payload);
assert_eq!(
safe,
"<show>away</show><status>Ready &amp; waiting</status>"
);
assert_eq!(sanitize_presence_payload(&safe), safe);
}
#[test]
fn foreign_stanzas_do_not_initialize_or_rewrite_muc_checkpoint() {
let root = tempfile::tempdir().unwrap();
let mut m = muc(root.path());
let packet = "<message to='bob@example.org/phone' type='chat'><body>direct</body></message>";
let doc = roxmltree::Document::parse(packet).unwrap();
assert!(m.handle(A, doc.root_element()).unwrap().is_none());
let path = storage::path(root.path(), HOST, None, "rust_muc_checkpoint");
assert!(!path.exists());
join(&mut m, A, "Alice");
let checkpoint = std::fs::read(&path).unwrap();
// A sentinel distinguishes a write from an identical journal serialization.
let sentinel = [checkpoint.as_slice(), b"-- untouched\n"].concat();
storage::atomic_write(&path, &sentinel).unwrap();
assert!(m.handle(A, doc.root_element()).unwrap().is_none());
assert_eq!(std::fs::read(&path).unwrap(), sentinel);
}