- 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).
This commit is contained in:
1 parent
b30fc524d8
commit
7f60661eef
41 files changed
+9804
-452
No files matched your search
+11
-1
@@ -10,7 +10,13 @@ fn send(muc: &mut Muc, jid: &str, packet: &str) -> ModuleResult {
|
||||
}
|
||||
|
||||
fn join(muc: &mut Muc, jid: &str, nick: &str) -> ModuleResult {
|
||||
send(muc, jid, &format!("<presence to='r@rooms.test/{nick}'/>"))
|
||||
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 {
|
||||
@@ -27,6 +33,7 @@ fn role(muc: &mut Muc, actor: &str, nick: &str, role: &str) -> ModuleResult {
|
||||
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");
|
||||
@@ -67,6 +74,7 @@ fn binary_occupant_id_salt_survives_saves_and_restarts() {
|
||||
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
|
||||
@@ -99,6 +107,7 @@ fn nested_affiliations_do_not_fall_back_to_obsolete_flat_entries() {
|
||||
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,
|
||||
@@ -109,5 +118,6 @@ fn nested_affiliations_do_not_fall_back_to_obsolete_flat_entries() {
|
||||
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"));
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SIGKILL MUC durability over real TCP/STARTTLS (no shutdown checkpoint).
|
||||
|
||||
Usage: python3 tests/muc_crash_network.py target/release/rust-xmpp
|
||||
All crashes happen with sockets open, immediately after the relevant wire reply.
|
||||
The grace test ages only the persisted clock while the server is stopped.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
from _wire_client import Client, local
|
||||
from muc_restart_network import MUC, USER, iq, occupant, presence, start, not_joined
|
||||
|
||||
STANZAS = 'urn:ietf:params:xml:ns:xmpp-stanzas'
|
||||
|
||||
|
||||
def join(client, room, nick, history=0, password=None):
|
||||
secret = '' if password is None else f'<password>{escape(password)}</password>'
|
||||
client.send(f"<presence to='{room}/{nick}'><x xmlns='{MUC}'>{secret}"
|
||||
f"<history maxstanzas='{history}'/></x><show>away</show>"
|
||||
'<status>durable presence</status></presence>')
|
||||
return presence(client, room, nick)
|
||||
|
||||
|
||||
def configure(client, room, **values):
|
||||
fields = "<field var='FORM_TYPE'><value>http://jabber.org/protocol/muc#roomconfig</value></field>"
|
||||
fields += ''.join(f"<field var='muc#roomconfig_{key}'><value>{escape(str(value))}</value></field>"
|
||||
for key, value in values.items())
|
||||
return iq(client, 'config', f"<query xmlns='{MUC}#owner'><x xmlns='jabber:x:data' type='submit'>{fields}</x></query>", room)
|
||||
|
||||
|
||||
def role(client, room, nick, value):
|
||||
return iq(client, 'role', f"<query xmlns='{MUC}#admin'><item nick='{nick}' role='{value}'/></query>", room)
|
||||
|
||||
|
||||
def denied(packet, condition):
|
||||
assert packet.get('type') == 'error', ET.tostring(packet)
|
||||
assert packet.find(f'.//{{{STANZAS}}}{condition}') is not None, ET.tostring(packet)
|
||||
|
||||
|
||||
class Server:
|
||||
def __init__(self, binary, root, options=None):
|
||||
self.binary, self.root = binary, root
|
||||
self.clients, self.process = [], None
|
||||
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1',
|
||||
'-keyout', str(root/'key.pem'), '-out', str(root/'cert.pem'), '-subj',
|
||||
'/CN=localhost', '-addext', 'subjectAltName=DNS:localhost'], check=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1', 0))
|
||||
self.port = probe.getsockname()[1]
|
||||
self.config = root/'config.json'
|
||||
self.config.write_text(json.dumps({'global': {
|
||||
'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [self.port],
|
||||
's2s_ports': [], 'http_ports': [], 'https_ports': [],
|
||||
'modules_enabled': ['tls', 'saslauth', 'ping', 'disco'],
|
||||
}, 'hosts': {
|
||||
'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256',
|
||||
'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}},
|
||||
'rooms.localhost': {'component': 'muc', 'options': {'max_history_messages': 20, **(options or {})}},
|
||||
}}))
|
||||
for user in ('alice', 'bob', 'carol'):
|
||||
subprocess.run([binary, 'create-user', str(self.config), user, '--password-stdin'],
|
||||
input='secret\n', text=True, check=True, capture_output=True)
|
||||
self.log = (root/'server.log').open('w+')
|
||||
self.restart()
|
||||
|
||||
def client(self, user, resource='desktop', available=True):
|
||||
client = Client(self.port, self.root/'cert.pem', user, 'secret', resource)
|
||||
self.clients.append(client)
|
||||
if available:
|
||||
client.available()
|
||||
return client
|
||||
|
||||
def restart(self):
|
||||
self.process = start(self.binary, self.config, self.port, self.log)
|
||||
|
||||
def crash(self):
|
||||
# Never close a client first: disconnect cleanup must not save the state.
|
||||
self.process.kill()
|
||||
assert self.process.wait(timeout=10) == -signal.SIGKILL
|
||||
self.process = None
|
||||
for client in self.clients:
|
||||
client.close()
|
||||
self.clients.clear()
|
||||
|
||||
def close(self):
|
||||
if self.process is not None:
|
||||
self.crash()
|
||||
if sys.exc_info()[0] is not None:
|
||||
self.log.seek(0)
|
||||
print(self.log.read(), file=sys.stderr)
|
||||
self.log.close()
|
||||
|
||||
|
||||
def recovery_and_history(server):
|
||||
rooms = ('persistent@rooms.localhost', 'temporary@rooms.localhost')
|
||||
alice, phone, bob = server.client('alice'), server.client('alice', 'phone'), server.client('bob')
|
||||
for index, room in enumerate(rooms):
|
||||
occupant(join(alice, room, 'Alice'), 'moderator', 'owner')
|
||||
configure(alice, room, persistentroom=int(index == 0), enablearchiving=0, historylength=20)
|
||||
# Exercise both shared-nickname sessions and distinct resource nicknames.
|
||||
occupant(join(phone, room, 'Alice' if index == 0 else 'Phone'), 'moderator', 'owner')
|
||||
occupant(join(bob, room, 'Bob'), 'participant', 'none')
|
||||
role(alice, room, 'Bob', 'moderator')
|
||||
for number in range(3):
|
||||
body = f'{room} precrash {number}'
|
||||
alice.send(f"<message to='{room}' type='groupchat'><body>{body}</body></message>")
|
||||
bob.body(body)
|
||||
server.crash()
|
||||
server.restart()
|
||||
wrong = server.client('alice', 'tablet')
|
||||
stranger = server.client('carol')
|
||||
for room in rooms:
|
||||
not_joined(wrong, room)
|
||||
not_joined(stranger, room)
|
||||
# A dormant exact-resource claim reserves its nick before activation.
|
||||
denied(join(stranger, room, 'Alice'), 'conflict')
|
||||
bob = server.client('bob')
|
||||
for room in rooms:
|
||||
occupant(presence(bob, room, 'Bob'), 'moderator', 'none')
|
||||
alice = server.client('alice')
|
||||
phone = server.client('alice', 'phone')
|
||||
for index, room in enumerate(rooms):
|
||||
packet = presence(alice, room, 'Alice')
|
||||
occupant(packet, 'moderator', 'owner')
|
||||
status = packet.find('{jabber:client}status')
|
||||
assert status is not None and status.text == 'durable presence', ET.tostring(packet)
|
||||
phone_nick = 'Alice' if index == 0 else 'Phone'
|
||||
occupant(presence(phone, room, phone_nick), 'moderator', 'owner')
|
||||
body = room + ' recovered route'
|
||||
phone.send(f"<message to='{room}' type='groupchat'><body>{body}</body></message>")
|
||||
assert bob.body(body).get('from') == room + '/' + phone_nick
|
||||
assert alice.body(body).get('from') == room + '/' + phone_nick
|
||||
occupant(join(stranger, room, 'History', history=20), 'participant', 'none')
|
||||
for number in range(3):
|
||||
packet = stranger.body(f'{room} precrash {number}')
|
||||
assert packet.find('{urn:xmpp:delay}delay') is not None, ET.tostring(packet)
|
||||
stranger.body(body)
|
||||
|
||||
|
||||
def mutation_barrier(server, mutation):
|
||||
room = mutation + '@rooms.localhost'
|
||||
alice, bob = server.client('alice'), server.client('bob')
|
||||
occupant(join(alice, room, 'Alice'), 'moderator', 'owner')
|
||||
configure(alice, room, persistentroom=1)
|
||||
occupant(join(bob, room, 'Bob'), 'participant', 'none')
|
||||
if mutation == 'leave':
|
||||
bob.send(f"<presence to='{room}/Bob' type='unavailable'/>")
|
||||
packet = bob.take(lambda e: local(e) == 'presence' and e.get('from') == room + '/Bob'
|
||||
and e.get('type') == 'unavailable')
|
||||
assert packet.find(f'{{{USER}}}x/{{{USER}}}item').get('role') == 'none', ET.tostring(packet)
|
||||
elif mutation == 'kick':
|
||||
role(alice, room, 'Bob', 'none')
|
||||
elif mutation == 'demotion':
|
||||
role(alice, room, 'Bob', 'moderator')
|
||||
role(alice, room, 'Bob', 'visitor')
|
||||
elif mutation == 'password':
|
||||
configure(alice, room, roomsecret='new-private-password')
|
||||
elif mutation == 'configuration':
|
||||
configure(alice, room, membersonly=1)
|
||||
else:
|
||||
raise AssertionError(mutation)
|
||||
# No ping, sleep, extra read or orderly close between acknowledgment and kill.
|
||||
server.crash()
|
||||
server.restart()
|
||||
bob = server.client('bob')
|
||||
if mutation in ('leave', 'kick', 'configuration'):
|
||||
not_joined(bob, room)
|
||||
elif mutation == 'demotion':
|
||||
occupant(presence(bob, room, 'Bob'), 'visitor', 'none')
|
||||
bob.send(f"<message to='{room}' type='groupchat' id='visitor'><body>forbidden</body></message>")
|
||||
packet = bob.take(lambda e: local(e) == 'message' and e.get('id') == 'visitor')
|
||||
assert packet.get('type') == 'error', ET.tostring(packet)
|
||||
stranger = server.client('carol')
|
||||
if mutation == 'password':
|
||||
denied(join(stranger, room, 'Carol'), 'not-authorized')
|
||||
occupant(join(stranger, room, 'Carol', password='new-private-password'), 'participant', 'none')
|
||||
elif mutation == 'configuration':
|
||||
denied(join(stranger, room, 'Carol'), 'registration-required')
|
||||
|
||||
|
||||
def checkpoint_clock(server, age=None):
|
||||
"""Age the committed journal clock without changing membership or config."""
|
||||
files = list((server.root/'data').rglob('rust_muc_checkpoint.dat'))
|
||||
found = []
|
||||
pattern = re.compile(r'(\["saved_at"\]\s*=\s*)(\d+)')
|
||||
for path in files:
|
||||
text = path.read_text()
|
||||
if pattern.search(text):
|
||||
found.append(int(pattern.search(text).group(2)))
|
||||
if age is not None:
|
||||
path.write_text(pattern.sub(lambda m: m.group(1) + str(int(time.time()) - age), text))
|
||||
assert found, 'no committed recovery checkpoint timestamp found'
|
||||
return found
|
||||
|
||||
|
||||
def restart_grace(server, grace=300):
|
||||
room = 'grace@rooms.localhost'
|
||||
alice = server.client('alice')
|
||||
occupant(join(alice, room, 'Alice'), 'moderator', 'owner')
|
||||
configure(alice, room, persistentroom=1)
|
||||
server.crash()
|
||||
# Make the original admission old enough to distinguish it from restart time.
|
||||
checkpoint_clock(server, age=grace - 10)
|
||||
original = checkpoint_clock(server)
|
||||
for _ in range(2):
|
||||
server.restart()
|
||||
probe = server.client('carol')
|
||||
not_joined(probe, room)
|
||||
server.crash()
|
||||
assert checkpoint_clock(server) == original, 'restart extended dormant recovery deadline'
|
||||
checkpoint_clock(server, age=grace + 1)
|
||||
server.restart()
|
||||
alice = server.client('alice')
|
||||
not_joined(alice, room)
|
||||
# Expiration releases nickname reservation; persistent-room ownership remains.
|
||||
carol = server.client('carol')
|
||||
occupant(join(carol, room, 'Alice'), 'participant', 'none')
|
||||
|
||||
|
||||
def unknown_component_module(server):
|
||||
server.crash()
|
||||
config = json.loads(server.config.read_text())
|
||||
marker = 'unimplemented_muc_crash_test_module'
|
||||
config['hosts']['rooms.localhost']['options']['modules_enabled'] = [marker]
|
||||
server.config.write_text(json.dumps(config))
|
||||
result = subprocess.run([server.binary, 'serve', str(server.config), '--experimental'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
assert result.returncode != 0, 'unknown MUC component module was accepted'
|
||||
assert marker in result.stdout + result.stderr, result.stdout + result.stderr
|
||||
|
||||
|
||||
def destroy_tombstone(server):
|
||||
room = 'destroyed@rooms.localhost'
|
||||
replacement = 'replacement@rooms.localhost'
|
||||
reason = 'Closed after migration'
|
||||
alice, bob = server.client('alice'), server.client('bob')
|
||||
occupant(join(alice, room, 'Alice'), 'moderator', 'owner')
|
||||
configure(alice, room, persistentroom=1)
|
||||
occupant(join(bob, room, 'Bob'), 'participant', 'none')
|
||||
iq(alice, 'destroy', f"<query xmlns='{MUC}#owner'><destroy jid='{replacement}'>"
|
||||
f'<reason>{reason}</reason></destroy></query>', room)
|
||||
# The IQ result is the durability barrier; no reads or disconnect precede kill.
|
||||
server.crash()
|
||||
journals = list((server.root/'data').rglob('rust_muc_checkpoint.dat'))
|
||||
assert len(journals) == 1, journals
|
||||
checkpoint = journals[0].read_text()
|
||||
for field in ('["destroyed"]=true', f'["reason"]="{reason}"',
|
||||
f'["newjid"]="{replacement}"'):
|
||||
assert field in checkpoint, f'acknowledged tombstone omitted {field}'
|
||||
server.restart()
|
||||
# Neither an old exact identity nor a new account can recreate the room.
|
||||
alice, carol = server.client('alice'), server.client('carol')
|
||||
for client in (alice, carol):
|
||||
client.sync()
|
||||
assert not any(local(packet) == 'presence' and packet.get('from', '').startswith(room + '/')
|
||||
for packet in client.queue), [ET.tostring(packet) for packet in client.queue]
|
||||
denied(join(client, room, 'Alice'), 'gone')
|
||||
|
||||
|
||||
def run(binary, selected=()):
|
||||
cases = [('recovery-history', recovery_and_history)]
|
||||
cases += [(name, lambda server, name=name: mutation_barrier(server, name))
|
||||
for name in ('leave', 'kick', 'demotion', 'password', 'configuration')]
|
||||
cases.append(('restart-grace', restart_grace))
|
||||
cases.append(('custom-grace', lambda server: restart_grace(server, grace=30)))
|
||||
cases.append(('unknown-module', unknown_component_module))
|
||||
cases.append(('destroy-tombstone', destroy_tombstone))
|
||||
for name, test in cases:
|
||||
if selected and name not in selected:
|
||||
continue
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-muc-crash-' + name + '-') as tmp:
|
||||
options = {'muc_recovery_grace_period': 30} if name == 'custom-grace' else None
|
||||
server = Server(binary, Path(tmp), options)
|
||||
try:
|
||||
test(server)
|
||||
finally:
|
||||
server.close()
|
||||
print('PASS:', name, flush=True)
|
||||
print('PASS: actual STARTTLS SIGKILL MUC recovery, history, acknowledged barriers and fixed grace')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run(str(Path(sys.argv[1]).resolve()), sys.argv[2:])
|
||||
@@ -0,0 +1,228 @@
|
||||
//! 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 & 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 & 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);
|
||||
}
|
||||
@@ -12,10 +12,11 @@ fn native_domain_bans_block_remote_users_but_allow_explicit_members() {
|
||||
let raw = storage::parse(b"return {_jid='room@rooms.test'; _data={persistent=true}; ['owner@test']='owner'; ['remote.test']='outcast'; ['member@remote.test']='member'}").unwrap();
|
||||
storage::save(temp.path(), "rooms.test", Some("room"), "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);
|
||||
let denied = send(
|
||||
&mut muc,
|
||||
"outsider@remote.test/client",
|
||||
"<presence to='room@rooms.test/Outsider'/>",
|
||||
"<presence to='room@rooms.test/Outsider'><x xmlns='http://jabber.org/protocol/muc'/></presence>",
|
||||
);
|
||||
assert!(
|
||||
denied.replies[0].contains("forbidden"),
|
||||
@@ -25,7 +26,7 @@ fn native_domain_bans_block_remote_users_but_allow_explicit_members() {
|
||||
let admitted = send(
|
||||
&mut muc,
|
||||
"member@remote.test/client",
|
||||
"<presence to='room@rooms.test/Member'/>",
|
||||
"<presence to='room@rooms.test/Member'><x xmlns='http://jabber.org/protocol/muc'/></presence>",
|
||||
);
|
||||
assert!(!admitted.replies.iter().any(|p| p.contains("type='error'")));
|
||||
assert!(
|
||||
@@ -40,20 +41,21 @@ fn native_domain_bans_block_remote_users_but_allow_explicit_members() {
|
||||
fn applying_domain_ban_removes_remote_occupants_and_blocks_rejoin() {
|
||||
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);
|
||||
send(
|
||||
&mut muc,
|
||||
"owner@test/r",
|
||||
"<presence to='room@rooms.test/Owner'/>",
|
||||
"<presence to='room@rooms.test/Owner'><x xmlns='http://jabber.org/protocol/muc'/></presence>",
|
||||
);
|
||||
send(
|
||||
&mut muc,
|
||||
"remote@remote.test/r",
|
||||
"<presence to='room@rooms.test/Remote'/>",
|
||||
"<presence to='room@rooms.test/Remote'><x xmlns='http://jabber.org/protocol/muc'/></presence>",
|
||||
);
|
||||
send(
|
||||
&mut muc,
|
||||
"other@allowed.test/r",
|
||||
"<presence to='room@rooms.test/Other'/>",
|
||||
"<presence to='room@rooms.test/Other'><x xmlns='http://jabber.org/protocol/muc'/></presence>",
|
||||
);
|
||||
let banned = send(
|
||||
&mut muc,
|
||||
@@ -79,7 +81,7 @@ fn applying_domain_ban_removes_remote_occupants_and_blocks_rejoin() {
|
||||
send(
|
||||
&mut muc,
|
||||
"remote@remote.test/new",
|
||||
"<presence to='room@rooms.test/Remote'/>"
|
||||
"<presence to='room@rooms.test/Remote'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
||||
)
|
||||
.replies[0]
|
||||
.contains("forbidden")
|
||||
|
||||
+64
-23
@@ -8,11 +8,14 @@ fn send(m: &mut Muc, jid: &str, packet: &str) -> ModuleResult {
|
||||
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_component_options(serde_json::json!({"muc_room_locking":false}), false);
|
||||
m.set_defaults(defaults);
|
||||
send(
|
||||
&mut m,
|
||||
"alice@example.org/r",
|
||||
&format!("<presence to='{ROOM}/Alice'/>"),
|
||||
&format!(
|
||||
"<presence to='{ROOM}/Alice'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
||||
),
|
||||
);
|
||||
(root, m)
|
||||
}
|
||||
@@ -22,7 +25,9 @@ fn restricted_private_messages_allow_self_and_optional_moderators() {
|
||||
send(
|
||||
&mut m,
|
||||
"bob@example.org/r",
|
||||
&format!("<presence to='{ROOM}/Bob'/>"),
|
||||
&format!(
|
||||
"<presence to='{ROOM}/Bob'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
||||
),
|
||||
);
|
||||
let denied = send(
|
||||
&mut m,
|
||||
@@ -58,7 +63,9 @@ fn broadcast_roles_hide_visitors_and_configuration_announces_transition() {
|
||||
let join = send(
|
||||
&mut m,
|
||||
"bob@example.org/r",
|
||||
&format!("<presence to='{ROOM}/Bob'/>"),
|
||||
&format!(
|
||||
"<presence to='{ROOM}/Bob'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
||||
),
|
||||
);
|
||||
assert!(join.deliveries.is_empty());
|
||||
assert!(
|
||||
@@ -99,11 +106,12 @@ fn language_config_sections_discovery_and_native_affiliation_hats() {
|
||||
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());
|
||||
m.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
|
||||
let join = send(
|
||||
&mut m,
|
||||
"alice@example.org/r",
|
||||
&format!(
|
||||
"<presence to='{ROOM}/Alice'><hats xmlns='urn:xmpp:hats:0'><hat uri='fake'/></hats></presence>"
|
||||
"<presence to='{ROOM}/Alice'><x xmlns='http://jabber.org/protocol/muc'/><hats xmlns='urn:xmpp:hats:0'><hat uri='fake'/></hats></presence>"
|
||||
),
|
||||
);
|
||||
let presence = join.replies.iter().find(|p| p.contains("110")).unwrap();
|
||||
@@ -135,7 +143,9 @@ fn messages_cannot_spoof_room_identity_hats_or_occupant_identifiers() {
|
||||
send(
|
||||
&mut m,
|
||||
"bob@example.org/r",
|
||||
&format!("<presence to='{ROOM}/Bob'/>"),
|
||||
&format!(
|
||||
"<presence to='{ROOM}/Bob'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
||||
),
|
||||
);
|
||||
for (kind, to) in [
|
||||
("groupchat", ROOM.to_string()),
|
||||
@@ -159,11 +169,17 @@ fn messages_cannot_spoof_room_identity_hats_or_occupant_identifiers() {
|
||||
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);
|
||||
m.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
|
||||
m.set_component_options(
|
||||
json!({"muc_room_locking":false,"muc_vcard":false,"muc_occupant_id":false}),
|
||||
false,
|
||||
);
|
||||
let join = send(
|
||||
&mut m,
|
||||
"alice@example.org/r",
|
||||
&format!("<presence to='{ROOM}/Alice'/>"),
|
||||
&format!(
|
||||
"<presence to='{ROOM}/Alice'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
||||
),
|
||||
);
|
||||
assert!(join.replies.iter().all(|p| !p.contains("occupant-id")));
|
||||
let disco = send(
|
||||
@@ -186,11 +202,14 @@ fn disabled_optional_vcard_and_occupant_ids_are_not_advertised_or_emitted() {
|
||||
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);
|
||||
m.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
|
||||
m.set_component_options(json!({"muc_room_locking":false,"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!("<presence to='{ROOM}/Alice'/>"),
|
||||
&format!(
|
||||
"<presence to='{ROOM}/Alice'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
||||
),
|
||||
);
|
||||
send(
|
||||
&mut m,
|
||||
@@ -258,10 +277,13 @@ fn offline_reserved_affiliates_follow_anonymity_and_roominfo_counts_nicknames()
|
||||
);
|
||||
std::fs::write(path, rust_xmpp::storage::serialize(&frozen).unwrap()).unwrap();
|
||||
let mut m = Muc::new(root.path().into(), "rooms.example.org".into());
|
||||
m.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
|
||||
let guest = send(
|
||||
&mut m,
|
||||
"bob@example.org/r",
|
||||
&format!("<presence to='{ROOM}/Bob'/>"),
|
||||
&format!(
|
||||
"<presence to='{ROOM}/Bob'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
||||
),
|
||||
);
|
||||
let offline = guest
|
||||
.replies
|
||||
@@ -279,7 +301,9 @@ fn offline_reserved_affiliates_follow_anonymity_and_roominfo_counts_nicknames()
|
||||
let owner = send(
|
||||
&mut m,
|
||||
"alice@example.org/r",
|
||||
&format!("<presence to='{ROOM}/Alice'/>"),
|
||||
&format!(
|
||||
"<presence to='{ROOM}/Alice'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
||||
),
|
||||
);
|
||||
assert!(
|
||||
owner
|
||||
@@ -290,7 +314,9 @@ fn offline_reserved_affiliates_follow_anonymity_and_roominfo_counts_nicknames()
|
||||
send(
|
||||
&mut m,
|
||||
"bob@example.org/second",
|
||||
&format!("<presence to='{ROOM}/Bob'/>"),
|
||||
&format!(
|
||||
"<presence to='{ROOM}/Bob'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
||||
),
|
||||
);
|
||||
let disco = send(
|
||||
&mut m,
|
||||
@@ -309,6 +335,7 @@ fn recovery_claim_is_durable_and_unrelated_disconnect_keeps_temporary_claims() {
|
||||
first.checkpoint_live().unwrap();
|
||||
drop(first);
|
||||
let mut recovered = Muc::new(root.path().into(), "rooms.example.org".into());
|
||||
recovered.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
|
||||
recovered.prepare_recovery().unwrap();
|
||||
recovered.disconnect("unrelated@example.org/r");
|
||||
let restored = recovered
|
||||
@@ -321,12 +348,17 @@ fn recovery_claim_is_durable_and_unrelated_disconnect_keeps_temporary_claims() {
|
||||
.any(|p| p.contains("110") && p.contains("role='moderator'"))
|
||||
);
|
||||
drop(recovered);
|
||||
// No new checkpoint: an already consumed claim cannot resurrect stale role.
|
||||
// Authenticated restoration committed a new live membership checkpoint.
|
||||
let mut again = Muc::new(root.path().into(), "rooms.example.org".into());
|
||||
again.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
|
||||
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());
|
||||
let restored_again = again.restore_for_session("alice@example.org/r").unwrap();
|
||||
assert!(
|
||||
restored_again
|
||||
.replies
|
||||
.iter()
|
||||
.any(|p| p.contains("/Alice") && p.contains("110") && p.contains("role='moderator'"))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn large_optional_history_does_not_prevent_checkpointing_membership() {
|
||||
@@ -347,35 +379,44 @@ fn explicit_new_nickname_consumes_old_proof_and_pending_nicks_are_reserved() {
|
||||
send(
|
||||
&mut first,
|
||||
"bob@example.org/r",
|
||||
&format!("<presence to='{ROOM}/Bob'/>"),
|
||||
&format!(
|
||||
"<presence to='{ROOM}/Bob'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
||||
),
|
||||
);
|
||||
first.checkpoint_live().unwrap();
|
||||
drop(first);
|
||||
let mut recovered = Muc::new(root.path().into(), "rooms.example.org".into());
|
||||
recovered.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
|
||||
recovered.prepare_recovery().unwrap();
|
||||
let stolen = send(
|
||||
&mut recovered,
|
||||
"mallory@example.org/r",
|
||||
&format!("<presence to='{ROOM}/Bob'/>"),
|
||||
&format!(
|
||||
"<presence to='{ROOM}/Bob'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
||||
),
|
||||
);
|
||||
assert!(stolen.replies.iter().any(|p| p.contains("conflict")));
|
||||
let explicit = send(
|
||||
&mut recovered,
|
||||
"alice@example.org/r",
|
||||
&format!("<presence to='{ROOM}/Changed'/>"),
|
||||
&format!(
|
||||
"<presence to='{ROOM}/Changed'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
||||
),
|
||||
);
|
||||
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.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
|
||||
again.prepare_recovery().unwrap();
|
||||
let alice = again.restore_for_session("alice@example.org/r").unwrap();
|
||||
assert!(
|
||||
again
|
||||
.restore_for_session("alice@example.org/r")
|
||||
.unwrap()
|
||||
alice
|
||||
.replies
|
||||
.is_empty()
|
||||
.iter()
|
||||
.any(|p| p.contains("/Changed") && p.contains("110"))
|
||||
);
|
||||
assert!(alice.replies.iter().all(|p| !p.contains("/Alice")));
|
||||
assert!(
|
||||
again
|
||||
.restore_for_session("bob@example.org/r")
|
||||
|
||||
+2
-1
@@ -125,8 +125,9 @@ fn archive_history_and_permissions() {
|
||||
fn nick_invite_destroy_and_local_creation() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
m.set_component_options(serde_json::json!({"restrict_room_creation":"local"}), false);
|
||||
m.set_local_domain("test".into());
|
||||
assert!(join(&mut m, "external@else/a", "External").replies[0].contains("forbidden"));
|
||||
assert!(join(&mut m, "external@else/a", "External").replies[0].contains("not-allowed"));
|
||||
join(&mut m, "alice@test/a", "Alice");
|
||||
unlock(&mut m);
|
||||
let renamed = join(&mut m, "alice@test/a", "NewName");
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
use rust_xmpp::{muc::Muc, protocol::ModuleResult};
|
||||
use serde_json::json;
|
||||
const ROOM: &str = "room@rooms.example.org";
|
||||
const COMMANDS: &str = "http://jabber.org/protocol/commands";
|
||||
const AFF: &str = "http://prosody.im/protocol/muc#set-affiliation";
|
||||
const DESTROY: &str = "http://prosody.im/protocol/muc#destroy";
|
||||
fn send(m: &mut Muc, jid: &str, xml: &str) -> ModuleResult {
|
||||
let doc = roxmltree::Document::parse(xml).unwrap();
|
||||
m.handle(jid, doc.root_element()).unwrap().unwrap()
|
||||
}
|
||||
fn room(m: &mut Muc) {
|
||||
send(
|
||||
m,
|
||||
"owner@example.org/r",
|
||||
&format!(
|
||||
"<presence to='{ROOM}/Owner'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
||||
),
|
||||
);
|
||||
send(
|
||||
m,
|
||||
"owner@example.org/r",
|
||||
&format!(
|
||||
"<iq to='{ROOM}' type='set'><query xmlns='http://jabber.org/protocol/muc#owner'><x xmlns='jabber:x:data' type='submit'><field var='muc#roomconfig_persistentroom'><value>1</value></field></x></query></iq>"
|
||||
),
|
||||
);
|
||||
}
|
||||
fn register(m: &mut Muc, jid: &str, nick: &str) -> ModuleResult {
|
||||
send(
|
||||
m,
|
||||
jid,
|
||||
&format!(
|
||||
"<iq to='{ROOM}' type='set'><query xmlns='jabber:iq:register'><x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>http://jabber.org/protocol/muc#register</value></field><field var='muc#register_roomnick'><value>{nick}</value></field></x></query></iq>"
|
||||
),
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn optional_registration_grants_membership_and_enforces_reserved_nickname() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(root.path().into(), "rooms.example.org".into());
|
||||
m.set_component_options(json!({"allow_unaffiliated_register":true,"enforce_registered_nickname":true,"muc_registration_include_form":true}),false);
|
||||
room(&mut m);
|
||||
assert!(register(&mut m, "bob@example.org/r", "Reserved").replies[0].contains("type='result'"));
|
||||
let result = send(
|
||||
&mut m,
|
||||
"bob@example.org/r",
|
||||
&format!("<iq to='{ROOM}' type='get'><query xmlns='jabber:iq:register'/></iq>"),
|
||||
);
|
||||
assert!(result.replies[0].contains("type='result'><field"));
|
||||
assert!(result.replies[0].contains("<value>Reserved</value>"));
|
||||
let joined = send(
|
||||
&mut m,
|
||||
"bob@example.org/r",
|
||||
&format!(
|
||||
"<presence to='{ROOM}/Other'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
||||
),
|
||||
);
|
||||
assert!(
|
||||
joined
|
||||
.replies
|
||||
.iter()
|
||||
.any(|p| p.contains("/Reserved'") && p.contains("affiliation='member'"))
|
||||
);
|
||||
let rename = send(
|
||||
&mut m,
|
||||
"bob@example.org/r",
|
||||
&format!("<presence to='{ROOM}/Other'/>"),
|
||||
);
|
||||
assert!(rename.replies[0].contains("not-acceptable"));
|
||||
let reregister = register(&mut m, "bob@example.org/r", "New");
|
||||
assert!(
|
||||
reregister
|
||||
.deliveries
|
||||
.iter()
|
||||
.any(|(_, p)| p.contains("code='307'"))
|
||||
);
|
||||
let message = send(
|
||||
&mut m,
|
||||
"bob@example.org/r",
|
||||
&format!("<message to='{ROOM}' type='groupchat'><body>departed</body></message>"),
|
||||
);
|
||||
assert!(message.replies[0].contains("not-acceptable"));
|
||||
}
|
||||
#[test]
|
||||
fn default_registration_denies_unaffiliated_users() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(root.path().into(), "rooms.example.org".into());
|
||||
room(&mut m);
|
||||
assert!(
|
||||
register(&mut m, "bob@example.org/r", "Bob").replies[0].contains("registration-required")
|
||||
);
|
||||
}
|
||||
fn start(m: &mut Muc, jid: &str, node: &str) -> String {
|
||||
let result = send(
|
||||
m,
|
||||
jid,
|
||||
&format!(
|
||||
"<iq to='rooms.example.org' type='set'><command xmlns='{COMMANDS}' node='{node}'/></iq>"
|
||||
),
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&result.replies[0]).unwrap();
|
||||
doc.descendants()
|
||||
.find(|n| n.has_tag_name((COMMANDS, "command")))
|
||||
.unwrap()
|
||||
.attribute("sessionid")
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
}
|
||||
fn submit(m: &mut Muc, jid: &str, node: &str, id: &str, fields: &str) -> ModuleResult {
|
||||
send(
|
||||
m,
|
||||
jid,
|
||||
&format!(
|
||||
"<iq to='rooms.example.org' type='set'><command xmlns='{COMMANDS}' node='{node}' sessionid='{id}' action='complete'><x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>{node}</value></field>{fields}</x></command></iq>"
|
||||
),
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn administration_commands_recheck_authority_bind_sessions_and_update_live_rooms() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(root.path().into(), "rooms.example.org".into());
|
||||
m.set_component_options(json!({"admins":["admin@example.org","other@example.org"],"muc_tombstone_expiry":"0 seconds"}),false);
|
||||
room(&mut m);
|
||||
let outsider = send(
|
||||
&mut m,
|
||||
"stranger@example.org/r",
|
||||
&format!(
|
||||
"<iq to='rooms.example.org' type='get'><query xmlns='http://jabber.org/protocol/disco#items' node='{COMMANDS}'/></iq>"
|
||||
),
|
||||
);
|
||||
assert!(!outsider.replies[0].contains("node='http://prosody.im/protocol/muc#destroy'"));
|
||||
let denied = send(
|
||||
&mut m,
|
||||
"stranger@example.org/r",
|
||||
&format!(
|
||||
"<iq to='rooms.example.org' type='set'><command xmlns='{COMMANDS}' node='{AFF}'/></iq>"
|
||||
),
|
||||
);
|
||||
assert!(denied.replies[0].contains("forbidden"));
|
||||
let id = start(&mut m, "admin@example.org/r", AFF);
|
||||
let fields = format!(
|
||||
"<field var='room'><value>{ROOM}</value></field><field var='jid'><value>owner@example.org</value></field><field var='affiliation'><value>outcast</value></field>"
|
||||
);
|
||||
assert!(
|
||||
submit(&mut m, "other@example.org/r", AFF, &id, &fields).replies[0].contains("bad-request")
|
||||
);
|
||||
let updated = submit(&mut m, "admin@example.org/r", AFF, &id, &fields);
|
||||
assert!(updated.replies[0].contains("status='completed'"));
|
||||
assert!(
|
||||
updated
|
||||
.deliveries
|
||||
.iter()
|
||||
.any(|(_, p)| p.contains("code='301'"))
|
||||
);
|
||||
assert!(
|
||||
submit(&mut m, "admin@example.org/r", AFF, &id, &fields).replies[0].contains("bad-request")
|
||||
);
|
||||
// A privileged component command may remove the final owner, just like
|
||||
// native room:set_affiliation(true,...); destroying such a room still works.
|
||||
let id = start(&mut m, "admin@example.org/r", DESTROY);
|
||||
assert!(
|
||||
submit(
|
||||
&mut m,
|
||||
"admin@example.org/r",
|
||||
DESTROY,
|
||||
&id,
|
||||
&format!("<field var='rooms'><value>{ROOM}</value></field>")
|
||||
)
|
||||
.replies[0]
|
||||
.contains("status='completed'")
|
||||
);
|
||||
let rejoin = send(
|
||||
&mut m,
|
||||
"new@example.org/r",
|
||||
&format!(
|
||||
"<presence to='{ROOM}/New'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
||||
),
|
||||
);
|
||||
assert!(rejoin.replies.iter().any(|p| p.contains("code='201'")));
|
||||
}
|
||||
#[test]
|
||||
fn configured_tombstone_expiry_allows_immediate_recreation() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(root.path().into(), "rooms.example.org".into());
|
||||
m.set_component_options(json!({"muc_tombstone_expiry":"0 seconds"}), false);
|
||||
room(&mut m);
|
||||
send(
|
||||
&mut m,
|
||||
"owner@example.org/r",
|
||||
&format!(
|
||||
"<iq to='{ROOM}' type='set'><query xmlns='http://jabber.org/protocol/muc#owner'><destroy/></query></iq>"
|
||||
),
|
||||
);
|
||||
let joined = send(
|
||||
&mut m,
|
||||
"new@example.org/r",
|
||||
&format!(
|
||||
"<presence to='{ROOM}/New'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
||||
),
|
||||
);
|
||||
assert!(joined.replies.iter().any(|p| p.contains("code='201'")));
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use rust_xmpp::{muc::Muc, protocol::ModuleResult};
|
||||
use serde_json::json;
|
||||
fn send(m: &mut Muc, who: &str, packet: &str) -> ModuleResult {
|
||||
let doc = roxmltree::Document::parse(packet).unwrap();
|
||||
m.handle(who, doc.root_element()).unwrap().unwrap()
|
||||
}
|
||||
fn join(m: &mut Muc, who: &str, nick: &str) -> ModuleResult {
|
||||
send(
|
||||
m,
|
||||
who,
|
||||
&format!(
|
||||
"<presence to='r@rooms.test/{nick}'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
||||
),
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn probes_and_invalid_presence_cannot_join_or_rename() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(dir.path().into(), "rooms.test".into());
|
||||
m.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
|
||||
assert!(
|
||||
send(
|
||||
&mut m,
|
||||
"alice@test/r",
|
||||
"<presence to='r@rooms.test/Alice'/>"
|
||||
)
|
||||
.replies[0]
|
||||
.contains("item-not-found")
|
||||
);
|
||||
join(&mut m, "alice@test/r", "Alice");
|
||||
assert!(
|
||||
send(
|
||||
&mut m,
|
||||
"bob@test/r",
|
||||
"<presence type='probe' to='r@rooms.test/Alice'/>"
|
||||
)
|
||||
.replies[0]
|
||||
.contains("not-acceptable")
|
||||
);
|
||||
assert!(
|
||||
send(
|
||||
&mut m,
|
||||
"bob@test/r",
|
||||
"<presence type='subscribe' to='r@rooms.test/Bob'/>"
|
||||
)
|
||||
.replies[0]
|
||||
.contains("bad-request")
|
||||
);
|
||||
let probe = send(
|
||||
&mut m,
|
||||
"alice@test/r",
|
||||
"<presence type='probe' to='r@rooms.test/Alice'/>",
|
||||
);
|
||||
assert!(probe.replies[0].contains("role='moderator'"));
|
||||
assert!(probe.deliveries.is_empty());
|
||||
assert!(
|
||||
send(
|
||||
&mut m,
|
||||
"alice@test/r",
|
||||
"<presence type='probe' to='r@rooms.test/Absent'/>"
|
||||
)
|
||||
.replies
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn error_messages_kick_only_on_delivery_failure_and_presence_errors_kick() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(dir.path().into(), "rooms.test".into());
|
||||
m.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
|
||||
join(&mut m, "alice@test/r", "Alice");
|
||||
join(&mut m, "bob@test/r", "Bob");
|
||||
let denied = send(
|
||||
&mut m,
|
||||
"bob@test/r",
|
||||
"<message type='error' to='r@rooms.test'><error><forbidden xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/></error></message>",
|
||||
);
|
||||
assert!(denied.replies.is_empty() && denied.deliveries.is_empty());
|
||||
let forwarded = send(
|
||||
&mut m,
|
||||
"bob@test/r",
|
||||
"<message type='error' to='r@rooms.test/Alice'><error><forbidden xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/></error></message>",
|
||||
);
|
||||
assert_eq!(forwarded.deliveries.len(), 1);
|
||||
assert!(forwarded.deliveries[0].1.contains("type='error'"));
|
||||
let kicked = send(
|
||||
&mut m,
|
||||
"bob@test/r",
|
||||
"<message type='error' to='r@rooms.test/Alice'><error><service-unavailable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/></error></message>",
|
||||
);
|
||||
assert!(
|
||||
kicked
|
||||
.deliveries
|
||||
.iter()
|
||||
.any(|(_, p)| p.contains("code='333'") && p.contains("Kicked: service unavailable"))
|
||||
);
|
||||
join(&mut m, "bob@test/r", "Bob");
|
||||
let kicked = send(
|
||||
&mut m,
|
||||
"bob@test/r",
|
||||
"<presence type='error' to='r@rooms.test/Bob'><error><forbidden xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/></error></presence>",
|
||||
);
|
||||
assert!(
|
||||
kicked
|
||||
.deliveries
|
||||
.iter()
|
||||
.any(|(_, p)| p.contains("code='333'"))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn room_creation_policies_apply_only_to_new_rooms() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(dir.path().into(), "rooms.test".into());
|
||||
m.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
|
||||
m.set_local_domain("test".into());
|
||||
m.set_component_options(json!({"muc_room_locking":false,"restrict_room_creation":true,"admins":["admin@test"],"muc_max_rooms":1}), false);
|
||||
assert!(join(&mut m, "alice@test/r", "Alice").replies[0].contains("not-allowed"));
|
||||
assert!(
|
||||
join(&mut m, "admin@test/r", "Admin")
|
||||
.replies
|
||||
.iter()
|
||||
.any(|p| p.contains("code='201'"))
|
||||
);
|
||||
assert!(
|
||||
join(&mut m, "alice@remote/r", "Alice")
|
||||
.replies
|
||||
.iter()
|
||||
.any(|p| p.contains("code='110'"))
|
||||
);
|
||||
let full = send(
|
||||
&mut m,
|
||||
"admin@test/r",
|
||||
"<presence to='s@rooms.test/Admin'><x xmlns='http://jabber.org/protocol/muc'/></presence>",
|
||||
);
|
||||
assert!(full.replies[0].contains("resource-constraint"));
|
||||
m.set_component_options(json!({"restrict_room_creation":"local"}), false);
|
||||
assert!(send(&mut m, "alice@remote/r", "<presence to='s@rooms.test/Alice'><x xmlns='http://jabber.org/protocol/muc'/></presence>").replies[0].contains("not-allowed"));
|
||||
m.set_component_options(json!({}), false);
|
||||
assert!(send(&mut m, "alice@remote/r", "<presence to='s@rooms.test/Alice'><x xmlns='http://jabber.org/protocol/muc'/></presence>").replies.iter().any(|p|p.contains("code='201'")));
|
||||
}
|
||||
#[test]
|
||||
fn ordinary_presence_extensions_survive_but_client_identity_is_filtered() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(dir.path().into(), "rooms.test".into());
|
||||
m.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
|
||||
join(&mut m, "alice@test/r", "Alice");
|
||||
let result = send(
|
||||
&mut m,
|
||||
"bob@test/r",
|
||||
"<presence to='r@rooms.test/Bob'><x xmlns='http://jabber.org/protocol/muc'/><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' node='client' ver='v'/><idle xmlns='urn:xmpp:idle:1' since='2026-01-01T00:00:00Z'/><occupant-id xmlns='urn:xmpp:occupant-id:0' id='forged'/></presence>",
|
||||
);
|
||||
let p = &result.deliveries[0].1;
|
||||
assert!(p.contains("http://jabber.org/protocol/caps") && p.contains("urn:xmpp:idle:1"));
|
||||
assert!(!p.contains("forged"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_with_body_remains_an_ordinary_groupchat_message() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(dir.path().into(), "rooms.test".into());
|
||||
m.set_component_options(json!({"muc_room_locking":false}), false);
|
||||
join(&mut m, "alice@test/r", "Alice");
|
||||
join(&mut m, "bob@test/r", "Bob");
|
||||
let ordinary = send(
|
||||
&mut m,
|
||||
"bob@test/r",
|
||||
"<message type='groupchat' to='r@rooms.test'><subject>Heading</subject><body>Message text</body></message>",
|
||||
);
|
||||
assert!(ordinary.replies.is_empty());
|
||||
assert_eq!(ordinary.deliveries.len(), 2);
|
||||
let subject = send(
|
||||
&mut m,
|
||||
"bob@test/r",
|
||||
"<message type='groupchat' to='r@rooms.test'><subject>Room title</subject></message>",
|
||||
);
|
||||
assert!(subject.replies[0].contains("forbidden"));
|
||||
}
|
||||
@@ -9,6 +9,7 @@ fn send(m: &mut Muc, jid: &str, packet: &str) -> ModuleResult {
|
||||
}
|
||||
fn initial(root: &std::path::Path) -> Muc {
|
||||
let mut m = Muc::new(root.into(), HOST.into());
|
||||
m.set_component_options(json!({"muc_room_locking":false}), false);
|
||||
m.set_defaults(json!({"persistent": true, "password":"private-pass"}));
|
||||
send(
|
||||
&mut m,
|
||||
@@ -57,12 +58,18 @@ fn repeated_restart_keeps_dormant_claim_and_deadline_without_ghost_routing() {
|
||||
);
|
||||
assert!(restored.replies.iter().all(|p| !p.contains("private-pass")));
|
||||
assert!(m.restore_for_session(ALICE).unwrap().replies.is_empty());
|
||||
// Simulate a later crash: no clean checkpoint. A consumed on-disk proof
|
||||
// must not revive a prior moderator grant on another process start.
|
||||
// The authenticated restoration itself committed fresh live membership,
|
||||
// so a second crash recovers that current room state again.
|
||||
drop(m);
|
||||
let mut m = Muc::new(root.path().into(), HOST.into());
|
||||
m.prepare_recovery().unwrap();
|
||||
assert!(m.restore_for_session(ALICE).unwrap().replies.is_empty());
|
||||
assert!(
|
||||
m.restore_for_session(ALICE)
|
||||
.unwrap()
|
||||
.replies
|
||||
.iter()
|
||||
.any(|p| p.contains("110"))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn changed_configuration_invalidates_auto_recovery_without_disclosing_password() {
|
||||
@@ -81,7 +88,13 @@ fn changed_configuration_invalidates_auto_recovery_without_disclosing_password()
|
||||
m.prepare_recovery().unwrap();
|
||||
let result = m.restore_for_session(ALICE).unwrap();
|
||||
assert!(result.replies.is_empty() && result.deliveries.is_empty());
|
||||
let denied = send(&mut m, ALICE, &format!("<presence to='{ROOM}/Alice'/>"));
|
||||
let denied = send(
|
||||
&mut m,
|
||||
ALICE,
|
||||
&format!(
|
||||
"<presence to='{ROOM}/Alice'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
||||
),
|
||||
);
|
||||
assert!(denied.replies.iter().any(|p| p.contains("not-authorized")));
|
||||
assert!(
|
||||
denied
|
||||
@@ -95,7 +108,13 @@ fn changed_configuration_invalidates_auto_recovery_without_disclosing_password()
|
||||
fn unrelated_disconnect_does_not_delete_dormant_temporary_room() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(root.path().into(), HOST.into());
|
||||
send(&mut m, ALICE, &format!("<presence to='{ROOM}/Alice'/>"));
|
||||
send(
|
||||
&mut m,
|
||||
ALICE,
|
||||
&format!(
|
||||
"<presence to='{ROOM}/Alice'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
||||
),
|
||||
);
|
||||
m.checkpoint_live().unwrap();
|
||||
drop(m);
|
||||
let mut m = Muc::new(root.path().into(), HOST.into());
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise durable MUC/SM hot paths over real STARTTLS with bounded fanout.
|
||||
|
||||
python3 tests/muc_sm_load_network.py --binary target/release/rust-xmpp --clients 500
|
||||
Defaults to 100 resources; --clients 500 uses 100 accounts and 10 rooms of 50.
|
||||
JSON contains observed latency/RSS/CPU, slot counts and exact fanout assertions.
|
||||
All sockets are drained continuously. SIGKILL precedes socket cleanup; ten
|
||||
representatives resume with unacknowledged room output and send without rejoin.
|
||||
This is a loopback smoke measurement, not a production capacity guarantee.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import collections
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import signal
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / 'scripts'))
|
||||
from load_smoke import Client as BaseClient, STREAM, distribution, process_metrics, proc_pid
|
||||
|
||||
SM = 'urn:xmpp:sm:3'
|
||||
MUC = 'http://jabber.org/protocol/muc'
|
||||
CLIENT = 'jabber:client'
|
||||
|
||||
|
||||
def start(binary, config, port, log):
|
||||
# Recovery of hundreds of durable resources legitimately exceeds the
|
||||
# five-second deadline used by the tiny functional restart fixture.
|
||||
server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log)
|
||||
deadline = time.monotonic() + 60
|
||||
while time.monotonic() < deadline:
|
||||
assert server.poll() is None, 'server stopped during startup'
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=.1):
|
||||
return server
|
||||
except OSError:
|
||||
time.sleep(.05)
|
||||
server.kill()
|
||||
server.wait()
|
||||
raise AssertionError('server did not start within 60 seconds')
|
||||
|
||||
|
||||
class Client(BaseClient):
|
||||
def __init__(self, number, user, reader, writer):
|
||||
super().__init__(number, user, reader, writer)
|
||||
self.received = self.inputs = self.acked = 0
|
||||
self.token = None
|
||||
self.presences = {}
|
||||
self.iqs = {}
|
||||
self.controls = collections.defaultdict(list)
|
||||
self.bodies = collections.Counter()
|
||||
self.errors = []
|
||||
self.changed = asyncio.Event()
|
||||
self.auto_ack = True
|
||||
self.task = None
|
||||
self.failure = None
|
||||
|
||||
@classmethod
|
||||
async def connect(cls, number, port, context, accounts, bind=True):
|
||||
reader, writer = await asyncio.wait_for(asyncio.open_connection('127.0.0.1', port), 60)
|
||||
user = f'load-{number % accounts}'
|
||||
client = cls(number, user, reader, writer)
|
||||
try:
|
||||
await client.send(STREAM)
|
||||
assert b'starttls' in await client.until(b'</stream:features>')
|
||||
await client.send("<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>")
|
||||
assert b'proceed' in await client.until(b'/>')
|
||||
await writer.start_tls(context, server_hostname='localhost', ssl_handshake_timeout=60)
|
||||
await client.send(STREAM)
|
||||
await client.until(b'</stream:features>')
|
||||
auth = base64.b64encode(f'\0{user}\0load-test-password'.encode()).decode()
|
||||
await client.send(f"<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>{auth}</auth>")
|
||||
assert b'success' in await client.until(b'/>')
|
||||
await client.send(STREAM)
|
||||
await client.until(b'</stream:features>')
|
||||
if bind:
|
||||
await client.send(f"<iq type='set' id='bind'><bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'><resource>load-{number}</resource></bind></iq>")
|
||||
assert client.jid.encode() in await client.until(b'</iq>')
|
||||
client.task = asyncio.create_task(client.drain())
|
||||
return client
|
||||
except BaseException:
|
||||
writer.close()
|
||||
raise
|
||||
|
||||
async def stanza(self, text):
|
||||
self.inputs = (self.inputs + 1) % (1 << 32)
|
||||
await self.send(text)
|
||||
|
||||
async def drain(self):
|
||||
parser = ET.XMLPullParser(events=('start', 'end'))
|
||||
parser.feed("<root xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams'>")
|
||||
root = next(parser.read_events())[1]
|
||||
depth = 0
|
||||
try:
|
||||
while chunk := await self.reader.read(65536):
|
||||
parser.feed(chunk)
|
||||
if hasattr(parser, 'flush'):
|
||||
parser.flush()
|
||||
for event, node in parser.read_events():
|
||||
depth += 1 if event == 'start' else -1
|
||||
if event != 'end' or depth != 0:
|
||||
continue
|
||||
local = node.tag.split('}')[-1]
|
||||
if node.tag in (f'{{{CLIENT}}}iq', f'{{{CLIENT}}}presence', f'{{{CLIENT}}}message'):
|
||||
self.received = (self.received + 1) % (1 << 32)
|
||||
if node.get('type') == 'error':
|
||||
self.errors.append(ET.tostring(node).decode())
|
||||
if local == 'presence':
|
||||
self.presences[node.get('from')] = node
|
||||
elif local == 'iq':
|
||||
self.iqs[node.get('id')] = node
|
||||
elif local == 'message':
|
||||
for body in node.findall(f'{{{CLIENT}}}body'):
|
||||
self.bodies[body.text] += 1
|
||||
elif node.tag == f'{{{SM}}}r':
|
||||
if self.auto_ack:
|
||||
await self.ack()
|
||||
else:
|
||||
self.controls[node.tag].append(node)
|
||||
root.remove(node)
|
||||
self.changed.set()
|
||||
raise EOFError('server closed stream')
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as error:
|
||||
self.failure = error
|
||||
self.changed.set()
|
||||
|
||||
async def wait(self, predicate, description):
|
||||
async def observe():
|
||||
while True:
|
||||
self.changed.clear()
|
||||
result = predicate()
|
||||
if isinstance(result, ET.Element) or result:
|
||||
return result
|
||||
if self.failure:
|
||||
raise RuntimeError(f'client {self.number}: {description}: {self.failure}')
|
||||
await self.changed.wait()
|
||||
return await asyncio.wait_for(observe(), 120)
|
||||
|
||||
async def control(self, name):
|
||||
key = f'{{{SM}}}{name}'
|
||||
await self.wait(lambda: self.controls[key], name)
|
||||
return self.controls[key].pop(0)
|
||||
|
||||
async def enable(self):
|
||||
await self.send(f"<enable xmlns='{SM}' resume='true'/>")
|
||||
node = await self.control('enabled')
|
||||
assert node.get('resume') == 'true'
|
||||
self.token = node.get('id')
|
||||
assert self.token
|
||||
|
||||
async def fence(self, identifier):
|
||||
await self.stanza(f"<iq type='get' to='localhost' id='{identifier}'><ping xmlns='urn:xmpp:ping'/></iq>")
|
||||
result = await self.wait(lambda: self.iqs.pop(identifier, None), identifier)
|
||||
assert result.get('type') == 'result', ET.tostring(result)
|
||||
|
||||
async def ack(self):
|
||||
self.acked = self.received
|
||||
await self.send(f"<a xmlns='{SM}' h='{self.acked}'/>")
|
||||
|
||||
async def handled(self):
|
||||
await self.send(f"<r xmlns='{SM}'/>")
|
||||
value = int((await self.control('a')).get('h'))
|
||||
assert value == self.inputs, (self.number, value, self.inputs)
|
||||
return value
|
||||
|
||||
async def close_raw(self):
|
||||
if self.task:
|
||||
self.task.cancel()
|
||||
await asyncio.gather(self.task, return_exceptions=True)
|
||||
self.writer.close()
|
||||
try:
|
||||
await asyncio.wait_for(self.writer.wait_closed(), 3)
|
||||
except (OSError, ssl.SSLError, TimeoutError):
|
||||
pass
|
||||
|
||||
|
||||
def slots(root):
|
||||
paths = list((root / 'data').rglob('rust_smacks/*.json'))
|
||||
return {'count': len(paths), 'bytes': sum(p.stat().st_size for p in paths)}
|
||||
|
||||
|
||||
async def exercise(args, root, config, port, log):
|
||||
clients = []
|
||||
server = await asyncio.to_thread(start, args.binary, config, port, log)
|
||||
pid = proc_pid(server.pid)
|
||||
samples = []
|
||||
running = True
|
||||
async def sample():
|
||||
while running:
|
||||
samples.append(process_metrics(pid))
|
||||
await asyncio.sleep(.2)
|
||||
sampler = asyncio.create_task(sample())
|
||||
gate = asyncio.Semaphore(args.concurrency)
|
||||
context = ssl.create_default_context(cafile=str(root / 'cert.pem'))
|
||||
report = {'clients': args.clients, 'accounts': args.accounts, 'rooms': args.rooms,
|
||||
'message_rounds': args.rounds, 'concurrency': args.concurrency,
|
||||
'binary': args.binary, 'binary_sha256': hashlib.sha256(pathlib.Path(args.binary).read_bytes()).hexdigest(),
|
||||
'before': process_metrics(pid)}
|
||||
begun = time.perf_counter()
|
||||
def save(phase):
|
||||
report['phase'] = phase
|
||||
report['status'] = 'running'
|
||||
report['elapsed_seconds'] = round(time.perf_counter() - begun, 3)
|
||||
pathlib.Path(args.output).write_text(json.dumps(report,indent=2)+'\n')
|
||||
print(f'MUC+SM load: {phase} ({report["elapsed_seconds"]}s)', file=sys.stderr, flush=True)
|
||||
try:
|
||||
async def setup(number):
|
||||
async with gate:
|
||||
started = time.perf_counter()
|
||||
client = await Client.connect(number, port, context, args.accounts)
|
||||
clients.append(client)
|
||||
await client.enable()
|
||||
await client.stanza('<presence/>')
|
||||
await client.fence(f'connected-{number}')
|
||||
await client.ack()
|
||||
return time.perf_counter() - started
|
||||
connect_times = await asyncio.gather(*(setup(i) for i in range(args.clients)))
|
||||
clients.sort(key=lambda c: c.number)
|
||||
report['connect_seconds'] = round(time.perf_counter() - begun, 3)
|
||||
report['connect_latency'] = distribution(connect_times)
|
||||
save('connected')
|
||||
groups = [[c for c in clients if c.number % args.rooms == room] for room in range(args.rooms)]
|
||||
started = time.perf_counter()
|
||||
async def join(client):
|
||||
async with gate:
|
||||
room = f'load-{client.number % args.rooms}@rooms.localhost'
|
||||
await client.stanza(f"<presence to='{room}/Nick-{client.number}'><x xmlns='{MUC}'><history maxstanzas='0'/></x></presence>")
|
||||
result = await client.wait(lambda: client.presences.get(f'{room}/Nick-{client.number}'), 'self MUC join')
|
||||
assert result.get('type') != 'error', ET.tostring(result)
|
||||
await asyncio.gather(*(join(c) for c in clients))
|
||||
# Each room has exactly N nicknames, including self, on every resource.
|
||||
for group in groups:
|
||||
for client in group:
|
||||
expected = {f'load-{client.number % args.rooms}@rooms.localhost/Nick-{other.number}' for other in group}
|
||||
await client.wait(lambda c=client,e=expected: e.issubset(c.presences), 'complete join fanout')
|
||||
await asyncio.gather(*(c.fence(f'joined-{c.number}') for c in clients))
|
||||
await asyncio.gather(*(c.ack() for c in clients))
|
||||
await asyncio.gather(*(c.handled() for c in clients))
|
||||
report['join_seconds'] = round(time.perf_counter() - started, 3)
|
||||
report['joined'] = process_metrics(pid)
|
||||
report['join_presence_deliveries'] = sum(len(g) ** 2 for g in groups)
|
||||
report['slots_joined'] = slots(root)
|
||||
assert report['slots_joined']['count'] == args.clients, report['slots_joined']
|
||||
save('joined')
|
||||
delivered = 0
|
||||
started = time.perf_counter()
|
||||
for turn in range(args.rounds):
|
||||
for index, group in enumerate(groups):
|
||||
sender = group[turn % len(group)]
|
||||
text = f'durable-load-{turn}-{index}'
|
||||
await sender.stanza(f"<message type='groupchat' to='load-{index}@rooms.localhost'><body>{text}</body></message>")
|
||||
async def receive(client):
|
||||
text = f'durable-load-{turn}-{client.number % args.rooms}'
|
||||
await client.wait(lambda: client.bodies[text] >= 1, text)
|
||||
assert client.bodies[text] == 1, (client.number, text, client.bodies[text])
|
||||
await asyncio.gather(*(receive(c) for c in clients))
|
||||
delivered += len(clients)
|
||||
await asyncio.gather(*(c.fence(f'round-{turn}-{c.number}') for c in clients))
|
||||
await asyncio.gather(*(c.ack() for c in clients))
|
||||
report['message_seconds'] = round(time.perf_counter() - started, 3)
|
||||
report['room_messages_sent'] = args.rounds * args.rooms
|
||||
report['room_messages_delivered'] = delivered
|
||||
assert delivered == args.clients * args.rounds
|
||||
save('fanout_verified')
|
||||
assert not [(c.number,c.errors) for c in clients if c.errors]
|
||||
# Freeze acknowledgements, then deliberately receive one more message
|
||||
# per room without acknowledging it. Its durable replay is mandatory.
|
||||
representatives = [group[0] for group in groups]
|
||||
for client in clients:
|
||||
client.auto_ack = False
|
||||
for index, sender in enumerate(representatives):
|
||||
await sender.stanza(f"<message type='groupchat' to='load-{index}@rooms.localhost'><body>pending-{index}</body></message>")
|
||||
await asyncio.gather(*(c.wait(lambda c=c: c.bodies[f'pending-{c.number % args.rooms}'] == 1, 'pending room message') for c in clients))
|
||||
await asyncio.gather(*(c.fence(f'before-kill-{c.number}') for c in clients))
|
||||
await asyncio.gather(*(c.handled() for c in clients))
|
||||
expected = {c.number: {'token': c.token, 'acked': c.acked, 'handled': c.inputs} for c in representatives}
|
||||
report['before_kill'] = process_metrics(pid)
|
||||
report['slots_before_kill'] = slots(root)
|
||||
save('before_sigkill')
|
||||
server.send_signal(signal.SIGKILL)
|
||||
assert await asyncio.to_thread(server.wait, 10) == -signal.SIGKILL
|
||||
await asyncio.gather(*(c.close_raw() for c in clients))
|
||||
clients.clear()
|
||||
started = time.perf_counter()
|
||||
server = await asyncio.to_thread(start, args.binary, config, port, log)
|
||||
pid = proc_pid(server.pid)
|
||||
report['restart_seconds'] = round(time.perf_counter() - started, 3)
|
||||
async def recover(number):
|
||||
async with gate:
|
||||
old = expected[number]
|
||||
client = await Client.connect(number, port, context, args.accounts, bind=False)
|
||||
client.auto_ack = False
|
||||
client.received = old['acked']
|
||||
client.acked = old['acked']
|
||||
client.inputs = old['handled']
|
||||
client.token = old['token']
|
||||
clients.append(client)
|
||||
await client.send(f"<resume xmlns='{SM}' previd='{client.token}' h='{client.acked}'/>")
|
||||
result = await client.control('resumed')
|
||||
assert result.get('previd') == client.token
|
||||
assert int(result.get('h')) == client.inputs, (number, ET.tostring(result),client.inputs)
|
||||
room = number % args.rooms
|
||||
await client.wait(lambda: client.bodies[f'pending-{room}'] == 1, 'unacknowledged room replay')
|
||||
await client.wait(lambda: client.iqs.get(f'before-kill-{number}'), 'unacknowledged IQ replay')
|
||||
# No available presence or MUC join is sent after SM resume.
|
||||
text = f'after-resume-{room}'
|
||||
await client.stanza(f"<message type='groupchat' to='load-{room}@rooms.localhost'><body>{text}</body></message>")
|
||||
await client.wait(lambda: client.bodies[text] == 1, 'recovered membership groupchat echo')
|
||||
await client.fence(f'after-resume-{number}')
|
||||
await client.ack()
|
||||
await client.handled()
|
||||
assert not client.errors, client.errors
|
||||
# No message already acknowledged before SIGKILL may replay.
|
||||
assert not any(count for text,count in client.bodies.items() if text and text.startswith('durable-load-')), client.bodies
|
||||
return number
|
||||
recovered = await asyncio.gather(*(recover(c.number) for c in representatives))
|
||||
report['resumed_resources'] = len(recovered)
|
||||
report['replayed_pending_room_messages'] = len(recovered)
|
||||
report['membership_groupchat_deliveries_after_resume'] = len(recovered)
|
||||
report['after_resume'] = process_metrics(pid)
|
||||
report['slots_after_resume'] = slots(root)
|
||||
report['total_seconds'] = round(time.perf_counter() - begun, 3)
|
||||
report['elapsed_seconds'] = report['total_seconds']
|
||||
report['peak_rss_kib'] = max((s.get('rss_kib',0) for s in samples), default=0)
|
||||
report['status'] = 'pass'
|
||||
report['phase'] = 'completed'
|
||||
return report
|
||||
except BaseException as error:
|
||||
report['status'] = 'fail'
|
||||
report['failure'] = repr(error)
|
||||
report['elapsed_seconds'] = round(time.perf_counter() - begun, 3)
|
||||
pathlib.Path(args.output).write_text(json.dumps(report,indent=2)+'\n')
|
||||
raise
|
||||
finally:
|
||||
running = False
|
||||
sampler.cancel()
|
||||
await asyncio.gather(sampler, return_exceptions=True)
|
||||
if server.poll() is None:
|
||||
server.terminate()
|
||||
try:
|
||||
await asyncio.to_thread(server.wait, 20)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
await asyncio.to_thread(server.wait)
|
||||
await asyncio.gather(*(c.close_raw() for c in clients), return_exceptions=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--binary', default='target/release/rust-xmpp')
|
||||
parser.add_argument('--clients', type=int, default=100)
|
||||
parser.add_argument('--accounts', type=int, default=100)
|
||||
parser.add_argument('--rooms', type=int, default=10)
|
||||
parser.add_argument('--rounds', type=int, default=3)
|
||||
parser.add_argument('--concurrency', type=int, default=20)
|
||||
parser.add_argument('--output', default='/tmp/muc-sm-load.json')
|
||||
args = parser.parse_args()
|
||||
args.binary = str(pathlib.Path(args.binary).resolve())
|
||||
args.accounts = min(args.accounts,args.clients)
|
||||
assert 1 <= args.rooms <= args.clients <= 4000
|
||||
assert 1 <= args.accounts <= args.clients and args.rounds > 0 and args.concurrency > 0
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-durable-muc-load-') as tmp:
|
||||
root = pathlib.Path(tmp)
|
||||
# Release builds/packaging may replace the source binary during a long
|
||||
# run; this private executable is the same inode generation on restart.
|
||||
shutil.copy2(args.binary, root / 'rust-xmpp')
|
||||
args.binary = str(root / 'rust-xmpp')
|
||||
subprocess.run(['openssl','req','-x509','-newkey','ec','-pkeyopt','ec_paramgen_curve:P-256',
|
||||
'-nodes','-days','1','-subj','/CN=localhost','-addext','subjectAltName=DNS:localhost',
|
||||
'-addext','basicConstraints=critical,CA:FALSE','-keyout',str(root/'key.pem'),
|
||||
'-out',str(root/'cert.pem')],check=True,capture_output=True)
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1',0))
|
||||
port = probe.getsockname()[1]
|
||||
cfg = {'global': {'data_path':str(root/'data'),'interfaces':['127.0.0.1'],
|
||||
'c2s_ports':[port],'s2s_ports':[],'http_ports':[],'https_ports':[],
|
||||
'smacks_hibernation_time':600,'muc_recovery_grace_period':600,
|
||||
'modules_enabled':['tls','saslauth','smacks','disco','ping'],
|
||||
'ssl':{'key':str(root/'key.pem'),'certificate':str(root/'cert.pem')}},
|
||||
'hosts': {'localhost':{'options':{'authentication':'internal_hashed','password_hash':'SHA-256'}},
|
||||
'rooms.localhost':{'component':'muc','options':{'muc_room_locking':False,
|
||||
'muc_room_default_history_length':0,'max_history_messages':0}}}}
|
||||
config = root/'config.json'
|
||||
config.write_text(json.dumps(cfg))
|
||||
for number in range(args.accounts):
|
||||
subprocess.run([args.binary,'create-user',str(config),f'load-{number}','--password-stdin'],
|
||||
input='load-test-password\n',text=True,check=True,capture_output=True)
|
||||
with (root/'server.log').open('w+') as log:
|
||||
try:
|
||||
report = asyncio.run(exercise(args,root,config,port,log))
|
||||
except BaseException:
|
||||
log.flush();log.seek(0)
|
||||
print(log.read(),file=sys.stderr)
|
||||
raise
|
||||
pathlib.Path(args.output).write_text(json.dumps(report,indent=2)+'\n')
|
||||
print(json.dumps(report,indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -13,9 +13,10 @@ fn actual_prosody_restores_rust_checkpoint_and_rust_reads_native_freeze() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
// Let the real room implementation create its normal configuration/index.
|
||||
let mut muc = Muc::new(root.path().into(), DOMAIN.into());
|
||||
muc.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
|
||||
muc.set_defaults(serde_json::json!({"persistent":true,"whois":"moderators"}));
|
||||
let join = roxmltree::Document::parse(
|
||||
"<presence to='room@rooms.example.org/Alice'><status>Rust desktop</status></presence>",
|
||||
"<presence to='room@rooms.example.org/Alice'><x xmlns='http://jabber.org/protocol/muc'/><status>Rust desktop</status></presence>",
|
||||
)
|
||||
.unwrap();
|
||||
muc.handle("alice@example.org/desktop", join.root_element())
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
//! Process-independent durable SM slots; network SIGKILL coverage lives in sm_restart_network.py.
|
||||
use rust_xmpp::sessions::{ConflictPolicy, Hub};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[test]
|
||||
fn restart_restores_owner_presence_and_only_unacknowledged_output() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let hub = Hub::persistent(dir.path()).unwrap();
|
||||
let (tx, mut rx) = mpsc::channel(8);
|
||||
let session = hub
|
||||
.bind("a@x/mobile", "original", tx, Duration::from_secs(60))
|
||||
.unwrap();
|
||||
let token;
|
||||
{
|
||||
let mut state = session.lock().unwrap();
|
||||
state.enable(true).unwrap();
|
||||
token = state.resume_id.clone().unwrap();
|
||||
state.available = true;
|
||||
state.priority = 3;
|
||||
state.presence = Some("<presence><show>away</show></presence>".into());
|
||||
state.client_id = Some("mobile-client".into());
|
||||
state.csi.set_inactive(true);
|
||||
state.mark_handled().unwrap();
|
||||
state.transmit("first".into()).unwrap();
|
||||
state.transmit("second".into()).unwrap();
|
||||
state.acknowledge(1).unwrap();
|
||||
}
|
||||
assert_eq!(rx.try_recv().unwrap(), "first");
|
||||
drop(hub);
|
||||
let recovered = Hub::persistent(dir.path()).unwrap();
|
||||
let state = recovered.get("a@x/mobile").unwrap();
|
||||
{
|
||||
let state = state.lock().unwrap();
|
||||
assert!(state.sender.is_none());
|
||||
assert!(state.available);
|
||||
assert_eq!(state.priority, 3);
|
||||
assert_eq!(state.handled, 1);
|
||||
assert_eq!(state.client_id.as_deref(), Some("mobile-client"));
|
||||
assert_eq!(
|
||||
state.pending.iter().cloned().collect::<Vec<_>>(),
|
||||
["second"]
|
||||
);
|
||||
}
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
assert!(
|
||||
recovered
|
||||
.resume("attacker@x", &token, 1, "bad", tx.clone())
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
recovered
|
||||
.resume("a@x", &token, 3, "bad", tx.clone())
|
||||
.is_err()
|
||||
);
|
||||
let (resumed, replay) = recovered
|
||||
.resume("a@x", &token, 1, "replacement", tx)
|
||||
.unwrap();
|
||||
assert!(replay[0].contains("h='1'"));
|
||||
assert_eq!(replay[1], "second");
|
||||
assert!(state.lock().unwrap().acknowledge(2).is_err());
|
||||
resumed.lock().unwrap().acknowledge(2).unwrap();
|
||||
drop(recovered);
|
||||
let recovered = Hub::persistent(dir.path()).unwrap();
|
||||
assert!(
|
||||
recovered
|
||||
.get("a@x/mobile")
|
||||
.unwrap()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pending
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnected_deadline_is_fixed_across_recovery() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let hub = Hub::persistent(dir.path()).unwrap();
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
let session = hub.bind("a@x/r", "c", tx, Duration::from_secs(60)).unwrap();
|
||||
session.lock().unwrap().enable(true).unwrap();
|
||||
hub.disconnect("c", false);
|
||||
let deadline = session.lock().unwrap().expires.unwrap();
|
||||
hub.disconnect("c", false);
|
||||
assert_eq!(session.lock().unwrap().expires.unwrap(), deadline);
|
||||
drop(hub);
|
||||
let recovered = Hub::persistent(dir.path()).unwrap();
|
||||
let recovered_deadline = recovered
|
||||
.get("a@x/r")
|
||||
.unwrap()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.expires
|
||||
.unwrap();
|
||||
assert!(recovered_deadline <= deadline + Duration::from_millis(2));
|
||||
assert!(recovered_deadline >= deadline - Duration::from_millis(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graceful_close_conflict_account_purge_and_expiration_remove_tokens() {
|
||||
for action in ["close", "conflict", "purge", "expire"] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let hub = Hub::persistent(dir.path()).unwrap();
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
let session = hub
|
||||
.bind("a@x/r", "c", tx.clone(), Duration::from_secs(60))
|
||||
.unwrap();
|
||||
session.lock().unwrap().enable(true).unwrap();
|
||||
let token = session.lock().unwrap().resume_id.clone().unwrap();
|
||||
match action {
|
||||
"close" => {
|
||||
hub.disconnect("c", true);
|
||||
}
|
||||
"purge" => hub.kick_owner("a@x"),
|
||||
"conflict" => {
|
||||
hub.bind_resolving(
|
||||
"a@x/r",
|
||||
"new",
|
||||
tx.clone(),
|
||||
Duration::from_secs(60),
|
||||
ConflictPolicy::KickOld,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
_ => {
|
||||
session.lock().unwrap().expires = Some(Instant::now());
|
||||
hub.expired();
|
||||
}
|
||||
}
|
||||
drop(hub);
|
||||
let recovered = Hub::persistent(dir.path()).unwrap();
|
||||
assert!(
|
||||
recovered.resume("a@x", &token, 0, "after", tx).is_err(),
|
||||
"{action}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_has_one_process_owner_and_enforces_output_byte_bound() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let hub = Hub::persistent(dir.path()).unwrap();
|
||||
assert!(Hub::persistent(dir.path()).is_err());
|
||||
let (tx, mut rx) = mpsc::channel(8);
|
||||
let session = hub.bind("a@x/r", "c", tx, Duration::from_secs(60)).unwrap();
|
||||
let mut state = session.lock().unwrap();
|
||||
state.enable(true).unwrap();
|
||||
assert!(state.transmit("x".repeat(8 * 1024 * 1024 + 1)).is_err());
|
||||
// Fits the payload limit but exceeds the serialized slot limit: persistence
|
||||
// failure must also roll counters back and keep bytes off the transport.
|
||||
assert!(state.transmit("x".repeat(8 * 1024 * 1024)).is_err());
|
||||
assert_eq!(state.sent, 0);
|
||||
assert!(state.pending.is_empty());
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_freeze_retires_old_transport_before_cleanup() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let hub = Hub::persistent(dir.path()).unwrap();
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
let old = hub.bind("a@x/r", "c", tx, Duration::from_secs(60)).unwrap();
|
||||
old.lock().unwrap().enable(true).unwrap();
|
||||
old.lock().unwrap().transmit("pending".into()).unwrap();
|
||||
let token = old.lock().unwrap().resume_id.clone().unwrap();
|
||||
hub.freeze().unwrap();
|
||||
assert!(old.lock().unwrap().acknowledge(1).is_err());
|
||||
assert!(old.lock().unwrap().transmit("stale".into()).is_err());
|
||||
assert!(hub.disconnect("c", true).is_empty());
|
||||
drop(hub);
|
||||
let hub = Hub::persistent(dir.path()).unwrap();
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
let (_, replay) = hub.resume("a@x", &token, 0, "fresh", tx).unwrap();
|
||||
assert_eq!(replay[1], "pending");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_counters_wrap_without_accepting_stale_acknowledgements() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let hub = Hub::persistent(dir.path()).unwrap();
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
let session = hub.bind("a@x/r", "c", tx, Duration::from_secs(60)).unwrap();
|
||||
{
|
||||
let mut state = session.lock().unwrap();
|
||||
state.enable(true).unwrap();
|
||||
state.sent = u32::MAX;
|
||||
state.acked = u32::MAX;
|
||||
state.handled = u32::MAX;
|
||||
state.mark_handled().unwrap();
|
||||
state.transmit("wrapped".into()).unwrap();
|
||||
}
|
||||
drop(hub);
|
||||
let hub = Hub::persistent(dir.path()).unwrap();
|
||||
let session = hub.get("a@x/r").unwrap();
|
||||
let mut state = session.lock().unwrap();
|
||||
assert_eq!(state.handled, 0);
|
||||
assert_eq!(state.sent, 0);
|
||||
assert!(state.acknowledge(u32::MAX - 1).is_err());
|
||||
state.acknowledge(0).unwrap();
|
||||
assert!(state.pending.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn durable_tokens_and_private_pending_output_have_owner_only_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let hub = Hub::persistent(dir.path()).unwrap();
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
let session = hub.bind("a@x/r", "c", tx, Duration::from_secs(60)).unwrap();
|
||||
session.lock().unwrap().enable(true).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777,
|
||||
0o700
|
||||
);
|
||||
for entry in std::fs::read_dir(dir.path()).unwrap() {
|
||||
assert_eq!(
|
||||
entry.unwrap().metadata().unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_discards_owned_crash_temps_and_keeps_last_committed_slot() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let hub = Hub::persistent(dir.path()).unwrap();
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
let session = hub.bind("a@x/r", "c", tx, Duration::from_secs(60)).unwrap();
|
||||
let token;
|
||||
{
|
||||
let mut state = session.lock().unwrap();
|
||||
state.enable(true).unwrap();
|
||||
state.transmit("committed".into()).unwrap();
|
||||
token = state.resume_id.clone().unwrap();
|
||||
}
|
||||
drop(hub);
|
||||
let interrupted = dir.path().join(format!("{token}.tmp"));
|
||||
let orphan = dir.path().join(format!("{}.tmp", uuid::Uuid::new_v4()));
|
||||
let unknown = dir.path().join("notes.tmp");
|
||||
let unknown_dir = dir.path().join(format!("{}.tmp", uuid::Uuid::new_v4()));
|
||||
std::fs::write(&interrupted, b"{\"pending\": [\"uncommitted").unwrap();
|
||||
std::fs::write(&orphan, b"incomplete").unwrap();
|
||||
std::fs::write(&unknown, b"preserve").unwrap();
|
||||
std::fs::create_dir(&unknown_dir).unwrap();
|
||||
let recovered = Hub::persistent(dir.path()).unwrap();
|
||||
assert!(!interrupted.exists());
|
||||
assert!(!orphan.exists());
|
||||
assert!(unknown.exists());
|
||||
assert!(unknown_dir.is_dir());
|
||||
let session = recovered.get("a@x/r").unwrap();
|
||||
let state = session.lock().unwrap();
|
||||
assert_eq!(state.sent, 1);
|
||||
assert_eq!(
|
||||
state.pending.iter().cloned().collect::<Vec<_>>(),
|
||||
["committed"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_committed_slot_fails_recovery_closed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join(format!("{}.json", uuid::Uuid::new_v4())),
|
||||
b"{incomplete",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(Hub::persistent(dir.path()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_hubs_with_identical_connection_names_keep_their_own_slots() {
|
||||
let first_dir = tempfile::tempdir().unwrap();
|
||||
let second_dir = tempfile::tempdir().unwrap();
|
||||
let first = Hub::persistent(first_dir.path()).unwrap();
|
||||
let second = Hub::persistent(second_dir.path()).unwrap();
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
let a = first
|
||||
.bind("a@x/r", "c", tx.clone(), Duration::from_secs(60))
|
||||
.unwrap();
|
||||
let b = second
|
||||
.bind("a@x/r", "c", tx, Duration::from_secs(60))
|
||||
.unwrap();
|
||||
a.lock().unwrap().enable(true).unwrap();
|
||||
b.lock().unwrap().enable(true).unwrap();
|
||||
a.lock().unwrap().transmit("first hub".into()).unwrap();
|
||||
b.lock().unwrap().transmit("second hub".into()).unwrap();
|
||||
first.disconnect("c", true);
|
||||
drop(first);
|
||||
drop(second);
|
||||
let first = Hub::persistent(first_dir.path()).unwrap();
|
||||
let second = Hub::persistent(second_dir.path()).unwrap();
|
||||
assert!(first.get("a@x/r").is_none());
|
||||
let b = second.get("a@x/r").unwrap();
|
||||
assert_eq!(
|
||||
b.lock().unwrap().pending.front().map(String::as_str),
|
||||
Some("second hub")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_reference_does_not_keep_store_locked_or_allow_unpersisted_writes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let hub = Hub::persistent(dir.path()).unwrap();
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
let old = hub.bind("a@x/r", "c", tx, Duration::from_secs(60)).unwrap();
|
||||
old.lock().unwrap().enable(true).unwrap();
|
||||
drop(hub);
|
||||
let recovered = Hub::persistent(dir.path()).unwrap();
|
||||
assert!(old.lock().unwrap().checkpoint().is_err());
|
||||
assert!(old.lock().unwrap().transmit("orphaned".into()).is_err());
|
||||
assert_eq!(old.lock().unwrap().sent, 0);
|
||||
assert!(
|
||||
recovered
|
||||
.get("a@x/r")
|
||||
.unwrap()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pending
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real STARTTLS/SASL SM recovery across abrupt and orderly process restarts.
|
||||
|
||||
Usage: python3 tests/sm_restart_network.py BINARY
|
||||
Pending server stanzas are at-least-once: a received but unacknowledged stanza
|
||||
must replay. This test does not assert exactly-once client stanza processing.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from _wire_client import Client, local
|
||||
from sasl2_bind import Client as InlineClient, SASL2
|
||||
from muc_restart_network import start, stop, join, iq, occupant
|
||||
|
||||
SM = 'urn:xmpp:sm:3'
|
||||
ROOM = 'durable@rooms.localhost'
|
||||
|
||||
|
||||
def enable(client):
|
||||
client.send(f"<enable xmlns='{SM}' resume='true'/>")
|
||||
response = client.take(lambda e: e.tag == f'{{{SM}}}enabled')
|
||||
assert response.get('resume') == 'true', ET.tostring(response)
|
||||
assert response.get('id'), ET.tostring(response)
|
||||
return response.get('id')
|
||||
|
||||
|
||||
def handled(client):
|
||||
client.send(f"<r xmlns='{SM}'/>")
|
||||
return int(client.take(lambda e: e.tag == f'{{{SM}}}a').get('h'))
|
||||
|
||||
|
||||
def ping(client, ident):
|
||||
client.send(f"<iq type='get' id='{ident}'><ping xmlns='urn:xmpp:ping'/></iq>")
|
||||
return client.iq(ident)
|
||||
|
||||
|
||||
def count_output(client):
|
||||
"""Track received SM-counted stanzas, including queued room broadcasts."""
|
||||
client.received = 0
|
||||
original = client.read
|
||||
def read():
|
||||
packet = original()
|
||||
if local(packet) in ('iq', 'presence', 'message'):
|
||||
client.received += 1
|
||||
return packet
|
||||
client.read = read
|
||||
|
||||
|
||||
def resume(port, cert, token, h, clients, user='alice', inline=False, success=True, password='secret'):
|
||||
request = f"<resume xmlns='{SM}' previd='{token}' h='{h}'/>"
|
||||
if inline:
|
||||
raw = InlineClient(port, cert)
|
||||
response = raw.auth(request, user=user, password=password)
|
||||
result = response.find(f'{{{SM}}}' + ('resumed' if success else 'failed'))
|
||||
assert result is not None, ET.tostring(response)
|
||||
if success:
|
||||
assert response.find(f'{{{SASL2}}}authorization-identifier').text == 'alice@localhost/desktop'
|
||||
# Adapt the TLS socket to the independent stanza parser; inline success
|
||||
# precedes replay, and the new stream features follow pending stanzas.
|
||||
client = Client.__new__(Client)
|
||||
client.sock, client.buf, client.queue = raw.sock, raw.buffer, []
|
||||
client.jid = 'alice@localhost/desktop'
|
||||
clients.append(client)
|
||||
if success:
|
||||
while b'</stream:features>' not in client.buf:
|
||||
client.buf += client.sock.recv(65536)
|
||||
before, after = client.buf.split(b'<stream:features', 1)
|
||||
_, tail = after.split(b'</stream:features>', 1)
|
||||
client.buf = before + tail
|
||||
else:
|
||||
client = Client(port, cert, user, password, bind=False)
|
||||
clients.append(client)
|
||||
client.send(request)
|
||||
result = client.take(lambda e: e.tag in (f'{{{SM}}}resumed', f'{{{SM}}}failed'))
|
||||
assert result.tag == f'{{{SM}}}' + ('resumed' if success else 'failed'), ET.tostring(result)
|
||||
if success:
|
||||
assert result.get('previd') == token, ET.tostring(result)
|
||||
return client, result
|
||||
|
||||
|
||||
def run(binary):
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-sm-restart-') as tmp:
|
||||
root = Path(tmp)
|
||||
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1',
|
||||
'-keyout', str(root/'key.pem'), '-out', str(root/'cert.pem'), '-subj',
|
||||
'/CN=localhost', '-addext', 'subjectAltName=DNS:localhost'], check=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1', 0))
|
||||
port = probe.getsockname()[1]
|
||||
cert = root/'cert.pem'
|
||||
config = root/'config.json'
|
||||
cfg = {'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'],
|
||||
'c2s_ports': [port], 's2s_ports': [], 'http_ports': [], 'https_ports': [],
|
||||
'smacks_hibernation_time': 30,
|
||||
'modules_enabled': ['tls', 'saslauth', 'sasl2', 'sasl2_bind2', 'carbons',
|
||||
'csi_simple', 'smacks', 'ping', 'disco', 'register']},
|
||||
'hosts': {'localhost': {'options': {'authentication': 'internal_hashed',
|
||||
'password_hash': 'SHA-256', 'ssl': {'key': str(root/'key.pem'),
|
||||
'certificate': str(cert)}}}, 'rooms.localhost': {'component': 'muc', 'options': {}}}}
|
||||
config.write_text(json.dumps(cfg))
|
||||
for user in ('alice', 'bob'):
|
||||
subprocess.run([binary, 'create-user', str(config), user, '--password-stdin'],
|
||||
input='secret\n', text=True, check=True, capture_output=True)
|
||||
clients, server = [], None
|
||||
with (root/'server.log').open('w+') as log:
|
||||
def connect(user='alice', resource='desktop'):
|
||||
client = Client(port, cert, user, 'secret', resource)
|
||||
clients.append(client)
|
||||
return client
|
||||
def restart(how):
|
||||
nonlocal server
|
||||
if how == 'kill':
|
||||
server.send_signal(signal.SIGKILL)
|
||||
assert server.wait(timeout=10) == -signal.SIGKILL
|
||||
else:
|
||||
stop(server)
|
||||
for client in clients:
|
||||
client.close()
|
||||
clients.clear()
|
||||
server = start(binary, config, port, log)
|
||||
try:
|
||||
server = start(binary, config, port, log)
|
||||
bob = connect(user='bob')
|
||||
bob.available()
|
||||
alice = connect()
|
||||
token = enable(alice)
|
||||
ping(alice, 'acknowledged')
|
||||
alice.send(f"<a xmlns='{SM}' h='1'/>")
|
||||
ping(alice, 'pending')
|
||||
# A handled incoming stanza without a response is preserved too.
|
||||
alice.send("<message to='bob@localhost'><body>incoming before crash</body></message>")
|
||||
bob.body('incoming before crash')
|
||||
assert handled(alice) == 3
|
||||
restart('kill')
|
||||
for user, ident, h in [('bob', token, 1), ('alice', 'unknown-token', 1), ('alice', token, 99)]:
|
||||
resume(port, cert, ident, h, clients, user=user, success=False)
|
||||
alice, result = resume(port, cert, token, 1, clients)
|
||||
assert result.get('h') == '3', ET.tostring(result)
|
||||
replay = alice.iq('pending')
|
||||
assert replay.get('id') == 'pending'
|
||||
alice.send(f"<a xmlns='{SM}' h='2'/>")
|
||||
assert handled(alice) == 3
|
||||
assert not alice.queue, [ET.tostring(e) for e in alice.queue]
|
||||
restart('term')
|
||||
alice, result = resume(port, cert, token, 2, clients, inline=True)
|
||||
assert result.get('h') == '3', ET.tostring(result)
|
||||
ping(alice, 'after-orderly')
|
||||
assert not alice.queue, [ET.tostring(e) for e in alice.queue]
|
||||
alice.send(f"<a xmlns='{SM}' h='3'/>")
|
||||
assert handled(alice) == 4
|
||||
restart('kill')
|
||||
alice, result = resume(port, cert, token, 3, clients)
|
||||
assert result.get('h') == '4', ET.tostring(result)
|
||||
ping(alice, 'after-second-crash')
|
||||
assert not alice.queue, [ET.tostring(e) for e in alice.queue]
|
||||
# Only one transport owns a token: the next claim displaces it.
|
||||
old = alice
|
||||
alice, result = resume(port, cert, token, 4, clients)
|
||||
old.sock.settimeout(2)
|
||||
assert b'conflict' in old.until(b'</stream:stream>')
|
||||
assert handled(alice) == 5
|
||||
# Explicitly replacing the same resource invalidates its token.
|
||||
replacement = connect()
|
||||
resume(port, cert, token, 4, clients, success=False)
|
||||
replacement.close()
|
||||
|
||||
# SM resumption itself activates recovered MUC membership. No
|
||||
# fresh available presence or MUC join is sent after restart.
|
||||
alice = connect(resource='room')
|
||||
count_output(alice)
|
||||
token = enable(alice)
|
||||
occupant(join(alice, ROOM, 'Alice'), 'moderator', 'owner')
|
||||
iq(alice, 'unlock', f"<query xmlns='http://jabber.org/protocol/muc#owner'><x xmlns='jabber:x:data' type='submit'/></query>", ROOM)
|
||||
bob = connect(user='bob', resource='room')
|
||||
count_output(bob)
|
||||
bob_token = enable(bob)
|
||||
occupant(join(bob, ROOM, 'Bob'), 'participant', 'none')
|
||||
alice.send(f"<message to='{ROOM}' type='groupchat'><body>before room crash</body></message>")
|
||||
alice.body('before room crash')
|
||||
bob.body('before room crash')
|
||||
ping(alice, 'room-fence')
|
||||
ping(bob, 'bob-room-fence')
|
||||
alice_count, bob_count = alice.received, bob.received
|
||||
alice.send(f"<a xmlns='{SM}' h='{alice_count}'/>")
|
||||
bob.send(f"<a xmlns='{SM}' h='{bob_count}'/>")
|
||||
assert handled(alice) == 4
|
||||
assert handled(bob) == 2
|
||||
restart('kill')
|
||||
alice, result = resume(port, cert, token, alice_count, clients)
|
||||
assert result.get('h') == '4'
|
||||
# Bob's logical SM session remains joined while his transport
|
||||
# is offline. Alice's fanout must queue before Bob authenticates.
|
||||
alice.send(f"<message to='{ROOM}' type='groupchat'><body>after room crash</body></message>")
|
||||
assert alice.body('after room crash').get('from') == ROOM + '/Alice'
|
||||
assert handled(alice) == 5
|
||||
# Inspect the committed slot before any Bob authentication.
|
||||
# A history replay on rejoining is not offline SM delivery.
|
||||
slots = list((root/'data').rglob(bob_token + '.json'))
|
||||
assert len(slots) == 1, slots
|
||||
record = json.loads(slots[0].read_text())
|
||||
assert record['jid'] == 'bob@localhost/room', record
|
||||
queued = [ET.fromstring(xml) for xml in record['pending']]
|
||||
assert any(packet.get('from') == ROOM + '/Alice'
|
||||
and any(local(node) == 'body' and node.text == 'after room crash'
|
||||
for node in packet)
|
||||
for packet in queued), ('room fanout missing from Bob durable pending before authentication', record)
|
||||
bob, result = resume(port, cert, bob_token, bob_count, clients, user='bob')
|
||||
assert result.get('h') == '2'
|
||||
packet = bob.body('after room crash')
|
||||
assert packet.get('from') == ROOM + '/Alice'
|
||||
assert packet.find('.//{urn:xmpp:delay}delay') is None, ET.tostring(packet)
|
||||
restart('term')
|
||||
|
||||
# Expired detached sessions stay expired through repeated boot.
|
||||
cfg['global']['smacks_hibernation_time'] = 1
|
||||
stop(server)
|
||||
config.write_text(json.dumps(cfg))
|
||||
server = start(binary, config, port, log)
|
||||
short = connect(resource='short')
|
||||
expired = enable(short)
|
||||
short.close()
|
||||
time.sleep(1.3)
|
||||
restart('kill')
|
||||
resume(port, cert, expired, 0, clients, success=False)
|
||||
restart('term')
|
||||
resume(port, cert, expired, 0, clients, success=False)
|
||||
|
||||
# Account deletion must deliver its successful IQ response
|
||||
# before closing the stream and revoke every old resume token.
|
||||
stop(server)
|
||||
cfg['global']['smacks_hibernation_time'] = 300
|
||||
config.write_text(json.dumps(cfg))
|
||||
server = start(binary, config, port, log)
|
||||
removed = connect(resource='remove-account')
|
||||
revoked = enable(removed)
|
||||
removed.send("<iq type='set' id='remove-account'><query xmlns='jabber:iq:register'><remove/></query></iq>")
|
||||
removed.iq('remove-account')
|
||||
stop(server)
|
||||
subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'],
|
||||
input='new-secret\n', text=True, check=True, capture_output=True)
|
||||
server = start(binary, config, port, log)
|
||||
resume(port, cert, revoked, 0, clients, password='new-secret', success=False)
|
||||
finally:
|
||||
if server is not None and server.poll() is None:
|
||||
stop(server)
|
||||
for client in clients:
|
||||
client.close()
|
||||
if sys.exc_info()[0] is not None:
|
||||
log.seek(0)
|
||||
print(log.read(), file=sys.stderr)
|
||||
print('PASS: TLS legacy/SASL2 durable SM after SIGKILL/SIGTERM, counters, ack pruning, at-least-once pending replay, owner/token/h rejection, transport/resource conflict, MUC activation/offline fanout, grace expiry and account deletion/recreation revocation')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run(str(Path(sys.argv[1]).resolve()))
|
||||
Reference in new issue
Block a user