Add a shared channel-access FLAGS model
A granular access system, replacing the op/voice-only levels with per-user flag letters (f full, o auto-op, O op, h auto-halfop, v auto-voice, t topic, i invite, a modify-access, s settings, g greet). One shared primitive in the api — level_caps() resolves a stored level to capabilities (recognising the legacy op/voice/founder presets so nothing breaks) and apply_flags() applies +/- deltas — so join_mode/is_op/access_rank all route through it and a flag-granted +o auto-ops exactly like a legacy op entry. ChanServ FLAGS <#chan> [account [+/-flags]] lists or edits access; changing needs the founder or the 'a' flag. This is the foundation GroupServ will reuse for group membership flags.
This commit is contained in:
parent
5b023c22a4
commit
c7a4d5be27
5 changed files with 269 additions and 22 deletions
148
api/src/lib.rs
148
api/src/lib.rs
|
|
@ -443,13 +443,101 @@ pub struct AccountView {
|
||||||
pub greet: String,
|
pub greet: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// One channel access-list entry (account -> level, e.g. "op" / "voice").
|
// One channel access-list entry (account -> level). `level` is either a legacy
|
||||||
|
// preset ("op"/"voice"/"founder") or a granular flag string (e.g. "otiv"); both
|
||||||
|
// resolve through `level_caps`.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ChanAccessView {
|
pub struct ChanAccessView {
|
||||||
pub account: String,
|
pub account: String,
|
||||||
pub level: String,
|
pub level: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The granular channel-access flags, shared by ChanServ (channel access) and,
|
||||||
|
// via the same primitive, by GroupServ. Each letter grants a capability:
|
||||||
|
// f full/co-founder o auto-op O op commands h auto-halfop
|
||||||
|
// v auto-voice t topic i invite a modify access list
|
||||||
|
// s channel settings g greet shown
|
||||||
|
pub const ACCESS_FLAGS: &str = "foOhvtiasg";
|
||||||
|
|
||||||
|
// The capabilities an access `level` confers (founder is layered on by callers).
|
||||||
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
pub struct Caps {
|
||||||
|
pub auto: Option<&'static str>, // auto status mode on join: +o / +h / +v
|
||||||
|
pub op: bool, // moderation: kick / ban / op / mode / akick
|
||||||
|
pub topic: bool,
|
||||||
|
pub invite: bool,
|
||||||
|
pub access: bool, // may modify the access / flags / akick lists
|
||||||
|
pub set: bool, // may change channel settings
|
||||||
|
pub greet: bool,
|
||||||
|
pub rank: u8, // 3 co-founder, 2 op, 1 voice/halfop — for PEACE comparisons
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve an access `level` string to its capabilities. Recognises the legacy
|
||||||
|
// presets, then falls back to treating it as flag letters, so old op/voice
|
||||||
|
// entries and new flag entries coexist.
|
||||||
|
pub fn level_caps(level: &str) -> Caps {
|
||||||
|
match level {
|
||||||
|
"founder" => Caps { auto: Some("+o"), op: true, topic: true, invite: true, access: true, set: true, greet: true, rank: 3 },
|
||||||
|
"op" => Caps { auto: Some("+o"), op: true, topic: true, invite: true, rank: 2, ..Caps::default() },
|
||||||
|
"voice" => Caps { auto: Some("+v"), rank: 1, ..Caps::default() },
|
||||||
|
flags => {
|
||||||
|
let has = |c: char| flags.contains(c);
|
||||||
|
let auto = if has('o') {
|
||||||
|
Some("+o")
|
||||||
|
} else if has('h') {
|
||||||
|
Some("+h")
|
||||||
|
} else if has('v') {
|
||||||
|
Some("+v")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let founderish = has('f');
|
||||||
|
let rank = if founderish {
|
||||||
|
3
|
||||||
|
} else if has('o') || has('O') {
|
||||||
|
2
|
||||||
|
} else if !flags.is_empty() {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
Caps {
|
||||||
|
auto,
|
||||||
|
op: founderish || has('o') || has('O'),
|
||||||
|
topic: founderish || has('t'),
|
||||||
|
invite: founderish || has('i'),
|
||||||
|
access: founderish || has('a'),
|
||||||
|
set: founderish || has('s'),
|
||||||
|
greet: founderish || has('g'),
|
||||||
|
rank,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply a `+ov-h`-style delta to a flag string, returning the new sorted, unique
|
||||||
|
// flag string. A bare "ov" (no sign) grants. Returns Err with the first invalid
|
||||||
|
// flag letter.
|
||||||
|
pub fn apply_flags(current: &str, delta: &str) -> Result<String, char> {
|
||||||
|
let mut set: Vec<char> = current.chars().filter(|c| ACCESS_FLAGS.contains(*c)).collect();
|
||||||
|
let mut adding = true;
|
||||||
|
for c in delta.chars() {
|
||||||
|
match c {
|
||||||
|
'+' => adding = true,
|
||||||
|
'-' => adding = false,
|
||||||
|
f if ACCESS_FLAGS.contains(f) => {
|
||||||
|
set.retain(|&x| x != f);
|
||||||
|
if adding {
|
||||||
|
set.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => return Err(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A stable, deduplicated order following ACCESS_FLAGS.
|
||||||
|
Ok(ACCESS_FLAGS.chars().filter(|c| set.contains(c)).collect())
|
||||||
|
}
|
||||||
|
|
||||||
// One auto-kick entry (a hostmask and the reason shown on kick).
|
// One auto-kick entry (a hostmask and the reason shown on kick).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ChanAkickView {
|
pub struct ChanAkickView {
|
||||||
|
|
@ -619,33 +707,32 @@ pub enum Kicker {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChannelView {
|
impl ChannelView {
|
||||||
// The channel mode this account is entitled to on join (+o founder/op, +v
|
// The account's resolved capabilities, or the empty set if it holds no access.
|
||||||
// voice), or None if it holds no access.
|
// The founder holds everything.
|
||||||
pub fn join_mode(&self, account: &str) -> Option<&'static str> {
|
pub fn caps_of(&self, account: Option<&str>) -> Caps {
|
||||||
if self.founder.eq_ignore_ascii_case(account) {
|
let Some(acc) = account else { return Caps::default() };
|
||||||
return Some("+o");
|
|
||||||
}
|
|
||||||
self.access
|
|
||||||
.iter()
|
|
||||||
.find(|a| a.account.eq_ignore_ascii_case(account))
|
|
||||||
.map(|a| if a.level == "voice" { "+v" } else { "+o" })
|
|
||||||
}
|
|
||||||
|
|
||||||
// A comparable access rank for PEACE: founder 3, op 2, voice 1, none 0.
|
|
||||||
pub fn access_rank(&self, account: Option<&str>) -> u8 {
|
|
||||||
let Some(acc) = account else { return 0 };
|
|
||||||
if self.founder.eq_ignore_ascii_case(acc) {
|
if self.founder.eq_ignore_ascii_case(acc) {
|
||||||
return 3;
|
return level_caps("founder");
|
||||||
}
|
}
|
||||||
self.access
|
self.access
|
||||||
.iter()
|
.iter()
|
||||||
.find(|a| a.account.eq_ignore_ascii_case(acc))
|
.find(|a| a.account.eq_ignore_ascii_case(acc))
|
||||||
.map_or(0, |a| if a.level == "voice" { 1 } else { 2 })
|
.map_or(Caps::default(), |a| level_caps(&a.level))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether this account holds channel-operator access (founder or op level).
|
// The status mode this account gets on join (+o / +h / +v), or None.
|
||||||
|
pub fn join_mode(&self, account: &str) -> Option<&'static str> {
|
||||||
|
self.caps_of(Some(account)).auto
|
||||||
|
}
|
||||||
|
|
||||||
|
// A comparable access rank for PEACE: founder 3, op 2, voice/halfop 1, none 0.
|
||||||
|
pub fn access_rank(&self, account: Option<&str>) -> u8 {
|
||||||
|
self.caps_of(account).rank
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether this account holds channel-operator (moderation) access.
|
||||||
pub fn is_op(&self, account: &str) -> bool {
|
pub fn is_op(&self, account: &str) -> bool {
|
||||||
self.join_mode(account) == Some("+o")
|
self.caps_of(Some(account)).op
|
||||||
}
|
}
|
||||||
|
|
||||||
// The mode-lock rendered as an applyable mode string, e.g. "+rnt-s".
|
// The mode-lock rendered as an applyable mode string, e.g. "+rnt-s".
|
||||||
|
|
@ -991,6 +1078,27 @@ pub fn human_time(ts: u64) -> String {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn access_flags_apply_and_resolve() {
|
||||||
|
// +/- deltas, dedup, stable order, invalid flag rejected.
|
||||||
|
assert_eq!(apply_flags("", "+ov").unwrap(), "ov");
|
||||||
|
assert_eq!(apply_flags("ov", "-o").unwrap(), "v");
|
||||||
|
assert_eq!(apply_flags("v", "+tv").unwrap(), "vt"); // ACCESS_FLAGS order, no dup
|
||||||
|
assert_eq!(apply_flags("", "ai").unwrap(), "ia"); // bare letters grant
|
||||||
|
assert_eq!(apply_flags("", "+ox"), Err('x'));
|
||||||
|
|
||||||
|
// Legacy presets and flag strings both resolve to capabilities.
|
||||||
|
assert_eq!(level_caps("op").auto, Some("+o"));
|
||||||
|
assert!(level_caps("op").op && level_caps("op").topic);
|
||||||
|
assert_eq!(level_caps("voice").auto, Some("+v"));
|
||||||
|
assert_eq!(level_caps("o").auto, Some("+o"));
|
||||||
|
assert_eq!(level_caps("v").auto, Some("+v"));
|
||||||
|
assert_eq!(level_caps("h").auto, Some("+h"));
|
||||||
|
let t = level_caps("t"); // topic-only: can topic, not op
|
||||||
|
assert!(t.topic && !t.op && t.auto.is_none());
|
||||||
|
assert!(level_caps("a").access && level_caps("f").set);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn privs_bitset_grants_and_checks() {
|
fn privs_bitset_grants_and_checks() {
|
||||||
let p = Privs::default().with(Priv::Auspex).with(Priv::Admin);
|
let p = Privs::default().with(Priv::Auspex).with(Priv::Admin);
|
||||||
|
|
|
||||||
80
chanserv/src/flags.rs
Normal file
80
chanserv/src/flags.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
use fedserv_api::{apply_flags, Sender, ServiceCtx, Store, ACCESS_FLAGS};
|
||||||
|
|
||||||
|
// FLAGS <#channel> [account [+/-flags]]: the granular access model. With no
|
||||||
|
// account, list the access entries and their flags; with an account, show or
|
||||||
|
// change its flags. Letters: f full o auto-op O op h auto-halfop v auto-voice
|
||||||
|
// t topic i invite a access-list s settings g greet. Viewing needs op access;
|
||||||
|
// changing needs the founder or the \x02a\x02 flag.
|
||||||
|
pub fn handle(me: &str, from: &Sender, chan: &str, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||||
|
let Some(info) = db.channel(chan) else {
|
||||||
|
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let is_founder = from.account == Some(info.founder.as_str());
|
||||||
|
let caps = info.caps_of(from.account);
|
||||||
|
|
||||||
|
// FLAGS <#chan> — list.
|
||||||
|
let Some(&target) = args.get(2) else {
|
||||||
|
if !is_founder && !caps.op {
|
||||||
|
ctx.notice(me, from.uid, format!("You need access to \x02{chan}\x02 to view its flags."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.notice(me, from.uid, format!("Access flags for \x02{chan}\x02:"));
|
||||||
|
ctx.notice(me, from.uid, format!(" \x02{}\x02 (founder): \x02f\x02", info.founder));
|
||||||
|
for a in &info.access {
|
||||||
|
ctx.notice(me, from.uid, format!(" \x02{}\x02: \x02{}\x02", a.account, display_flags(&a.level)));
|
||||||
|
}
|
||||||
|
ctx.notice(me, from.uid, format!("End of flags ({} entr{}).", info.access.len() + 1, if info.access.is_empty() { "y" } else { "ies" }));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let current = info.access.iter().find(|a| a.account.eq_ignore_ascii_case(target)).map(|a| display_flags(&a.level));
|
||||||
|
|
||||||
|
// FLAGS <#chan> <account> — show one.
|
||||||
|
let Some(&delta) = args.get(3) else {
|
||||||
|
match current {
|
||||||
|
Some(f) if !f.is_empty() => ctx.notice(me, from.uid, format!("\x02{target}\x02 on \x02{chan}\x02: \x02{f}\x02")),
|
||||||
|
_ => ctx.notice(me, from.uid, format!("\x02{target}\x02 has no access to \x02{chan}\x02.")),
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// FLAGS <#chan> <account> <+/-flags> — modify.
|
||||||
|
if !is_founder && !caps.access {
|
||||||
|
ctx.notice(me, from.uid, format!("You need the founder or the \x02a\x02 flag to change access on \x02{chan}\x02."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if info.founder.eq_ignore_ascii_case(target) {
|
||||||
|
ctx.notice(me, from.uid, "The founder's access is set with \x02SET FOUNDER\x02, not flags.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let base = current.unwrap_or_default();
|
||||||
|
let updated = match apply_flags(&base, delta) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(bad) => {
|
||||||
|
ctx.notice(me, from.uid, format!("\x02{bad}\x02 isn't a valid flag. Valid flags: \x02{ACCESS_FLAGS}\x02."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if updated.is_empty() {
|
||||||
|
match db.access_del(chan, target) {
|
||||||
|
Ok(true) => ctx.notice(me, from.uid, format!("Cleared \x02{target}\x02's access to \x02{chan}\x02.")),
|
||||||
|
_ => ctx.notice(me, from.uid, format!("\x02{target}\x02 had no access to \x02{chan}\x02.")),
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match db.access_add(chan, target, &updated) {
|
||||||
|
Ok(()) => ctx.notice(me, from.uid, format!("\x02{target}\x02 on \x02{chan}\x02 now holds \x02{updated}\x02.")),
|
||||||
|
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show a stored level as flag letters (mapping the legacy presets).
|
||||||
|
fn display_flags(level: &str) -> String {
|
||||||
|
match level {
|
||||||
|
"op" => "oti".to_string(),
|
||||||
|
"voice" => "v".to_string(),
|
||||||
|
"founder" => "f".to_string(),
|
||||||
|
flags => flags.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ use fedserv_api::NetView;
|
||||||
mod mode;
|
mod mode;
|
||||||
#[path = "access.rs"]
|
#[path = "access.rs"]
|
||||||
mod access;
|
mod access;
|
||||||
|
mod flags;
|
||||||
#[path = "op.rs"]
|
#[path = "op.rs"]
|
||||||
mod op;
|
mod op;
|
||||||
#[path = "kick.rs"]
|
#[path = "kick.rs"]
|
||||||
|
|
@ -205,6 +206,13 @@ impl Service for ChanServ {
|
||||||
}
|
}
|
||||||
Some("MODE") => mode::handle(me, from, args, ctx, db),
|
Some("MODE") => mode::handle(me, from, args, ctx, db),
|
||||||
Some("ACCESS") => access::handle(me, from, args, ctx, db),
|
Some("ACCESS") => access::handle(me, from, args, ctx, db),
|
||||||
|
Some("FLAGS") => {
|
||||||
|
let Some(&chan) = args.get(1) else {
|
||||||
|
ctx.notice(me, from.uid, "Syntax: FLAGS <#channel> [account [+/-flags]]");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
flags::handle(me, from, chan, args, ctx, db);
|
||||||
|
}
|
||||||
Some("OP") => op::handle(me, from, "+o", args, ctx, net, db),
|
Some("OP") => op::handle(me, from, "+o", args, ctx, net, db),
|
||||||
Some("DEOP") => op::handle(me, from, "-o", args, ctx, net, db),
|
Some("DEOP") => op::handle(me, from, "-o", args, ctx, net, db),
|
||||||
Some("VOICE") => op::handle(me, from, "+v", args, ctx, net, db),
|
Some("VOICE") => op::handle(me, from, "+v", args, ctx, net, db),
|
||||||
|
|
@ -229,7 +237,7 @@ impl Service for ChanServ {
|
||||||
Some("AOP") => xop::handle(me, from, "AOP", "op", args, ctx, db),
|
Some("AOP") => xop::handle(me, from, "AOP", "op", args, ctx, db),
|
||||||
Some("SOP") => xop::handle(me, from, "SOP", "op", args, ctx, db),
|
Some("SOP") => xop::handle(me, from, "SOP", "op", args, ctx, db),
|
||||||
Some("VOP") => xop::handle(me, from, "VOP", "voice", args, ctx, db),
|
Some("VOP") => xop::handle(me, from, "VOP", "voice", args, ctx, db),
|
||||||
Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02, \x02INFO\x02, \x02LIST\x02, \x02SET\x02, \x02ACCESS\x02, \x02AOP\x02/\x02SOP\x02/\x02VOP\x02, \x02STATUS\x02, \x02OP\x02/\x02DEOP\x02, \x02VOICE\x02/\x02DEVOICE\x02, \x02KICK\x02, \x02BAN\x02/\x02UNBAN\x02, \x02AKICK\x02, \x02ENFORCE\x02, \x02TOPIC\x02, \x02ENTRYMSG\x02, \x02INVITE\x02, \x02GETKEY\x02, \x02SEEN\x02, \x02CLONE\x02, \x02MODE\x02, \x02MLOCK\x02, \x02DROP\x02. Operators also have \x02SUSPEND\x02/\x02UNSUSPEND\x02 and \x02NOEXPIRE\x02 <#channel> {ON|OFF}."),
|
Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02, \x02INFO\x02, \x02LIST\x02, \x02SET\x02, \x02ACCESS\x02, \x02FLAGS\x02, \x02AOP\x02/\x02SOP\x02/\x02VOP\x02, \x02STATUS\x02, \x02OP\x02/\x02DEOP\x02, \x02VOICE\x02/\x02DEVOICE\x02, \x02KICK\x02, \x02BAN\x02/\x02UNBAN\x02, \x02AKICK\x02, \x02ENFORCE\x02, \x02TOPIC\x02, \x02ENTRYMSG\x02, \x02INVITE\x02, \x02GETKEY\x02, \x02SEEN\x02, \x02CLONE\x02, \x02MODE\x02, \x02MLOCK\x02, \x02DROP\x02. Operators also have \x02SUSPEND\x02/\x02UNSUSPEND\x02 and \x02NOEXPIRE\x02 <#channel> {ON|OFF}."),
|
||||||
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
|
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -681,7 +681,7 @@ impl ChannelInfo {
|
||||||
self.access
|
self.access
|
||||||
.iter()
|
.iter()
|
||||||
.find(|a| a.account.eq_ignore_ascii_case(account))
|
.find(|a| a.account.eq_ignore_ascii_case(account))
|
||||||
.map(|a| if a.level == "voice" { "+v" } else { "+o" })
|
.and_then(|a| fedserv_api::level_caps(&a.level).auto)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The matching auto-kick entry for `hostmask` (nick!user@host), if any.
|
/// The matching auto-kick entry for `hostmask` (nick!user@host), if any.
|
||||||
|
|
|
||||||
|
|
@ -4831,6 +4831,57 @@ mod tests {
|
||||||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "non-oper refused: {out:?}");
|
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "non-oper refused: {out:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChanServ FLAGS: granular access grants flow through the shared resolver, so
|
||||||
|
// a flag-granted +o gets auto-op like a legacy op entry; changing needs the
|
||||||
|
// founder or the 'a' flag.
|
||||||
|
#[test]
|
||||||
|
fn chanserv_flags_grant_access() {
|
||||||
|
use fedserv_chanserv::ChanServ;
|
||||||
|
let path = std::env::temp_dir().join("fedserv-csflags.jsonl");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let mut db = Db::open(&path, "42S");
|
||||||
|
db.scram_iterations = 4096;
|
||||||
|
for a in ["alice", "bob", "carol"] {
|
||||||
|
db.register(a, "password1", None).unwrap();
|
||||||
|
}
|
||||||
|
db.register_channel("#room", "alice").unwrap();
|
||||||
|
let mut e = Engine::new(
|
||||||
|
vec![
|
||||||
|
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||||||
|
Box::new(ChanServ { uid: "42SAAAAAB".into() }),
|
||||||
|
],
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
let cs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAB".into(), text: t.into() });
|
||||||
|
let has = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||||||
|
|
||||||
|
for (uid, nick) in [("000AAAAAA", "alice"), ("000AAAAAB", "bob"), ("000AAAAAC", "carol")] {
|
||||||
|
e.handle(NetEvent::UserConnect { uid: uid.into(), nick: nick.into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||||||
|
e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Founder grants bob auto-op + topic via flags.
|
||||||
|
assert!(has(&cs(&mut e, "000AAAAAA", "FLAGS #room bob +ot"), "now holds"), "flags set");
|
||||||
|
// The grant confers real access: bob is auto-opped on join.
|
||||||
|
let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#room".into(), op: false });
|
||||||
|
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#room" && modes.starts_with("+o"))), "flag-op is auto-opped: {out:?}");
|
||||||
|
|
||||||
|
// Change to voice-only; now the join mode is +v.
|
||||||
|
cs(&mut e, "000AAAAAA", "FLAGS #room bob -ot+v");
|
||||||
|
let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#room2".into(), op: false });
|
||||||
|
assert!(!out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes.starts_with("+o"))), "no longer auto-opped elsewhere");
|
||||||
|
assert_eq!(e.db.channel("#room").unwrap().join_mode("bob"), Some("+v"), "bob resolves to +v");
|
||||||
|
|
||||||
|
// A non-founder without the 'a' flag can't change access.
|
||||||
|
assert!(has(&cs(&mut e, "000AAAAAC", "FLAGS #room bob +o"), "founder or the \x02a\x02 flag"), "carol refused");
|
||||||
|
// Grant carol the 'a' flag; now she can.
|
||||||
|
cs(&mut e, "000AAAAAA", "FLAGS #room carol +a");
|
||||||
|
assert!(has(&cs(&mut e, "000AAAAAC", "FLAGS #room bob +i"), "now holds"), "carol with 'a' can change flags");
|
||||||
|
|
||||||
|
// An invalid flag letter is rejected.
|
||||||
|
assert!(has(&cs(&mut e, "000AAAAAA", "FLAGS #room bob +z"), "isn't a valid flag"), "bad flag rejected");
|
||||||
|
}
|
||||||
|
|
||||||
// ChanServ moderation: an op can op/kick/ban users; a non-op is refused.
|
// ChanServ moderation: an op can op/kick/ban users; a non-op is refused.
|
||||||
#[test]
|
#[test]
|
||||||
fn chanserv_moderation() {
|
fn chanserv_moderation() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue