channel-rename: implement IRCv3 draft/channel-rename — RENAME command + cap, in-place rename (RENAME for cap clients, PART/JOIN fallback for the rest), and S2S propagation
This commit is contained in:
parent
755e835baf
commit
c9bd8e7492
6 changed files with 400 additions and 1 deletions
104
src/channels.rs
104
src/channels.rs
|
|
@ -970,6 +970,110 @@ impl Server {
|
|||
self.events.push_back(Hook::Join(uid, key));
|
||||
}
|
||||
|
||||
/// Rename channel `oldkey` (an existing lowercase key) to display name
|
||||
/// `newname`, preserving all state — membership, modes, topic, bans, TS. The
|
||||
/// channel object is rekeyed in the table and every local member's channel set
|
||||
/// is moved with it. Local members are then notified: a `RENAME` line for
|
||||
/// draft/channel-rename clients, a PART+JOIN(+topic+names) emulation for the
|
||||
/// rest (skipped when only the casing changed, per the spec). `src_prefix` is
|
||||
/// the nick!user@host — or server name — shown as the RENAME source. Returns
|
||||
/// the new lowercase key, or `None` if the move couldn't be made. Callers
|
||||
/// validate policy first (op rank, name validity, collision).
|
||||
pub fn rename_channel(
|
||||
&mut self,
|
||||
oldkey: &str,
|
||||
newname: &str,
|
||||
src_prefix: &str,
|
||||
reason: &str,
|
||||
) -> Option<String> {
|
||||
let newkey = newname.to_ascii_lowercase();
|
||||
let case_only = newkey.as_str() == oldkey;
|
||||
if !self.channels.contains_key(oldkey) {
|
||||
return None;
|
||||
}
|
||||
if !case_only && self.channels.contains_key(&newkey) {
|
||||
return None;
|
||||
}
|
||||
let oldname = self.channels[oldkey].name.clone();
|
||||
let members: Vec<Uid> = self.channels[oldkey].members.keys().copied().collect();
|
||||
if case_only {
|
||||
if let Some(ch) = self.channels.get_mut(oldkey) {
|
||||
ch.name = newname.to_string();
|
||||
}
|
||||
} else {
|
||||
let mut ch = self.channels.remove(oldkey).unwrap();
|
||||
ch.name = newname.to_string();
|
||||
self.channels.insert(newkey.clone(), ch);
|
||||
for &m in &members {
|
||||
if let Some(u) = self.users.get_mut(&m) {
|
||||
u.channels.remove(oldkey);
|
||||
u.channels.insert(newkey.clone());
|
||||
}
|
||||
}
|
||||
self.rekey_channel_state(oldkey, &newkey);
|
||||
}
|
||||
let renline = format!(":{src_prefix} RENAME {oldname} {newname} :{reason}");
|
||||
for &m in &members {
|
||||
let has_cap = self
|
||||
.users
|
||||
.get(&m)
|
||||
.map(|u| u.caps.channel_rename)
|
||||
.unwrap_or(false);
|
||||
if has_cap {
|
||||
self.send(m, renline.clone());
|
||||
} else if !case_only {
|
||||
self.emulate_rename_join(m, &oldname, newname, &newkey, reason);
|
||||
}
|
||||
// case-only + no cap: the spec says the PART/JOIN fallback SHOULD NOT
|
||||
// be used, so those clients simply keep the channel under its old case.
|
||||
}
|
||||
Some(newkey)
|
||||
}
|
||||
|
||||
/// The PART-old + JOIN-new(+topic+names) fallback shown to one member that
|
||||
/// lacks draft/channel-rename, so their client follows the channel across a
|
||||
/// rename. Mirrors the JOIN broadcast (extended-join aware).
|
||||
fn emulate_rename_join(&self, m: Uid, oldname: &str, newname: &str, newkey: &str, reason: &str) {
|
||||
let Some(u) = self.users.get(&m) else {
|
||||
return;
|
||||
};
|
||||
let prefix = u.prefix();
|
||||
let joinline = if u.caps.extended_join {
|
||||
let acct = u.account.clone().unwrap_or_else(|| "*".to_string());
|
||||
format!(":{prefix} JOIN {newname} {acct} :{}", u.realname)
|
||||
} else {
|
||||
format!(":{prefix} JOIN {newname}")
|
||||
};
|
||||
let partline = if reason.is_empty() {
|
||||
format!(":{prefix} PART {oldname}")
|
||||
} else {
|
||||
format!(":{prefix} PART {oldname} :{reason}")
|
||||
};
|
||||
self.send(m, partline);
|
||||
self.send(m, joinline);
|
||||
if let Some(t) = self.channels.get(newkey).and_then(|c| c.topic.as_ref()) {
|
||||
let text = t.text.clone();
|
||||
self.numeric(m, RPL_TOPIC, &format!("{newname} :{text}"));
|
||||
}
|
||||
self.send_names(m, newkey);
|
||||
}
|
||||
|
||||
/// Move a channel's auxiliary, name-keyed module state (recorded history,
|
||||
/// channel metadata) from `oldkey` to `newkey` on a rename, so a CHATHISTORY
|
||||
/// replay or a metadata read still finds it under the new name.
|
||||
fn rekey_channel_state(&mut self, oldkey: &str, newkey: &str) {
|
||||
if let Some(h) = self.ext.get_mut::<crate::modules::chathistory::History>() {
|
||||
if let Some(v) = h.0.remove(oldkey) {
|
||||
h.0.insert(newkey.to_string(), v);
|
||||
}
|
||||
}
|
||||
if let Some(store) = self.ext.get_mut::<crate::modules::metadata::MetaStore>() {
|
||||
if let Some(v) = store.0.remove(oldkey) {
|
||||
store.0.insert(newkey.to_string(), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// +H chanhistory: replay a channel's recent messages to a user who just
|
||||
/// joined — the last `<lines>` (within `<secs>`, 0 = no limit) from the store,
|
||||
/// wrapped in a `chathistory` batch for batch-capable clients.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! core_channel — channel membership commands: JOIN, PART, KICK, TOPIC, NAMES.
|
||||
|
||||
use crate::channels::{normalize_ban_mask, Ban, Topic, RANK_HALFOP};
|
||||
use crate::channels::{normalize_ban_mask, valid_chan, Ban, Topic, RANK_HALFOP, RANK_OP};
|
||||
use crate::command::{CmdResult, Command};
|
||||
use crate::module::Hook;
|
||||
use crate::numeric::*;
|
||||
|
|
@ -12,6 +12,7 @@ pub fn commands() -> Vec<Box<dyn Command>> {
|
|||
vec![
|
||||
Box::new(Join),
|
||||
Box::new(Part),
|
||||
Box::new(Rename),
|
||||
Box::new(Kick),
|
||||
Box::new(TopicCmd),
|
||||
Box::new(Names),
|
||||
|
|
@ -479,6 +480,112 @@ impl Command for Part {
|
|||
}
|
||||
}
|
||||
|
||||
/// RENAME `<old-channel> <new-channel> [<reason>]` — IRCv3 `draft/channel-rename`.
|
||||
/// A channel operator renames a channel in place, keeping its membership, modes
|
||||
/// and topic. Members that negotiated the cap see a `RENAME`; the rest are moved
|
||||
/// with a PART/JOIN. Registered (+r) channels are managed by services, so a
|
||||
/// client can't rename them — ChanServ does that over S2S.
|
||||
struct Rename;
|
||||
impl Command for Rename {
|
||||
fn name(&self) -> &'static str {
|
||||
"RENAME"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let (old, new) = (¶ms[0], ¶ms[1]);
|
||||
let reason = params.get(2).cloned().unwrap_or_default();
|
||||
let oldkey = old.to_ascii_lowercase();
|
||||
let newkey = new.to_ascii_lowercase();
|
||||
let case_only = oldkey == newkey;
|
||||
if !s.channels.contains_key(&oldkey) {
|
||||
s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{old} :No such channel"));
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if !s.is_member(uid, &oldkey) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOTONCHANNEL,
|
||||
&format!("{old} :You're not on that channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let is_oper = s.is_oper(uid);
|
||||
if s.rank(uid, &oldkey) < RANK_OP && !is_oper {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CHANOPRIVSNEEDED,
|
||||
&format!("{old} :You're not a channel operator"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// A registered channel's name is owned by services; renaming it moves the
|
||||
// registration, which only ChanServ (founder-authorised) may do.
|
||||
if s.channels[&oldkey].modes.registered && !is_oper {
|
||||
s.fail(
|
||||
uid,
|
||||
"RENAME",
|
||||
"CANNOT_RENAME",
|
||||
"This channel is registered — ask ChanServ to rename it.",
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if !valid_chan(new, s.conf_num("maxchannel", 50usize)) {
|
||||
s.fail(
|
||||
uid,
|
||||
"RENAME",
|
||||
"CANNOT_RENAME",
|
||||
&format!("{new} is not a valid channel name."),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// A pure prefix-type change (e.g. # -> &) isn't a rename we support.
|
||||
if new.chars().next() != old.chars().next() {
|
||||
s.fail(
|
||||
uid,
|
||||
"RENAME",
|
||||
"CANNOT_RENAME",
|
||||
"The channel prefix can't be changed.",
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if !case_only && s.channels.contains_key(&newkey) {
|
||||
s.fail(
|
||||
uid,
|
||||
"RENAME",
|
||||
"CHANNEL_NAME_IN_USE",
|
||||
&format!("{new} already exists."),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if !is_oper {
|
||||
if let Some(reason) = s.matched_cban(&newkey) {
|
||||
s.fail(
|
||||
uid,
|
||||
"RENAME",
|
||||
"CANNOT_RENAME",
|
||||
&format!("{new} is CBAN'd: {reason}"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
}
|
||||
// Capture the identity/TS before the move, then rename + propagate.
|
||||
let (prefix, uuid) = {
|
||||
let u = &s.users[&uid];
|
||||
(u.prefix(), u.uuid.clone())
|
||||
};
|
||||
let oldname = s.channels[&oldkey].name.clone();
|
||||
if s.rename_channel(&oldkey, new, &prefix, &reason).is_none() {
|
||||
s.fail(uid, "RENAME", "CANNOT_RENAME", "The channel cannot be renamed.");
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
s.snotice_c('a', &format!("{oldname} renamed to {new} by {prefix}"));
|
||||
s.propagate_rename(&uuid, &oldname, new, &reason, None);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Kick;
|
||||
impl Command for Kick {
|
||||
fn name(&self) -> &'static str {
|
||||
|
|
|
|||
59
src/link.rs
59
src/link.rs
|
|
@ -178,6 +178,7 @@ impl Server {
|
|||
"TOPIC" if registered => self.link_topic_recv(uid, msg),
|
||||
"FTOPIC" if registered => self.link_ftopic_recv(uid, msg),
|
||||
"KICK" if registered => self.link_kick_recv(uid, msg),
|
||||
"RENAME" if registered => self.link_rename_recv(uid, msg),
|
||||
"MODE" | "FMODE" if registered => self.link_mode_recv(uid, msg),
|
||||
"FJOIN" if registered => self.link_fjoin_recv(uid, msg),
|
||||
"IJOIN" if registered => self.link_ijoin_recv(uid, msg),
|
||||
|
|
@ -1525,6 +1526,27 @@ impl Server {
|
|||
}
|
||||
}
|
||||
|
||||
/// Tell linked servers a channel was renamed. `source` is the initiator's
|
||||
/// uuid (a client or a services pseudoclient) or a server SID; each receiver
|
||||
/// moves the channel and notifies its own members. `except` skips the link a
|
||||
/// forwarded rename arrived on.
|
||||
pub fn propagate_rename(
|
||||
&self,
|
||||
source: &str,
|
||||
oldname: &str,
|
||||
newname: &str,
|
||||
reason: &str,
|
||||
except: Option<Uid>,
|
||||
) {
|
||||
if self.links.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.propagate(
|
||||
&format!(":{source} RENAME {oldname} {newname} :{reason}"),
|
||||
except,
|
||||
);
|
||||
}
|
||||
|
||||
/// The distinct links a channel's remote members sit behind (minus `except`).
|
||||
fn channel_link_targets(&self, key: &str, except: Option<Uid>) -> Vec<Uid> {
|
||||
let mut set: HashSet<Uid> = HashSet::new();
|
||||
|
|
@ -1673,6 +1695,43 @@ impl Server {
|
|||
self.propagate(&fwd, Some(via));
|
||||
}
|
||||
|
||||
fn link_rename_recv(&mut self, via: Uid, msg: &Message) {
|
||||
// :<source> RENAME <old> <new> [:reason] — a channel renamed elsewhere
|
||||
// (by a client on another server, or by ChanServ). Apply it locally,
|
||||
// notify our members, then forward to the rest of the mesh. A
|
||||
// services-sourced rename is honoured unconditionally: services owns the
|
||||
// registered name and validated the op/founder before sending this.
|
||||
let Some(source) = msg.source.clone() else {
|
||||
return;
|
||||
};
|
||||
let (Some(old), Some(new)) = (msg.params.first().cloned(), msg.params.get(1).cloned())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let reason = msg.params.get(2).cloned().unwrap_or_default();
|
||||
let oldkey = old.to_ascii_lowercase();
|
||||
if !self.channels.contains_key(&oldkey) {
|
||||
return;
|
||||
}
|
||||
// The nick!user@host (or server name) shown to local members as the source.
|
||||
let prefix = self
|
||||
.remote_users
|
||||
.get(&source)
|
||||
.map(|r| r.prefix())
|
||||
.or_else(|| self.servers.get(&source).map(|s| s.name.clone()))
|
||||
.or_else(|| {
|
||||
self.servers
|
||||
.get(source.get(..3).unwrap_or(source.as_str()))
|
||||
.map(|s| s.name.clone())
|
||||
});
|
||||
let Some(prefix) = prefix else {
|
||||
return; // unknown source — don't act on a rename we can't attribute
|
||||
};
|
||||
if self.rename_channel(&oldkey, &new, &prefix, &reason).is_some() {
|
||||
self.propagate_rename(&source, &old, &new, &reason, Some(via));
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a remote user everywhere (channels + registries) and QUIT them to
|
||||
/// any local users who shared a channel.
|
||||
fn drop_remote_user(&mut self, uuid: &str, reason: &str) {
|
||||
|
|
|
|||
|
|
@ -1352,6 +1352,49 @@ mod tests {
|
|||
assert!(bob.iter().any(|l| l.contains("353") && l.contains("@ann")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_moves_channel_and_notifies_by_cap() {
|
||||
let mut s = srv();
|
||||
let arx = add_user(&mut s, 1, "ann"); // op, cap-aware
|
||||
let brx = add_user(&mut s, 2, "bob"); // no cap
|
||||
s.users.get_mut(&1).unwrap().caps.channel_rename = true;
|
||||
s.join(1, "#old", None); // ann creates -> op
|
||||
s.join(2, "#old", None);
|
||||
let _ = arx.try_iter().count(); // drain the join chatter
|
||||
let _ = brx.try_iter().count();
|
||||
|
||||
let key = s.rename_channel("#old", "#new", "ann!u@localhost", "moving");
|
||||
assert_eq!(key.as_deref(), Some("#new"));
|
||||
assert!(!s.channels.contains_key("#old"), "old key gone");
|
||||
assert!(s.channels.contains_key("#new"), "new key present");
|
||||
assert_eq!(s.channels["#new"].members.len(), 2, "membership preserved");
|
||||
assert!(s.users[&1].channels.contains("#new") && !s.users[&1].channels.contains("#old"));
|
||||
assert!(s.users[&2].channels.contains("#new") && !s.users[&2].channels.contains("#old"));
|
||||
|
||||
// The cap holder sees a RENAME; the plain client is walked PART -> JOIN.
|
||||
let ann: Vec<String> = arx.try_iter().collect();
|
||||
assert!(ann.iter().any(|l| l.contains("RENAME #old #new")), "cap client got RENAME: {ann:?}");
|
||||
assert!(!ann.iter().any(|l| l.contains("PART #old")), "cap client not PARTed");
|
||||
let bob: Vec<String> = brx.try_iter().collect();
|
||||
assert!(bob.iter().any(|l| l.contains("PART #old")), "plain client PARTed: {bob:?}");
|
||||
assert!(bob.iter().any(|l| l.contains("JOIN #new")), "plain client re-JOINed");
|
||||
assert!(!bob.iter().any(|l| l.contains("RENAME")), "plain client got no RENAME");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_case_only_keeps_key_and_skips_fallback() {
|
||||
let mut s = srv();
|
||||
let arx = add_user(&mut s, 1, "ann");
|
||||
s.join(1, "#chan", None);
|
||||
let _ = arx.try_iter().count();
|
||||
let key = s.rename_channel("#chan", "#Chan", "ann!u@localhost", "");
|
||||
assert_eq!(key.as_deref(), Some("#chan"), "key unchanged on a case-only rename");
|
||||
assert_eq!(s.channels["#chan"].name, "#Chan", "display casing updated");
|
||||
// Non-cap member: the spec says no PART/JOIN fallback for a case change.
|
||||
let ann: Vec<String> = arx.try_iter().collect();
|
||||
assert!(!ann.iter().any(|l| l.contains("PART")), "no fallback on case-only: {ann:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nick_change_reindexes_and_notifies_channel() {
|
||||
let mut s = srv();
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ pub const SUPPORTED_CAPS: &[&str] = &[
|
|||
"draft/extended-isupport",
|
||||
"reverse.im/filehost",
|
||||
"draft/relaymsg",
|
||||
"draft/channel-rename",
|
||||
"cap-notify",
|
||||
];
|
||||
|
||||
|
|
@ -166,6 +167,7 @@ pub struct Caps {
|
|||
pub ext_isupport: bool, // draft/extended-isupport — ISUPPORT command + batched 005
|
||||
pub filehost: bool, // reverse.im/filehost — knows the file-host extension
|
||||
pub relaymsg: bool, // draft/relaymsg — may use RELAYMSG (bridge relaying)
|
||||
pub channel_rename: bool, // draft/channel-rename — receives RENAME (else PART+JOIN)
|
||||
pub cap_notify: bool,
|
||||
}
|
||||
|
||||
|
|
@ -234,6 +236,7 @@ impl Caps {
|
|||
"draft/extended-isupport" => self.ext_isupport,
|
||||
"reverse.im/filehost" => self.filehost,
|
||||
"draft/relaymsg" => self.relaymsg,
|
||||
"draft/channel-rename" => self.channel_rename,
|
||||
"cap-notify" => self.cap_notify,
|
||||
_ => false,
|
||||
}
|
||||
|
|
@ -269,6 +272,7 @@ impl Caps {
|
|||
"draft/extended-isupport" => &mut self.ext_isupport,
|
||||
"reverse.im/filehost" => &mut self.filehost,
|
||||
"draft/relaymsg" => &mut self.relaymsg,
|
||||
"draft/channel-rename" => &mut self.channel_rename,
|
||||
"cap-notify" => &mut self.cap_notify,
|
||||
_ => return false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -184,6 +184,25 @@ fn read_until<S: Read>(s: &mut S, needle: &str, timeout: Duration) -> bool {
|
|||
String::from_utf8_lossy(&buf).contains(needle)
|
||||
}
|
||||
|
||||
/// Accumulate everything readable within `timeout` into one string, so a test can
|
||||
/// assert on several lines that arrived in a single batch (read_until discards its
|
||||
/// buffer per call, which loses lines sent back-to-back).
|
||||
fn read_collect<S: Read>(s: &mut S, timeout: Duration) -> String {
|
||||
let deadline = Instant::now() + timeout;
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = [0u8; 8192];
|
||||
while Instant::now() < deadline {
|
||||
match s.read(&mut chunk) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => buf.extend_from_slice(&chunk[..n]),
|
||||
Err(ref e)
|
||||
if e.kind() == io::ErrorKind::WouldBlock || e.kind() == io::ErrorKind::TimedOut => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&buf).into_owned()
|
||||
}
|
||||
|
||||
fn register<S: Read + Write>(s: &mut S, nick: &str) {
|
||||
s.write_all(format!("NICK {nick}\r\nUSER {nick} 0 * :{nick}\r\n").as_bytes())
|
||||
.unwrap();
|
||||
|
|
@ -272,6 +291,69 @@ fn tls_in_reactor_handshake_and_cross_transport() {
|
|||
);
|
||||
}
|
||||
|
||||
/// Register `nick` having negotiated capability `cap` (IRCv3 CAP LS/REQ/END).
|
||||
fn register_with_cap<S: Read + Write>(s: &mut S, nick: &str, cap: &str) {
|
||||
s.write_all(
|
||||
format!("CAP LS 302\r\nNICK {nick}\r\nUSER {nick} 0 * :{nick}\r\nCAP REQ :{cap}\r\nCAP END\r\n")
|
||||
.as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
read_until(s, "ACK", Duration::from_secs(5)),
|
||||
"no CAP ACK for {cap}"
|
||||
);
|
||||
assert!(
|
||||
read_until(s, " 001 ", Duration::from_secs(5)),
|
||||
"no 001 welcome for {nick}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_rename_notifies_by_cap_and_needs_ops() {
|
||||
let srv = Server::start(2, false, 0);
|
||||
// alice negotiates draft/channel-rename; bob does not.
|
||||
let mut alice = TcpStream::connect(("127.0.0.1", srv.plain)).unwrap();
|
||||
alice.set_read_timeout(Some(Duration::from_millis(400))).unwrap();
|
||||
register_with_cap(&mut alice, "alice", "draft/channel-rename");
|
||||
let mut bob = srv.plain_client("bob");
|
||||
|
||||
line(&mut alice, "JOIN #old"); // alice creates -> op
|
||||
line(&mut bob, "JOIN #old");
|
||||
read_until(&mut alice, "JOIN #old", Duration::from_secs(2));
|
||||
read_until(&mut bob, "JOIN #old", Duration::from_secs(2));
|
||||
|
||||
// A non-op can't rename.
|
||||
line(&mut bob, "RENAME #old #nope");
|
||||
assert!(
|
||||
read_until(&mut bob, " 482 ", Duration::from_secs(3)),
|
||||
"non-op RENAME should get 482 CHANOPRIVSNEEDED"
|
||||
);
|
||||
|
||||
// The op renames; alice (cap) gets a RENAME line, bob (no cap) is walked PART -> JOIN.
|
||||
line(&mut alice, "RENAME #old #new :moving");
|
||||
assert!(
|
||||
read_until(&mut alice, "RENAME #old #new", Duration::from_secs(3)),
|
||||
"cap client did not receive RENAME"
|
||||
);
|
||||
// bob's PART and JOIN arrive in one batch — collect and check both.
|
||||
let bobseen = read_collect(&mut bob, Duration::from_secs(2));
|
||||
assert!(bobseen.contains("PART #old"), "plain client not PARTed: {bobseen:?}");
|
||||
assert!(bobseen.contains("JOIN #new"), "plain client not re-JOINed: {bobseen:?}");
|
||||
assert!(!bobseen.contains("RENAME"), "plain client should not see RENAME: {bobseen:?}");
|
||||
|
||||
// The channel now answers under the new name (and not the old).
|
||||
line(&mut alice, "PRIVMSG #new :landed");
|
||||
assert!(
|
||||
read_until(&mut bob, "landed", Duration::from_secs(3)),
|
||||
"message to the renamed channel didn't reach members"
|
||||
);
|
||||
line(&mut alice, "NAMES #old");
|
||||
assert!(
|
||||
read_until(&mut alice, " 366 ", Duration::from_secs(3)),
|
||||
"NAMES on the old name should just end (channel is gone)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_rate_limit_drops_connection_churn() {
|
||||
// rate/burst = 5: a rapid burst of 20 connections from one IP must be partly dropped
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue