OperServ: NEWS — logon and oper announcements
NEWS ADD <LOGON|OPER> <text> / DEL <LOGON|OPER> <number> / LIST. Logon news is shown to every user as they connect; oper news is shown to an operator when they log in. Admin-only to manage. Each item carries a stable id assigned at add time and embedded in the log, so deletion is order- independent under replay (DEL by number resolves to that id). Enabling groundwork: the replicated network-wide lists (bans, now news) are consolidated into one NetData struct threaded through apply() as a single parameter — this replaces the akill parameter rather than adding one, so the fold stays within its argument budget as the lists grow.
This commit is contained in:
parent
51d4ebf789
commit
e45eab2cd6
6 changed files with 281 additions and 26 deletions
123
src/engine/db.rs
123
src/engine/db.rs
|
|
@ -30,7 +30,7 @@ use super::scram::{self, Hash};
|
|||
// fedserv-api SDK crate; re-exported so the engine keeps naming them locally and
|
||||
// modules importing `crate::engine::db::{ChanError, ...}` are unaffected.
|
||||
pub use fedserv_api::{
|
||||
AccountView, AjoinView, AkillView, BotView, IgnoreView, MemoView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView,
|
||||
AccountView, AjoinView, AkillView, BotView, IgnoreView, MemoView, NewsView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -185,6 +185,9 @@ pub enum Event {
|
|||
// A staff note set or cleared (OperServ INFO).
|
||||
AccountOperNoteSet { account: String, note: Option<String> },
|
||||
ChannelOperNoteSet { channel: String, note: Option<String> },
|
||||
// News items (OperServ NEWS). The stable `id` makes deletion order-independent.
|
||||
NewsAdded { id: u64, kind: String, text: String, setter: String, ts: u64 },
|
||||
NewsDeleted { id: u64 },
|
||||
// Network bans (OperServ AKILL / SQLINE). Global: a ban covers the whole
|
||||
// network, so every node holds the list and re-applies it at burst. `kind`
|
||||
// is the ircd X-line type and defaults to "G" for records predating it.
|
||||
|
|
@ -243,7 +246,9 @@ impl Event {
|
|||
| Event::AccountExpiryWarned { .. }
|
||||
| Event::AccountOperNoteSet { .. }
|
||||
| Event::AkillAdded { .. }
|
||||
| Event::AkillRemoved { .. } => Scope::Global,
|
||||
| Event::AkillRemoved { .. }
|
||||
| Event::NewsAdded { .. }
|
||||
| Event::NewsDeleted { .. } => Scope::Global,
|
||||
Event::ChannelRegistered { .. }
|
||||
| Event::ChannelDropped { .. }
|
||||
| Event::ChannelMlock { .. }
|
||||
|
|
@ -333,6 +338,29 @@ pub struct Ignore {
|
|||
pub expires: Option<u64>,
|
||||
}
|
||||
|
||||
// A news item (OperServ NEWS): a `kind` ("logon" shown to everyone on connect,
|
||||
// "oper" shown to operators on login), the text, who set it, and when. Carries a
|
||||
// stable id (assigned at add time, embedded in the log) so deletion is order-
|
||||
// independent under replay.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct News {
|
||||
pub id: u64,
|
||||
pub kind: String,
|
||||
pub text: String,
|
||||
pub setter: String,
|
||||
pub ts: u64,
|
||||
}
|
||||
|
||||
// The network-wide lists that aren't accounts/channels/grouped/bots: the network
|
||||
// bans and the news items. Bundled so `apply` threads one parameter for them
|
||||
// (and stays within its argument budget as more are added).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct NetData {
|
||||
pub akills: Vec<Akill>,
|
||||
pub news: Vec<News>,
|
||||
pub news_seq: u64,
|
||||
}
|
||||
|
||||
// A memo left for an account (MemoServ).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Memo {
|
||||
|
|
@ -900,8 +928,8 @@ pub struct Db {
|
|||
// HostServ node config: the self-serve offer menu, the forbidden-pattern
|
||||
// blocklist, and the auto-vhost template.
|
||||
host_cfg: HostConfig,
|
||||
// Network bans (OperServ AKILL), in insertion order.
|
||||
akills: Vec<Akill>,
|
||||
// Network-wide replicated lists: bans (AKILL/SQLINE) and news.
|
||||
net: NetData,
|
||||
// Services ignores (OperServ IGNORE), node-local and in-memory.
|
||||
ignores: Vec<Ignore>,
|
||||
}
|
||||
|
|
@ -954,12 +982,12 @@ impl Db {
|
|||
let mut grouped = HashMap::new();
|
||||
let mut bots = HashMap::new();
|
||||
let mut host_cfg = HostConfig::default();
|
||||
let mut akills = Vec::new();
|
||||
let mut net = NetData::default();
|
||||
for event in events {
|
||||
apply(&mut accounts, &mut channels, &mut grouped, &mut bots, &mut host_cfg, &mut akills, event);
|
||||
apply(&mut accounts, &mut channels, &mut grouped, &mut bots, &mut host_cfg, &mut net, event);
|
||||
}
|
||||
tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded");
|
||||
Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), bots, host_cfg, akills, ignores: Vec::new() }
|
||||
Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new() }
|
||||
}
|
||||
|
||||
/// Fold an entry authored by another node into the store — the services-side
|
||||
|
|
@ -975,7 +1003,7 @@ impl Db {
|
|||
_ => None,
|
||||
};
|
||||
if let Some(event) = self.log.ingest(entry)? {
|
||||
apply(&mut self.accounts, &mut self.channels, &mut self.grouped, &mut self.bots, &mut self.host_cfg, &mut self.akills, event);
|
||||
apply(&mut self.accounts, &mut self.channels, &mut self.grouped, &mut self.bots, &mut self.host_cfg, &mut self.net, event);
|
||||
}
|
||||
if let Some((name, prev_home)) = watched {
|
||||
match (prev_home, self.account(&name).map(|c| c.home.clone())) {
|
||||
|
|
@ -1050,9 +1078,12 @@ impl Db {
|
|||
}
|
||||
// Compaction is a good moment to forget akills that have already expired.
|
||||
let now = now();
|
||||
for a in self.akills.iter().filter(|a| a.expires.is_none_or(|e| e > now)) {
|
||||
for a in self.net.akills.iter().filter(|a| a.expires.is_none_or(|e| e > now)) {
|
||||
snapshot.push(Event::AkillAdded { kind: a.kind.clone(), mask: a.mask.clone(), setter: a.setter.clone(), reason: a.reason.clone(), ts: a.ts, expires: a.expires });
|
||||
}
|
||||
for n in &self.net.news {
|
||||
snapshot.push(Event::NewsAdded { id: n.id, kind: n.kind.clone(), text: n.text.clone(), setter: n.setter.clone(), ts: n.ts });
|
||||
}
|
||||
self.log.compact(snapshot)?;
|
||||
tracing::info!(before, after = self.log.len(), "compacted event log");
|
||||
Ok(())
|
||||
|
|
@ -1822,37 +1853,67 @@ impl Db {
|
|||
/// Add (or refresh) a `kind` network ban. Returns whether it was newly added.
|
||||
pub fn akill_add(&mut self, kind: &str, mask: &str, setter: &str, reason: &str, expires: Option<u64>) -> Result<bool, RegError> {
|
||||
let same = |a: &Akill| a.kind == kind && a.mask.eq_ignore_ascii_case(mask);
|
||||
let fresh = !self.akills.iter().any(|a| same(a) && a.expires.is_none_or(|e| e > now()));
|
||||
let fresh = !self.net.akills.iter().any(|a| same(a) && a.expires.is_none_or(|e| e > now()));
|
||||
self.log
|
||||
.append(Event::AkillAdded { kind: kind.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now(), expires })
|
||||
.map_err(|_| RegError::Internal)?;
|
||||
self.akills.retain(|a| !same(a));
|
||||
self.akills.push(Akill { kind: kind.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now(), expires });
|
||||
self.net.akills.retain(|a| !same(a));
|
||||
self.net.akills.push(Akill { kind: kind.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now(), expires });
|
||||
Ok(fresh)
|
||||
}
|
||||
|
||||
/// Lift a `kind` network ban. Returns whether a live one was removed.
|
||||
pub fn akill_del(&mut self, kind: &str, mask: &str) -> Result<bool, RegError> {
|
||||
let same = |a: &Akill| a.kind == kind && a.mask.eq_ignore_ascii_case(mask);
|
||||
let existed = self.akills.iter().any(|a| same(a) && a.expires.is_none_or(|e| e > now()));
|
||||
let existed = self.net.akills.iter().any(|a| same(a) && a.expires.is_none_or(|e| e > now()));
|
||||
if !existed {
|
||||
return Ok(false);
|
||||
}
|
||||
self.log.append(Event::AkillRemoved { kind: kind.to_string(), mask: mask.to_string() }).map_err(|_| RegError::Internal)?;
|
||||
self.akills.retain(|a| !same(a));
|
||||
self.net.akills.retain(|a| !same(a));
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The live network bans (expired ones hidden lazily), oldest first.
|
||||
pub fn akills(&self) -> Vec<AkillView> {
|
||||
let now = now();
|
||||
self.akills
|
||||
self.net.akills
|
||||
.iter()
|
||||
.filter(|a| a.expires.is_none_or(|e| e > now))
|
||||
.map(|a| AkillView { kind: a.kind.clone(), mask: a.mask.clone(), setter: a.setter.clone(), reason: a.reason.clone(), ts: a.ts, expires: a.expires })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Add a news item of `kind` ("logon"/"oper"). Returns its stable id.
|
||||
pub fn news_add(&mut self, kind: &str, text: &str, setter: &str) -> u64 {
|
||||
let id = self.net.news_seq;
|
||||
let ts = now();
|
||||
let _ = self.log.append(Event::NewsAdded { id, kind: kind.to_string(), text: text.to_string(), setter: setter.to_string(), ts });
|
||||
self.net.news_seq = id + 1;
|
||||
self.net.news.push(News { id, kind: kind.to_string(), text: text.to_string(), setter: setter.to_string(), ts });
|
||||
id
|
||||
}
|
||||
|
||||
/// Delete a news item by id. Returns whether one existed.
|
||||
pub fn news_del(&mut self, id: u64) -> bool {
|
||||
let existed = self.net.news.iter().any(|n| n.id == id);
|
||||
if existed {
|
||||
let _ = self.log.append(Event::NewsDeleted { id });
|
||||
self.net.news.retain(|n| n.id != id);
|
||||
}
|
||||
existed
|
||||
}
|
||||
|
||||
/// The news items of `kind`, oldest first.
|
||||
pub fn news(&self, kind: &str) -> Vec<NewsView> {
|
||||
self.net
|
||||
.news
|
||||
.iter()
|
||||
.filter(|n| n.kind == kind)
|
||||
.map(|n| NewsView { id: n.id, text: n.text.clone(), setter: n.setter.clone(), ts: n.ts })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Add a services ignore, replacing any existing entry for the same mask.
|
||||
pub fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option<u64>) {
|
||||
self.ignores.retain(|i| !i.mask.eq_ignore_ascii_case(mask));
|
||||
|
|
@ -2535,7 +2596,7 @@ fn owns_over(held: &Account, claim: &Account) -> bool {
|
|||
|
||||
// Fold one event into the store. Shared by log replay (`open`) and gossip
|
||||
// ingest, so both routes reconstruct identical state.
|
||||
fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String, ChannelInfo>, grouped: &mut HashMap<String, String>, bots: &mut HashMap<String, Bot>, host_cfg: &mut HostConfig, akills: &mut Vec<Akill>, event: Event) {
|
||||
fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String, ChannelInfo>, grouped: &mut HashMap<String, String>, bots: &mut HashMap<String, Bot>, host_cfg: &mut HostConfig, net: &mut NetData, event: Event) {
|
||||
match event {
|
||||
Event::AccountRegistered(a) => {
|
||||
// Resolve a concurrent registration of the same name deterministically:
|
||||
|
|
@ -2796,11 +2857,11 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
|
|||
Event::AkillAdded { kind, mask, setter, reason, ts, expires } => {
|
||||
// Keyed by (kind, mask), case-insensitive: a re-add refreshes in
|
||||
// place, so replaying over a snapshot stays idempotent.
|
||||
akills.retain(|a| !(a.kind == kind && a.mask.eq_ignore_ascii_case(&mask)));
|
||||
akills.push(Akill { kind, mask, setter, reason, ts, expires });
|
||||
net.akills.retain(|a| !(a.kind == kind && a.mask.eq_ignore_ascii_case(&mask)));
|
||||
net.akills.push(Akill { kind, mask, setter, reason, ts, expires });
|
||||
}
|
||||
Event::AkillRemoved { kind, mask } => {
|
||||
akills.retain(|a| !(a.kind == kind && a.mask.eq_ignore_ascii_case(&mask)));
|
||||
net.akills.retain(|a| !(a.kind == kind && a.mask.eq_ignore_ascii_case(&mask)));
|
||||
}
|
||||
Event::AccountExpiryWarned { account } => {
|
||||
if let Some(a) = accounts.get_mut(&key(&account)) {
|
||||
|
|
@ -2822,6 +2883,15 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
|
|||
c.oper_note = note;
|
||||
}
|
||||
}
|
||||
Event::NewsAdded { id, kind, text, setter, ts } => {
|
||||
net.news_seq = net.news_seq.max(id + 1);
|
||||
if !net.news.iter().any(|n| n.id == id) {
|
||||
net.news.push(News { id, kind, text, setter, ts });
|
||||
}
|
||||
}
|
||||
Event::NewsDeleted { id } => {
|
||||
net.news.retain(|n| n.id != id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3084,6 +3154,15 @@ impl Store for Db {
|
|||
fn channel_note(&self, channel: &str) -> Option<String> {
|
||||
Db::channel_note(self, channel)
|
||||
}
|
||||
fn news_add(&mut self, kind: &str, text: &str, setter: &str) -> u64 {
|
||||
Db::news_add(self, kind, text, setter)
|
||||
}
|
||||
fn news_del(&mut self, id: u64) -> bool {
|
||||
Db::news_del(self, id)
|
||||
}
|
||||
fn news(&self, kind: &str) -> Vec<NewsView> {
|
||||
Db::news(self, kind)
|
||||
}
|
||||
fn register_channel(&mut self, name: &str, founder: &str) -> Result<(), ChanError> {
|
||||
Db::register_channel(self, name, founder)
|
||||
}
|
||||
|
|
@ -3287,9 +3366,9 @@ mod tests {
|
|||
ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, vhost_request: None, last_seen: ts, noexpire: false, expiry_warned: false, oper_note: None,
|
||||
};
|
||||
let converge = |first: &Account, second: &Account| {
|
||||
let (mut acc, mut ch, mut gr, mut bo, mut hc, mut ak) = (HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HostConfig::default(), Vec::new());
|
||||
apply(&mut acc, &mut ch, &mut gr, &mut bo, &mut hc, &mut ak, Event::AccountRegistered(Box::new(first.clone())));
|
||||
apply(&mut acc, &mut ch, &mut gr, &mut bo, &mut hc, &mut ak, Event::AccountRegistered(Box::new(second.clone())));
|
||||
let (mut acc, mut ch, mut gr, mut bo, mut hc, mut nd) = (HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HostConfig::default(), NetData::default());
|
||||
apply(&mut acc, &mut ch, &mut gr, &mut bo, &mut hc, &mut nd, Event::AccountRegistered(Box::new(first.clone())));
|
||||
apply(&mut acc, &mut ch, &mut gr, &mut bo, &mut hc, &mut nd, Event::AccountRegistered(Box::new(second.clone())));
|
||||
acc["alice"].password_hash.clone()
|
||||
};
|
||||
// Earlier registration wins, regardless of which claim applies first.
|
||||
|
|
|
|||
|
|
@ -596,6 +596,19 @@ impl Engine {
|
|||
}
|
||||
}
|
||||
|
||||
// The news items of `kind` as server-sourced notices to `uid`, each tagged
|
||||
// with `label` so it reads as an announcement. Empty if we have no SID yet.
|
||||
fn news_notices(&self, kind: &str, label: &str, uid: &str) -> Vec<NetAction> {
|
||||
if self.sid.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
self.db
|
||||
.news(kind)
|
||||
.into_iter()
|
||||
.map(|n| NetAction::Notice { from: self.sid.clone(), to: uid.to_string(), text: format!("[\x02{label}\x02] {}", n.text) })
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Announce a line to the staff audit channel, if one is configured, sourced
|
||||
// from the services server. Used for actions that aren't tied to a command.
|
||||
fn audit(&self, text: String) {
|
||||
|
|
@ -700,8 +713,9 @@ impl Engine {
|
|||
let evout = match event {
|
||||
NetEvent::Ping { token, from } => vec![NetAction::Pong { token, from }],
|
||||
NetEvent::UserConnect { uid, nick, host } => {
|
||||
self.network.user_connect(uid, nick, host);
|
||||
Vec::new()
|
||||
self.network.user_connect(uid.clone(), nick, host);
|
||||
// Greet the arriving user with the logon news.
|
||||
self.news_notices("logon", "News", &uid)
|
||||
}
|
||||
NetEvent::NickChange { uid, nick } => {
|
||||
self.network.user_nick_change(&uid, nick);
|
||||
|
|
@ -888,6 +902,12 @@ impl Engine {
|
|||
self.network.set_account(target, value);
|
||||
// A login is activity: keep the account from expiring.
|
||||
self.db.mark_account_seen(value, self.now_secs());
|
||||
// An operator logging in is shown the staff (oper) news.
|
||||
if self.oper_privs(value).any() {
|
||||
for note in self.news_notices("oper", "Staff News", target) {
|
||||
self.emit_irc(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1582,6 +1602,8 @@ fn audit_summary(event: &db::Event) -> Option<String> {
|
|||
Some(_) => format!("set a staff note on \x02{channel}\x02"),
|
||||
None => format!("cleared the staff note on \x02{channel}\x02"),
|
||||
},
|
||||
NewsAdded { kind, .. } => format!("added a \x02{kind}\x02 news item"),
|
||||
NewsDeleted { .. } => "removed a news item".to_string(),
|
||||
AccountNoExpire { account, on } => {
|
||||
let verb = if *on { "pinned" } else { "unpinned" };
|
||||
format!("{verb} account \x02{account}\x02 against expiry")
|
||||
|
|
@ -4024,6 +4046,70 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
// OperServ NEWS: logon news greets everyone on connect, oper news greets an
|
||||
// operator on login; ADD/DEL/LIST are admin-only.
|
||||
#[test]
|
||||
fn operserv_news_logon_and_oper() {
|
||||
use fedserv_operserv::OperServ;
|
||||
let path = std::env::temp_dir().join("fedserv-osnews.jsonl");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut db = Db::open(&path, "42S");
|
||||
db.scram_iterations = 4096;
|
||||
db.register("staff", "password1", None).unwrap();
|
||||
db.register("boss", "password1", None).unwrap();
|
||||
db.register("plain", "password1", None).unwrap();
|
||||
let mut e = Engine::new(
|
||||
vec![
|
||||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||||
Box::new(OperServ { uid: "42SAAAAAH".into() }),
|
||||
],
|
||||
db,
|
||||
);
|
||||
e.set_sid("42S".into());
|
||||
let mut opers = std::collections::HashMap::new();
|
||||
opers.insert("staff".to_string(), Privs::default().with(fedserv_api::Priv::Admin));
|
||||
opers.insert("boss".to_string(), Privs::default().with(fedserv_api::Priv::Admin));
|
||||
e.set_opers(opers);
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
e.set_irc_out(tx);
|
||||
let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() });
|
||||
let has = |out: &[NetAction], needle: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle)));
|
||||
|
||||
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into() });
|
||||
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||||
|
||||
os(&mut e, "000AAAAAS", "NEWS ADD LOGON Welcome to the network");
|
||||
os(&mut e, "000AAAAAS", "NEWS ADD OPER Staff meeting at 5");
|
||||
|
||||
// A new user is greeted with the logon news on connect, not the oper news.
|
||||
let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAP".into(), nick: "plain".into(), host: "h".into() });
|
||||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { to, text, .. } if to == "000AAAAAP" && text.contains("Welcome to the network"))), "logon news on connect: {out:?}");
|
||||
assert!(!has(&out, "Staff meeting"), "a plain user doesn't get oper news");
|
||||
|
||||
// An operator logging in is shown the oper news (over the outbound path).
|
||||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() });
|
||||
while rx.try_recv().is_ok() {} // drain the connect's logon news
|
||||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||||
let mut oper_news = false;
|
||||
while let Ok(a) = rx.try_recv() {
|
||||
if let NetAction::Notice { to, text, .. } = a {
|
||||
if to == "000AAAAAB" && text.contains("Staff meeting at 5") {
|
||||
oper_news = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(oper_news, "oper news shown to an operator on login");
|
||||
|
||||
// LIST shows both; DEL removes the logon item so a later connect is quiet.
|
||||
assert!(has(&os(&mut e, "000AAAAAS", "NEWS LIST"), "Welcome to the network"), "list shows logon");
|
||||
os(&mut e, "000AAAAAS", "NEWS DEL LOGON 1");
|
||||
let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAQ".into(), nick: "late".into(), host: "h".into() });
|
||||
assert!(!has(&out, "Welcome to the network"), "logon news gone after DEL");
|
||||
|
||||
// A non-admin (the unidentified plain user) can't manage news.
|
||||
assert!(has(&os(&mut e, "000AAAAAP", "NEWS ADD LOGON sneaky"), "Access denied"), "non-admin refused");
|
||||
}
|
||||
|
||||
// OperServ INFO staff notes: an admin annotates an account/channel; the note
|
||||
// shows in that service's INFO to opers only, never to the account owner.
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue