From c2a7dfa8f708b6ea97792b3015e7813b2e90d29d Mon Sep 17 00:00:00 2001 From: Jean Date: Mon, 20 Jul 2026 20:59:47 +0000 Subject: [PATCH] Add NickServ LOGIN to identify and reclaim your nick in one command --- api/src/lib.rs | 3 + lang/de.json | 4 +- lang/es-ar.json | 4 +- lang/es.json | 4 +- lang/fr.json | 4 +- lang/pt-br.json | 4 +- lang/pt.json | 4 +- modules/nickserv/src/identify.rs | 50 +++++++++------- modules/nickserv/src/lib.rs | 3 + modules/nickserv/src/login.rs | 27 +++++++++ src/engine/register.rs | 99 ++++++++++++++++++++------------ src/engine/tests.rs | 14 +++++ 12 files changed, 158 insertions(+), 62 deletions(-) create mode 100644 modules/nickserv/src/login.rs diff --git a/api/src/lib.rs b/api/src/lib.rs index 38d5835..11df94f 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -243,6 +243,9 @@ pub enum AuthThen { // NickServ IDENTIFY: `uid` logs in, `agent` (NickServ uid) sends the notices, // `name` is what the user typed (for lockout/note_auth), `account` is canonical. Identify { uid: String, agent: String, name: String, account: String }, + // NickServ LOGIN: like Identify, but on success also reclaims `nick` — freeing + // any ghost holding it and moving the caller onto it. + Login { uid: String, agent: String, name: String, account: String, nick: String }, // SASL: finish the exchange for `client`, sourced from `agent`. `password` is // whether this was a password verify (feeds the brute-force throttle) vs a // one-time keycard redemption (which must not touch the password lockout). diff --git a/lang/de.json b/lang/de.json index f3e75d2..d821234 100644 --- a/lang/de.json +++ b/lang/de.json @@ -1366,5 +1366,7 @@ "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Unbekannter AJOIN-Befehl \u0002{other}\u0002. Nutze \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 oder \u0002LIST\u0002.", "Or confirm in one click: {link}": "Oder bestätige mit einem Klick: {link}", "Confirm now": "Jetzt bestätigen", - "… and \u0002{more}\u0002 more; showing the first {cap}.": "… und \u0002{more}\u0002 weitere; die ersten {cap} werden angezeigt." + "… and \u0002{more}\u0002 more; showing the first {cap}.": "… und \u0002{more}\u0002 weitere; die ersten {cap} werden angezeigt.", + "log in and reclaim your nick": "anmelden und deinen Nick zurückholen", + "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Syntax: \u0002LOGIN \u0002\nMeldet dich beim Konto von an und setzt dich auf diesen Nick, wobei jede Sitzung, die ihn belegt, freigegeben wird." } \ No newline at end of file diff --git a/lang/es-ar.json b/lang/es-ar.json index 1bf5076..3e28332 100644 --- a/lang/es-ar.json +++ b/lang/es-ar.json @@ -1366,5 +1366,7 @@ "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconocido \u0002{other}\u0002. Usá \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 o \u0002LIST\u0002.", "Or confirm in one click: {link}": "O confirmá con un clic: {link}", "Confirm now": "Confirmar ahora", - "… and \u0002{more}\u0002 more; showing the first {cap}.": "… y \u0002{more}\u0002 más; mostrando los primeros {cap}." + "… and \u0002{more}\u0002 more; showing the first {cap}.": "… y \u0002{more}\u0002 más; mostrando los primeros {cap}.", + "log in and reclaim your nick": "iniciar sesión y recuperar tu apodo", + "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Sintaxis: \u0002LOGIN \u0002\nInicia sesión en la cuenta de y te mueve a ese apodo, liberando cualquier sesión que ya lo esté usando." } \ No newline at end of file diff --git a/lang/es.json b/lang/es.json index 29e6208..7f41a7b 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1366,5 +1366,7 @@ "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconocido \u0002{other}\u0002. Usa \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 o \u0002LIST\u0002.", "Or confirm in one click: {link}": "O confirma con un clic: {link}", "Confirm now": "Confirmar ahora", - "… and \u0002{more}\u0002 more; showing the first {cap}.": "… y \u0002{more}\u0002 más; mostrando los primeros {cap}." + "… and \u0002{more}\u0002 more; showing the first {cap}.": "… y \u0002{more}\u0002 más; mostrando los primeros {cap}.", + "log in and reclaim your nick": "iniciar sesión y recuperar tu apodo", + "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Sintaxis: \u0002LOGIN \u0002\nInicia sesión en la cuenta de y te mueve a ese apodo, liberando cualquier sesión que ya lo ocupe." } \ No newline at end of file diff --git a/lang/fr.json b/lang/fr.json index 56c1dc2..f4a9156 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -1366,5 +1366,7 @@ "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Commande AJOIN inconnue \u0002{other}\u0002. Utilisez \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 ou \u0002LIST\u0002.", "Or confirm in one click: {link}": "Ou confirmez en un clic : {link}", "Confirm now": "Confirmer maintenant", - "… and \u0002{more}\u0002 more; showing the first {cap}.": "… et \u0002{more}\u0002 de plus ; affichage des {cap} premiers." + "… and \u0002{more}\u0002 more; showing the first {cap}.": "… et \u0002{more}\u0002 de plus ; affichage des {cap} premiers.", + "log in and reclaim your nick": "se connecter et récupérer son pseudo", + "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Syntaxe : \u0002LOGIN \u0002\nVous connecte au compte de et vous place sur ce pseudo, en libérant toute session qui l'occupe déjà." } \ No newline at end of file diff --git a/lang/pt-br.json b/lang/pt-br.json index 498e016..c1c2820 100644 --- a/lang/pt-br.json +++ b/lang/pt-br.json @@ -1366,5 +1366,7 @@ "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconhecido \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 ou \u0002LIST\u0002.", "Or confirm in one click: {link}": "Ou confirme com um clique: {link}", "Confirm now": "Confirmar agora", - "… and \u0002{more}\u0002 more; showing the first {cap}.": "… e mais \u0002{more}\u0002; mostrando os primeiros {cap}." + "… and \u0002{more}\u0002 more; showing the first {cap}.": "… e mais \u0002{more}\u0002; mostrando os primeiros {cap}.", + "log in and reclaim your nick": "entrar e recuperar seu apelido", + "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Sintaxe: \u0002LOGIN \u0002\nEntra na conta de e move você para esse apelido, liberando qualquer sessão que já o ocupe." } \ No newline at end of file diff --git a/lang/pt.json b/lang/pt.json index 191016b..0944a02 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -1366,5 +1366,7 @@ "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconhecido \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 ou \u0002LIST\u0002.", "Or confirm in one click: {link}": "Ou confirme com um clique: {link}", "Confirm now": "Confirmar agora", - "… and \u0002{more}\u0002 more; showing the first {cap}.": "… e mais \u0002{more}\u0002; a mostrar os primeiros {cap}." + "… and \u0002{more}\u0002 more; showing the first {cap}.": "… e mais \u0002{more}\u0002; a mostrar os primeiros {cap}.", + "log in and reclaim your nick": "iniciar sessão e recuperar a sua alcunha", + "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Sintaxe: \u0002LOGIN \u0002\nInicia sessão na conta de e move-o para essa alcunha, libertando qualquer sessão que já a ocupe." } \ No newline at end of file diff --git a/modules/nickserv/src/identify.rs b/modules/nickserv/src/identify.rs index 59870dc..977e645 100644 --- a/modules/nickserv/src/identify.rs +++ b/modules/nickserv/src/identify.rs @@ -13,10 +13,30 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: return; } }; + let Some((account, verifier)) = precheck(me, from, "IDENTIFY", account_name, ctx, db) else { + return; + }; + // Already identified to this account: skip the (wasted) verify. + if from.account == Some(account.as_str()) { + ctx.notice(me, from.uid, t!(ctx, "You're already identified as \x02{account}\x02.", account = account)); + return; + } + ctx.defer_authenticate( + verifier, + password, + AuthThen::Identify { uid: from.uid.to_string(), agent: me.to_string(), name: account_name.to_string(), account }, + ); +} + +// Shared validation for the password-login commands (IDENTIFY, LOGIN): the account +// must exist, not be suspended, not be throttled, and have a password verifier. +// Returns the (canonical account, verifier) for the deferred verify, or None after +// emitting the failure to `from`. `cmd` names the command in the FAIL/audit lines. +pub(crate) fn precheck(me: &str, from: &Sender, cmd: &str, account_name: &str, ctx: &mut ServiceCtx, db: &mut dyn Store) -> Option<(String, String)> { // Distinguish an unregistered account from a wrong password. if !db.exists(account_name) { - ctx.fail(me, from.uid, "IDENTIFY", "ACCOUNT_NOT_REGISTERED", t!(ctx, "\x02{account_name}\x02 isn't registered.", account_name = account_name)); - return; + ctx.fail(me, from.uid, cmd, "ACCOUNT_NOT_REGISTERED", t!(ctx, "\x02{account_name}\x02 isn't registered.", account_name = account_name)); + return None; } // A suspended account can't be logged into (checked before the password so it // doesn't reveal whether the password was right). Tell them who, when and why, @@ -34,15 +54,15 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: msg.push_str(&t!(ctx, " The suspension is due to lift on {when}.", when = human_time(exp))); } msg.push_str(&t!(ctx, " If you think this is a mistake, please contact the network staff.")); - ctx.fail(me, from.uid, "IDENTIFY", "ACCOUNT_SUSPENDED", msg); + ctx.fail(me, from.uid, cmd, "ACCOUNT_SUSPENDED", msg); } - return; + return None; } } // Refuse while throttled, so a password can't be brute-forced. if let Some(secs) = db.auth_lockout(account_name) { - ctx.fail(me, from.uid, "IDENTIFY", "RATE_LIMITED", t!(ctx, "Too many failed attempts. Please wait {secs}s and try again.", secs = secs)); - return; + ctx.fail(me, from.uid, cmd, "RATE_LIMITED", t!(ctx, "Too many failed attempts. Please wait {secs}s and try again.", secs = secs)); + return None; } // Fetch the verifier cheaply and hand the (~1s) PBKDF2 verify to the engine to // run off the reactor — never verify under the engine lock. The login finish @@ -54,20 +74,10 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: // blind spot the deferred wrong-password path doesn't have. db.note_auth(account_name, false); ctx.count("nickserv.identify_fail"); - ctx.auth_report(false, Some(account_name), "NickServ IDENTIFY", from.uid, Some("no password set")); - ctx.fail(me, from.uid, "IDENTIFY", "INVALID_CREDENTIALS", "Invalid password. Please try again."); - } - Some((account, verifier)) => { - // Already identified to this account: skip the (wasted) verify. - if from.account == Some(account.as_str()) { - ctx.notice(me, from.uid, t!(ctx, "You're already identified as \x02{account}\x02.", account = account)); - return; - } - ctx.defer_authenticate( - verifier, - password, - AuthThen::Identify { uid: from.uid.to_string(), agent: me.to_string(), name: account_name.to_string(), account }, - ); + ctx.auth_report(false, Some(account_name), &format!("NickServ {cmd}"), from.uid, Some("no password set")); + ctx.fail(me, from.uid, cmd, "INVALID_CREDENTIALS", "Invalid password. Please try again."); + None } + Some((account, verifier)) => Some((account, verifier)), } } diff --git a/modules/nickserv/src/lib.rs b/modules/nickserv/src/lib.rs index 75c3ad8..fc79103 100644 --- a/modules/nickserv/src/lib.rs +++ b/modules/nickserv/src/lib.rs @@ -6,6 +6,7 @@ use echo_api::NetView; mod register; #[path = "identify.rs"] mod identify; +mod login; #[path = "logout.rs"] mod logout; #[path = "cert.rs"] @@ -51,6 +52,7 @@ const BLURB: &str = "NickServ looks after your nickname and account: register it const TOPICS: &[HelpEntry] = &[ HelpEntry { cmd: "REGISTER", summary: "register your nick as an account", detail: "Syntax: \x02REGISTER [email]\x02\nRegisters your current nick as an account. If an email is given and confirmation is on, you get a code to \x02CONFIRM\x02." }, HelpEntry { cmd: "IDENTIFY", summary: "log in to your account", detail: "Syntax: \x02IDENTIFY [account] \x02\nLogs you in. Also \x02ID\x02." }, + HelpEntry { cmd: "LOGIN", summary: "log in and reclaim your nick", detail: "Syntax: \x02LOGIN \x02\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it." }, HelpEntry { cmd: "LOGOUT", summary: "log out to a guest nick", detail: "Syntax: \x02LOGOUT\x02\nLogs you out and moves you to a guest nick. Also \x02LOGOFF\x02." }, HelpEntry { cmd: "INFO", summary: "show account information", detail: "Syntax: \x02INFO [account]\x02\nShows account information. The email is shown only to the owner." }, HelpEntry { cmd: "ALIST", summary: "list channels you have access on", detail: "Syntax: \x02ALIST\x02\nLists the channels you hold access on." }, @@ -112,6 +114,7 @@ impl Service for NickServ { match cmd.as_deref() { Some("REGISTER") => register::handle(me, from, args, ctx, db), Some("IDENTIFY") | Some("ID") => identify::handle(me, from, args, ctx, db), + Some("LOGIN") => login::handle(me, from, args, ctx, db), Some("LOGOUT") | Some("LOGOFF") => logout::handle(me, &self.guest_nick, &mut self.guest_seq, from, ctx, net, db), Some("CERT") => cert::handle(me, from, args, ctx, db), Some("INFO") => info::handle(me, from, args, ctx, net, db), diff --git a/modules/nickserv/src/login.rs b/modules/nickserv/src/login.rs new file mode 100644 index 0000000..31aa5e3 --- /dev/null +++ b/modules/nickserv/src/login.rs @@ -0,0 +1,27 @@ +use echo_api::{AuthThen, Sender, ServiceCtx, Store}; + +use super::identify; + +// LOGIN : identify to the account owning and, on success, +// reclaim the nick — freeing any ghost holding it and moving you onto it. A +// one-shot IDENTIFY + RECOVER for someone who connected under a guest nick. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + let (Some(&nick), Some(&password)) = (args.get(1), args.get(2)) else { + ctx.notice(me, from.uid, "Syntax: LOGIN "); + return; + }; + let Some((account, verifier)) = identify::precheck(me, from, "LOGIN", nick, ctx, db) else { + return; + }; + ctx.defer_authenticate( + verifier, + password, + AuthThen::Login { + uid: from.uid.to_string(), + agent: me.to_string(), + name: nick.to_string(), + account, + nick: nick.to_string(), + }, + ); +} diff --git a/src/engine/register.rs b/src/engine/register.rs index e24d849..60f38b1 100644 --- a/src/engine/register.rs +++ b/src/engine/register.rs @@ -371,43 +371,13 @@ impl Engine { /// AJOIN, vhost, memo notice) stay identical. pub fn complete_authenticate(&mut self, ok: bool, then: AuthThen) -> Vec { let actions = match then { - AuthThen::Identify { uid, agent, name, account } => { - self.db.note_auth(&name, ok); - let lang = self.lang_for_account(&account); - let mut ctx = ServiceCtx { lang: lang.clone(), ..Default::default() }; - if !ok { - ctx.count("nickserv.identify_fail"); - ctx.fail(&agent, &uid, "IDENTIFY", "INVALID_CREDENTIALS", "Invalid password. Please try again."); - } else { - ctx.login(&uid, &account); - ctx.count("nickserv.identify"); - ctx.notice(&agent, &uid, echo_api::render(&lang, "You're now identified as \x02{account}\x02. Welcome back!", &[("account", account.clone())])); - for entry in self.db.ajoin_list(&account) { - ctx.force_join(&uid, &entry.channel, &entry.key); - } - let now = self.now_secs(); - let vhost = self.db.account(&account).and_then(|a| { - a.vhost.as_ref().filter(|v| v.expires.is_none_or(|e| e > now)).map(|v| v.host.clone()) - }); - if let Some(host) = vhost { - ctx.apply_vhost(&uid, &host); - } - let unread = self.db.unread_memos(&account); - if unread > 0 && self.db.memo_notify_on(&account) { - ctx.notice(&agent, &uid, echo_api::render_plural(&lang, unread as u64, "You have \x02{unread}\x02 new memo. Read it with \x02/msg MemoServ READ NEW\x02.", "You have \x02{unread}\x02 new memos. Read them with \x02/msg MemoServ READ NEW\x02.", &[("unread", unread.to_string())])); - } + AuthThen::Identify { uid, agent, name, account } => self.finish_identify(ok, uid, agent, name, account), + AuthThen::Login { uid, agent, name, account, nick } => { + let mut out = self.finish_identify(ok, uid.clone(), agent, name, account); + if ok { + out.extend(self.recover_nick(&uid, &nick)); } - for key in std::mem::take(&mut ctx.stats) { - self.bump(&key); - } - let feed = if ok { - self.auth_report(true, Some(&account), "NickServ IDENTIFY", &uid, None) - } else { - self.auth_report(false, Some(&name), "NickServ IDENTIFY", &uid, Some("bad password")) - }; - let mut actions = ctx.actions; - actions.extend(feed); - actions + out } AuthThen::Sasl { agent, client, account, password } => { // Feed the same brute-force throttle IDENTIFY uses (success clears it, @@ -432,4 +402,61 @@ impl Engine { self.track_accounts(&actions); actions } + + // The login side-effects shared by IDENTIFY and LOGIN: throttle bookkeeping, + // the welcome + auto-join + vhost + waiting-memo notice, and the auth-feed line. + fn finish_identify(&mut self, ok: bool, uid: String, agent: String, name: String, account: String) -> Vec { + self.db.note_auth(&name, ok); + let lang = self.lang_for_account(&account); + let mut ctx = ServiceCtx { lang: lang.clone(), ..Default::default() }; + if !ok { + ctx.count("nickserv.identify_fail"); + ctx.fail(&agent, &uid, "IDENTIFY", "INVALID_CREDENTIALS", "Invalid password. Please try again."); + } else { + ctx.login(&uid, &account); + ctx.count("nickserv.identify"); + ctx.notice(&agent, &uid, echo_api::render(&lang, "You're now identified as \x02{account}\x02. Welcome back!", &[("account", account.clone())])); + for entry in self.db.ajoin_list(&account) { + ctx.force_join(&uid, &entry.channel, &entry.key); + } + let now = self.now_secs(); + let vhost = self.db.account(&account).and_then(|a| { + a.vhost.as_ref().filter(|v| v.expires.is_none_or(|e| e > now)).map(|v| v.host.clone()) + }); + if let Some(host) = vhost { + ctx.apply_vhost(&uid, &host); + } + let unread = self.db.unread_memos(&account); + if unread > 0 && self.db.memo_notify_on(&account) { + ctx.notice(&agent, &uid, echo_api::render_plural(&lang, unread as u64, "You have \x02{unread}\x02 new memo. Read it with \x02/msg MemoServ READ NEW\x02.", "You have \x02{unread}\x02 new memos. Read them with \x02/msg MemoServ READ NEW\x02.", &[("unread", unread.to_string())])); + } + } + for key in std::mem::take(&mut ctx.stats) { + self.bump(&key); + } + let feed = if ok { + self.auth_report(true, Some(&account), "NickServ IDENTIFY", &uid, None) + } else { + self.auth_report(false, Some(&name), "NickServ IDENTIFY", &uid, Some("bad password")) + }; + let mut actions = ctx.actions; + actions.extend(feed); + actions + } + + // Reclaim `nick` for `uid` after a LOGIN: rename any other session off it (to a + // guest nick), then move the caller onto it. + fn recover_nick(&mut self, uid: &str, nick: &str) -> Vec { + let mut out = Vec::new(); + if let Some(ghost) = self.network.uid_by_nick(nick).map(str::to_string) { + if ghost != uid { + let guest = echo_api::next_guest_nick(&self.guest_nick, &mut self.enforce_seq, &self.network, &self.db); + out.push(NetAction::ForceNick { uid: ghost, nick: guest }); + } + } + if self.network.nick_of(uid) != Some(nick) { + out.push(NetAction::ForceNick { uid: uid.to_string(), nick: nick.to_string() }); + } + out + } } diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 731cc9c..69c984d 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -854,6 +854,20 @@ assert_eq!(e.network.account_of("000AAAAAB"), Some("foo"), "and is authoritative in echo's own map"); } + // NS LOGIN identifies AND reclaims the nick: after the deferred verify succeeds, + // any ghost on the target nick is renamed off and the caller is moved onto it. + #[test] + fn login_recovers_the_nick_after_the_deferred_verify() { + let mut e = engine_with("login", "alice", "sesame"); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "Guest7".into(), host: "h".into(), ip: "0.0.0.0".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() }); + let then = echo_api::AuthThen::Login { uid: "000AAAAAB".into(), agent: "42SAAAAAA".into(), name: "alice".into(), account: "alice".into(), nick: "alice".into() }; + let out = e.complete_authenticate(true, then); + assert_eq!(e.network.account_of("000AAAAAB"), Some("alice"), "caller is identified"); + assert!(out.iter().any(|a| matches!(a, NetAction::ForceNick { uid, nick } if uid == "000AAAAAC" && nick != "alice")), "ghost renamed off the nick: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::ForceNick { uid, nick } if uid == "000AAAAAB" && nick == "alice")), "caller takes the nick: {out:?}"); + } + // A suspension that arrives by gossip must end local sessions on that account, // just as a local SUSPEND does — the account and its channels stay put. #[test]