5863 lines
347 KiB
Rust
5863 lines
347 KiB
Rust
use super::*;
|
||
use echo_nickserv::NickServ;
|
||
|
||
fn plain(authzid: &[u8], authcid: &[u8], passwd: &[u8]) -> String {
|
||
let mut payload = Vec::new();
|
||
payload.extend_from_slice(authzid);
|
||
payload.push(0);
|
||
payload.extend_from_slice(authcid);
|
||
payload.push(0);
|
||
payload.extend_from_slice(passwd);
|
||
STANDARD.encode(payload)
|
||
}
|
||
|
||
fn engine_with(name: &str, account: &str, password: &str) -> Engine {
|
||
let path = std::env::temp_dir().join(format!("echo-sasl-{name}.jsonl"));
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "test");
|
||
db.scram_iterations = 4096; // keep the debug-build verifier cheap in tests
|
||
assert!(db.register(account, password, None).is_ok());
|
||
Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".to_string(), guest_nick: "Guest".to_string(), guest_seq: 12345 })], db)
|
||
}
|
||
|
||
fn sasl(engine: &mut Engine, mode: &str, chunk: &str) -> Vec<NetAction> {
|
||
engine.handle(NetEvent::Sasl {
|
||
client: "000AAAAAB".to_string(),
|
||
agent: "*".to_string(),
|
||
mode: mode.to_string(),
|
||
data: vec![chunk.to_string()],
|
||
})
|
||
}
|
||
|
||
fn is_success(out: &[NetAction]) -> bool {
|
||
out.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value == "foo"))
|
||
&& out.iter().any(|a| matches!(a, NetAction::Sasl { mode, data, .. } if mode == "D" && data.as_slice() == ["S"]))
|
||
}
|
||
|
||
// Single-chunk PLAIN (short response) logs in.
|
||
#[test]
|
||
fn plain_single_chunk() {
|
||
let mut e = engine_with("single", "foo", "sesame");
|
||
sasl(&mut e, "S", "PLAIN");
|
||
let out = sasl(&mut e, "C", &plain(b"", b"foo", b"sesame"));
|
||
assert!(is_success(&out), "{out:?}");
|
||
}
|
||
|
||
// A routed WHOIS (mIRC double-click) asks the service's server for its idle
|
||
// time; we must answer for our own pseudo-clients, and only them.
|
||
#[test]
|
||
fn idle_request_is_answered_for_our_clients_only() {
|
||
let mut e = engine_with("idle", "foo", "sesame");
|
||
e.set_sid("42S".into());
|
||
let ours = e.handle(NetEvent::Idle { requester: "000AAAAAB".into(), target: "42SAAAAAA".into() });
|
||
assert!(
|
||
ours.iter().any(|a| matches!(a, NetAction::IdleReply { target, requester, idle, .. }
|
||
if target == "42SAAAAAA" && requester == "000AAAAAB" && *idle == 0)),
|
||
"expected an IDLE reply for NickServ, got {ours:?}"
|
||
);
|
||
let foreign = e.handle(NetEvent::Idle { requester: "000AAAAAB".into(), target: "000AAAAAZ".into() });
|
||
assert!(!foreign.iter().any(|a| matches!(a, NetAction::IdleReply { .. })), "{foreign:?}");
|
||
}
|
||
|
||
// DebugServ streams auth outcomes into the log channel, and stays silent when
|
||
// it isn't running.
|
||
#[test]
|
||
fn debugserv_feeds_auth_events() {
|
||
let mut e = engine_with("dbgfeed", "foo", "sesame");
|
||
e.set_sid("42S".into());
|
||
e.set_log_channel(Some("#services".into()));
|
||
e.set_debugserv_uid("42SAAAAAO");
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into(), host: "host.example".into(), ip: "0.0.0.0".into() });
|
||
|
||
sasl(&mut e, "S", "PLAIN");
|
||
let ok = sasl(&mut e, "C", &plain(b"", b"foo", b"sesame"));
|
||
assert!(is_success(&ok), "login should still work: {ok:?}");
|
||
assert!(
|
||
ok.iter().any(|a| matches!(a, NetAction::Privmsg { from, to, text }
|
||
if from == "42SAAAAAO" && to == "#services" && text.contains("[AUTH]") && text.contains("SASL PLAIN"))),
|
||
"expected an AUTH feed line from DebugServ, got {ok:?}"
|
||
);
|
||
|
||
sasl(&mut e, "S", "PLAIN");
|
||
let bad = sasl(&mut e, "C", &plain(b"", b"foo", b"wrongpass"));
|
||
assert!(!is_success(&bad), "wrong password must not log in: {bad:?}");
|
||
assert!(
|
||
bad.iter().any(|a| matches!(a, NetAction::Privmsg { from, text, .. }
|
||
if from == "42SAAAAAO" && text.contains("[AUTH]") && text.contains("bad password"))),
|
||
"expected an AUTH failure line, got {bad:?}"
|
||
);
|
||
|
||
// Disabled (no uid) -> the feed is silent.
|
||
e.set_debugserv_uid("");
|
||
sasl(&mut e, "S", "PLAIN");
|
||
let quiet = sasl(&mut e, "C", &plain(b"", b"foo", b"sesame"));
|
||
assert!(
|
||
!quiet.iter().any(|a| matches!(a, NetAction::Privmsg { to, .. } if to == "#services")),
|
||
"no feed line when DebugServ is off: {quiet:?}"
|
||
);
|
||
}
|
||
|
||
// A response that is not a multiple of 400 splits into 400 + remainder.
|
||
#[test]
|
||
fn plain_chunked_412() {
|
||
let pw = "bar".repeat(100);
|
||
let mut e = engine_with("c412", "foo", &pw);
|
||
let auth = plain(b"foo", b"foo", pw.as_bytes());
|
||
assert_eq!(auth.len(), 412);
|
||
sasl(&mut e, "S", "PLAIN");
|
||
assert!(sasl(&mut e, "C", &auth[0..400]).is_empty());
|
||
let out = sasl(&mut e, "C", &auth[400..]);
|
||
assert!(is_success(&out), "{out:?}");
|
||
}
|
||
|
||
// A response that is an exact multiple of 400 ends with a trailing "+".
|
||
#[test]
|
||
fn plain_chunked_800() {
|
||
let pw = "x".repeat(592);
|
||
let mut e = engine_with("c800", "foo", &pw);
|
||
let auth = plain(b"foo", b"foo", pw.as_bytes());
|
||
assert_eq!(auth.len(), 800);
|
||
sasl(&mut e, "S", "PLAIN");
|
||
assert!(sasl(&mut e, "C", &auth[0..400]).is_empty());
|
||
assert!(sasl(&mut e, "C", &auth[400..800]).is_empty());
|
||
let out = sasl(&mut e, "C", "+");
|
||
assert!(is_success(&out), "{out:?}");
|
||
}
|
||
|
||
// Wrong password fails.
|
||
#[test]
|
||
fn plain_bad_password() {
|
||
let mut e = engine_with("bad", "foo", "sesame");
|
||
sasl(&mut e, "S", "PLAIN");
|
||
let out = sasl(&mut e, "C", &plain(b"", b"foo", b"wrong"));
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Sasl { mode, data, .. } if mode == "D" && data.as_slice() == ["F"])), "{out:?}");
|
||
}
|
||
|
||
// SCRAM (Orbit's default mech) must honour the brute-force lockout — else a
|
||
// throttled attacker could switch to SCRAM and keep guessing unthrottled.
|
||
#[test]
|
||
fn sasl_scram_refuses_a_locked_out_account() {
|
||
let mut e = engine_with("scramlock", "foo", "sesame");
|
||
for _ in 0..10 {
|
||
e.db.note_auth("foo", false);
|
||
}
|
||
assert!(e.db.auth_lockout("foo").is_some(), "account is throttled after repeated failures");
|
||
// A SCRAM client-first for the locked account is refused (D/F), never challenged.
|
||
sasl(&mut e, "S", "SCRAM-SHA-256");
|
||
let out = sasl(&mut e, "C", &STANDARD.encode("n,,n=foo,r=cnonce"));
|
||
assert!(
|
||
out.iter().any(|a| matches!(a, NetAction::Sasl { mode, data, .. } if mode == "D" && data.as_slice() == ["F"])),
|
||
"locked-out SCRAM is refused, not challenged: {out:?}"
|
||
);
|
||
}
|
||
|
||
// The single reply datum of a C/D action (SCRAM messages are single-chunk).
|
||
fn datum(out: &[NetAction]) -> (&str, &str) {
|
||
match out.first() {
|
||
Some(NetAction::Sasl { mode, data, .. }) => (mode.as_str(), data[0].as_str()),
|
||
other => panic!("expected a SASL action, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
// Drive a full SCRAM exchange through the engine, playing the client, and
|
||
// return the final actions. `login_pw` may differ from the registered one.
|
||
fn scram_exchange(mech: &str, login_pw: &str) -> Vec<NetAction> {
|
||
use scram::Hash;
|
||
let hash = Hash::from_mech(mech).unwrap();
|
||
let mut e = engine_with("scram", "foo", "sesame");
|
||
|
||
assert_eq!(datum(&sasl(&mut e, "S", mech)), ("C", "+"));
|
||
|
||
let client_first_bare = "n=foo,r=cnonce";
|
||
let client_first = format!("n,,{client_first_bare}");
|
||
let first_out = sasl(&mut e, "C", &STANDARD.encode(&client_first));
|
||
let (mode, b64) = datum(&first_out);
|
||
assert_eq!(mode, "C");
|
||
let server_first = String::from_utf8(STANDARD.decode(b64).unwrap()).unwrap();
|
||
|
||
// Reconstruct salt/iterations from server-first and forge the proof.
|
||
let salt = STANDARD.decode(server_first.split(",s=").nth(1).unwrap().split(',').next().unwrap()).unwrap();
|
||
let iters: u32 = server_first.rsplit(",i=").next().unwrap().parse().unwrap();
|
||
let full_nonce = server_first.split("r=").nth(1).unwrap().split(',').next().unwrap();
|
||
|
||
let salted = scram::hi(hash, login_pw.as_bytes(), &salt, iters);
|
||
let client_key = scram::hmac(hash, &salted, b"Client Key");
|
||
let stored_key = scram::h(hash, &client_key);
|
||
let without_proof = format!("c=biws,r={full_nonce}");
|
||
let auth = format!("{client_first_bare},{server_first},{without_proof}");
|
||
let proof = scram::xor(&client_key, &scram::hmac(hash, &stored_key, auth.as_bytes()));
|
||
let client_final = format!("{without_proof},p={}", STANDARD.encode(&proof));
|
||
|
||
let final_out = sasl(&mut e, "C", &STANDARD.encode(&client_final));
|
||
// On success the engine sends server-final (C v=...); the client then acks.
|
||
match datum(&final_out) {
|
||
("C", _) => sasl(&mut e, "C", "+"),
|
||
_ => final_out,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn scram_sha256_success() {
|
||
assert!(is_success(&scram_exchange("SCRAM-SHA-256", "sesame")));
|
||
}
|
||
|
||
#[test]
|
||
fn scram_sha512_success() {
|
||
assert!(is_success(&scram_exchange("SCRAM-SHA-512", "sesame")));
|
||
}
|
||
|
||
#[test]
|
||
fn scram_bad_password() {
|
||
let out = scram_exchange("SCRAM-SHA-256", "millet");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Sasl { mode, data, .. } if mode == "D" && data.as_slice() == ["F"])), "{out:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn scram_unknown_user_fails() {
|
||
let mut e = engine_with("scramnouser", "foo", "sesame");
|
||
sasl(&mut e, "S", "SCRAM-SHA-256");
|
||
let out = sasl(&mut e, "C", &STANDARD.encode("n,,n=ghost,r=cnonce"));
|
||
assert_eq!(datum(&out), ("D", "F"));
|
||
}
|
||
|
||
// Start a SASL exchange for an arbitrary client uid.
|
||
fn start(e: &mut Engine, client: &str) -> Vec<NetAction> {
|
||
e.handle(NetEvent::Sasl { client: client.into(), agent: "*".into(), mode: "S".into(), data: vec!["PLAIN".into()] })
|
||
}
|
||
|
||
// A client that starts SASL then vanishes must not linger forever.
|
||
#[test]
|
||
fn abandoned_session_swept_after_ttl() {
|
||
let mut e = engine_with("swept", "foo", "sesame");
|
||
start(&mut e, "AAA");
|
||
assert!(e.sasl_sessions.contains_key("AAA"));
|
||
// Backdate it past the TTL; guarded so a just-booted monotonic clock can't panic.
|
||
if let Some(stale) = Instant::now().checked_sub(SASL_SESSION_TTL + Duration::from_secs(1)) {
|
||
e.sasl_sessions.get_mut("AAA").unwrap().touched = stale;
|
||
start(&mut e, "BBB"); // any new start sweeps first
|
||
assert!(!e.sasl_sessions.contains_key("AAA"), "stale exchange should be swept");
|
||
assert!(e.sasl_sessions.contains_key("BBB"));
|
||
}
|
||
}
|
||
|
||
// The map is hard-capped so a flood of half-open exchanges can't exhaust memory.
|
||
#[test]
|
||
fn concurrent_sessions_capped() {
|
||
let mut e = engine_with("capped", "foo", "sesame");
|
||
for i in 0..MAX_SASL_SESSIONS {
|
||
assert_eq!(datum(&start(&mut e, &format!("u{i}"))), ("C", "+"));
|
||
}
|
||
assert_eq!(datum(&start(&mut e, "over")), ("D", "F"), "over-cap start must be refused");
|
||
}
|
||
|
||
// A QUIT drops any half-finished exchange for that uid.
|
||
#[test]
|
||
fn session_cleared_on_quit() {
|
||
let mut e = engine_with("quit", "foo", "sesame");
|
||
start(&mut e, "ZZZ");
|
||
assert!(e.sasl_sessions.contains_key("ZZZ"));
|
||
e.handle(NetEvent::Quit { uid: "ZZZ".into(), reason: String::new() });
|
||
assert!(!e.sasl_sessions.contains_key("ZZZ"), "quit should drop the exchange");
|
||
}
|
||
|
||
// Drive a SASL step with a multi-field data vector (EXTERNAL carries the
|
||
// mechanism plus the ircd-supplied fingerprints in one message).
|
||
fn sasl_multi(e: &mut Engine, mode: &str, data: &[&str]) -> Vec<NetAction> {
|
||
e.handle(NetEvent::Sasl {
|
||
client: "000AAAAAB".into(),
|
||
agent: "*".into(),
|
||
mode: mode.into(),
|
||
data: data.iter().map(|s| s.to_string()).collect(),
|
||
})
|
||
}
|
||
|
||
// EXTERNAL with a registered fingerprint logs in; matching is case-insensitive.
|
||
#[test]
|
||
fn external_success_with_registered_cert() {
|
||
let mut e = engine_with("extok", "foo", "sesame");
|
||
e.db.certfp_add("foo", "aabbccddeeff00112233445566778899").unwrap();
|
||
assert_eq!(datum(&sasl_multi(&mut e, "S", &["EXTERNAL", "AABBCCDDEEFF00112233445566778899"])), ("C", "+"));
|
||
assert!(is_success(&sasl(&mut e, "C", "+")), "empty authzid should log in");
|
||
}
|
||
|
||
// An authzid, if sent, must name the cert's own account.
|
||
#[test]
|
||
fn external_authzid_must_match() {
|
||
let mut e = engine_with("extaz", "foo", "sesame");
|
||
e.db.certfp_add("foo", "aabbccddeeff00112233445566778899").unwrap();
|
||
sasl_multi(&mut e, "S", &["EXTERNAL", "aabbccddeeff00112233445566778899"]);
|
||
assert!(is_success(&sasl(&mut e, "C", &STANDARD.encode("foo"))), "matching authzid ok");
|
||
|
||
let mut e = engine_with("extaz2", "foo", "sesame");
|
||
e.db.certfp_add("foo", "aabbccddeeff00112233445566778899").unwrap();
|
||
sasl_multi(&mut e, "S", &["EXTERNAL", "aabbccddeeff00112233445566778899"]);
|
||
assert_eq!(datum(&sasl(&mut e, "C", &STANDARD.encode("bar"))), ("D", "F"), "foreign authzid rejected");
|
||
}
|
||
|
||
// An unknown fingerprint, or no fingerprint at all (plaintext client), fails.
|
||
#[test]
|
||
fn external_without_registered_cert_fails() {
|
||
let mut e = engine_with("extno", "foo", "sesame");
|
||
sasl_multi(&mut e, "S", &["EXTERNAL", "0011223344556677889900aabbccddee"]);
|
||
assert_eq!(datum(&sasl(&mut e, "C", "+")), ("D", "F"), "unknown fp rejected");
|
||
|
||
sasl_multi(&mut e, "S", &["EXTERNAL"]); // no fingerprint supplied
|
||
assert_eq!(datum(&sasl(&mut e, "C", "+")), ("D", "F"), "no cert rejected");
|
||
}
|
||
|
||
// End to end: enrol a cert with NickServ CERT ADD, then log in with EXTERNAL.
|
||
#[test]
|
||
fn nickserv_cert_add_enables_external() {
|
||
let mut e = engine_with("nscert", "foo", "sesame");
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into(), host: "host.example".into() , ip: "0.0.0.0".into() });
|
||
let fp = "aabbccddeeff00112233445566778899";
|
||
|
||
let out = e.handle(NetEvent::Privmsg {
|
||
from: "000AAAAAB".into(),
|
||
to: "42SAAAAAA".into(),
|
||
text: format!("CERT ADD sesame {fp}"),
|
||
});
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Added"))), "{out:?}");
|
||
|
||
sasl_multi(&mut e, "S", &["EXTERNAL", fp]);
|
||
assert!(is_success(&sasl(&mut e, "C", "+")), "external should work after CERT ADD");
|
||
}
|
||
|
||
// A wrong password can't enrol a cert, and a fingerprint is one account only.
|
||
#[test]
|
||
fn cert_add_is_guarded_and_unique() {
|
||
let mut e = engine_with("certguard", "foo", "sesame");
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into(), host: "host.example".into() , ip: "0.0.0.0".into() });
|
||
let fp = "aabbccddeeff00112233445566778899";
|
||
|
||
let bad = e.handle(NetEvent::Privmsg {
|
||
from: "000AAAAAB".into(),
|
||
to: "42SAAAAAA".into(),
|
||
text: format!("CERT ADD wrongpw {fp}"),
|
||
});
|
||
assert!(bad.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Invalid password"))), "{bad:?}");
|
||
assert!(e.db.certfp_owner(fp).is_none(), "bad password must not enrol");
|
||
|
||
e.db.certfp_add("foo", fp).unwrap();
|
||
e.db.register("bar", "sesame", None).unwrap();
|
||
assert!(matches!(e.db.certfp_add("bar", fp), Err(db::CertError::InUse)), "a fingerprint maps to one account");
|
||
}
|
||
|
||
// An identified caller manages certs without re-entering a password.
|
||
#[test]
|
||
fn cert_works_for_identified_user_without_password() {
|
||
let mut e = engine_with("certid", "foo", "sesame");
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into(), host: "host.example".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
let fp = "aabbccddeeff00112233445566778899";
|
||
let cert = |e: &mut Engine, t: String| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: t });
|
||
|
||
let add = cert(&mut e, format!("CERT ADD {fp}"));
|
||
assert!(add.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Added"))), "add without password: {add:?}");
|
||
let list = cert(&mut e, "CERT LIST".into());
|
||
assert!(list.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(fp))), "list without password: {list:?}");
|
||
let del = cert(&mut e, format!("CERT DEL {fp}"));
|
||
assert!(del.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Removed"))), "del without password: {del:?}");
|
||
assert!(e.db.certfp_owner(fp).is_none(), "fingerprint removed");
|
||
}
|
||
|
||
// Standard replies gate: off (default) degrades a service FAIL to a notice so
|
||
// nothing is lost; on, the structured FAIL survives with command + code.
|
||
#[test]
|
||
fn standard_replies_gate_service_failures() {
|
||
let connect = |e: &mut Engine| {
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
};
|
||
let identify_unregistered = |e: &mut Engine| e.handle(NetEvent::Privmsg {
|
||
from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY ghost pw".into(),
|
||
});
|
||
|
||
// Off: no StandardReply reaches the wire; the user still gets a notice.
|
||
let mut off = engine_with("stdrploff", "foo", "sesame");
|
||
connect(&mut off);
|
||
let a = identify_unregistered(&mut off);
|
||
assert!(a.iter().any(|x| matches!(x, NetAction::Notice { text, .. } if text.contains("isn't registered"))), "{a:?}");
|
||
assert!(!a.iter().any(|x| matches!(x, NetAction::StandardReply { .. })), "off must degrade: {a:?}");
|
||
|
||
// On: the FAIL survives, carrying the command and machine code.
|
||
let mut on = engine_with("stdrplon", "foo", "sesame");
|
||
on.set_standard_replies(true);
|
||
connect(&mut on);
|
||
let b = identify_unregistered(&mut on);
|
||
let hit = b.iter().find_map(|x| match x {
|
||
NetAction::StandardReply { kind, command, code, .. } => Some((kind.verb(), command.clone(), code.clone())),
|
||
_ => None,
|
||
});
|
||
assert_eq!(hit, Some(("FAIL", "IDENTIFY".to_string(), "ACCOUNT_NOT_REGISTERED".to_string())), "{b:?}");
|
||
}
|
||
|
||
// IDENTIFY logs in, LOGOUT clears the accountname (ircd emits RPL_LOGGEDOUT).
|
||
#[test]
|
||
fn nickserv_logout_clears_account() {
|
||
let mut e = engine_with("nslogout", "foo", "sesame");
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into(), host: "host.example".into() , ip: "0.0.0.0".into() });
|
||
|
||
let login = e.handle(NetEvent::Privmsg {
|
||
from: "000AAAAAB".into(),
|
||
to: "42SAAAAAA".into(),
|
||
text: "IDENTIFY sesame".into(),
|
||
});
|
||
assert!(login.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value == "foo")), "{login:?}");
|
||
|
||
let out = e.handle(NetEvent::Privmsg {
|
||
from: "000AAAAAB".into(),
|
||
to: "42SAAAAAA".into(),
|
||
text: "LOGOUT".into(),
|
||
});
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value.is_empty())), "logout clears accountname: {out:?}");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ForceNick { uid, nick } if uid == "000AAAAAB" && nick == "Guest12345")), "logout renames to guest nick: {out:?}");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("logged out"))), "{out:?}");
|
||
}
|
||
|
||
fn logout(e: &mut Engine, uid: &str) -> Vec<NetAction> {
|
||
e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: "LOGOUT".into() })
|
||
}
|
||
|
||
// SET LANGUAGE stores the preference and switches replies to that language:
|
||
// the confirmation renders in the newly-chosen language, and a later command
|
||
// resolves the language from the account via the dispatch path.
|
||
#[test]
|
||
fn set_language_switches_reply_language() {
|
||
use echo_nickserv::NickServ;
|
||
// Minimal French catalog for this test process. English tests are unaffected:
|
||
// they resolve to "en" and fall back to the English source string.
|
||
let mut fr = std::collections::HashMap::new();
|
||
fr.insert("Your language is now \x02{lang}\x02.".to_string(), "Votre langue est maintenant \x02{lang}\x02.".to_string());
|
||
fr.insert(
|
||
"Your language is \x02{lang}\x02. Available: {list}. Change it with \x02SET LANGUAGE <code>\x02.".to_string(),
|
||
"Votre langue est \x02{lang}\x02. Disponibles : {list}.".to_string(),
|
||
);
|
||
echo_api::load_catalog(std::collections::HashMap::from([("fr".to_string(), fr)]));
|
||
|
||
let path = std::env::temp_dir().join("echo-setlang.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.set_available_languages(vec!["fr".to_string()]);
|
||
db.register("bob", "password1", None).unwrap();
|
||
let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "bob".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
let ns = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
|
||
let out = ns(&mut e, "SET LANGUAGE fr");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Votre langue est maintenant"))), "confirmation is French: {out:?}");
|
||
assert_eq!(e.db.language_of("bob").as_deref(), Some("fr"), "the preference is stored");
|
||
|
||
// A later command now resolves to French via the dispatch path (bob's pref).
|
||
let out2 = ns(&mut e, "SET LANGUAGE");
|
||
assert!(out2.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Disponibles"))), "later reply is French via ctx.lang: {out2:?}");
|
||
}
|
||
|
||
// A guest rename must skip a nick that's already online, or it would collide
|
||
// with (and get) an innocent user instead of freeing the name.
|
||
#[test]
|
||
fn logout_skips_an_occupied_guest_nick() {
|
||
let mut e = engine_with("guestskip", "foo", "sesame");
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
// Someone already holds Guest12345 (the first nick the counter would pick).
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "Guest12345".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
let out = logout(&mut e, "000AAAAAB");
|
||
assert!(
|
||
out.iter().any(|a| matches!(a, NetAction::ForceNick { uid, nick } if uid == "000AAAAAB" && nick == "Guest12346")),
|
||
"logout skips the taken guest nick: {out:?}"
|
||
);
|
||
}
|
||
|
||
// LOGOUT while not identified must not rename you or clear anything.
|
||
#[test]
|
||
fn logout_without_login_is_noop() {
|
||
let mut e = engine_with("nologin", "foo", "sesame");
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "someone".into(), host: "host.example".into() , ip: "0.0.0.0".into() });
|
||
let out = logout(&mut e, "000AAAAAB");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("not logged in"))), "{out:?}");
|
||
assert!(!out.iter().any(|a| matches!(a, NetAction::ForceNick { .. })), "must not rename when not logged in: {out:?}");
|
||
}
|
||
|
||
// Regression: a second LOGOUT is a no-op, not another guest rename (the churn
|
||
// where Guest33294 -> LOGOUT -> Guest33295).
|
||
#[test]
|
||
fn logout_twice_renames_only_once() {
|
||
let mut e = engine_with("twice", "foo", "sesame");
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into(), host: "host.example".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
|
||
let first = logout(&mut e, "000AAAAAB");
|
||
assert!(first.iter().any(|a| matches!(a, NetAction::ForceNick { .. })), "first logout renames: {first:?}");
|
||
|
||
let second = logout(&mut e, "000AAAAAB");
|
||
assert!(second.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("not logged in"))), "{second:?}");
|
||
assert!(!second.iter().any(|a| matches!(a, NetAction::ForceNick { .. })), "second logout must not rename again: {second:?}");
|
||
}
|
||
|
||
// A SASL login (before the user is even bursted) is remembered, so a later
|
||
// LOGOUT from that uid is recognised as logged in.
|
||
#[test]
|
||
fn sasl_login_is_tracked_for_logout() {
|
||
let mut e = engine_with("sasllogout", "foo", "sesame");
|
||
sasl(&mut e, "S", "PLAIN");
|
||
let ok = sasl(&mut e, "C", &plain(b"", b"foo", b"sesame"));
|
||
assert!(is_success(&ok), "{ok:?}");
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into(), host: "host.example".into() , ip: "0.0.0.0".into() });
|
||
|
||
let out = logout(&mut e, "000AAAAAB");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ForceNick { .. })), "sasl-authed user can log out: {out:?}");
|
||
}
|
||
|
||
// Regression: repeated IDENTIFY while already identified must not re-fire the
|
||
// login (the 900 loop when the command is spammed).
|
||
#[test]
|
||
fn identify_twice_does_not_relogin() {
|
||
let mut e = engine_with("reident", "foo", "sesame");
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into(), host: "host.example".into() , ip: "0.0.0.0".into() });
|
||
let ident = |e: &mut Engine| e.handle(NetEvent::Privmsg {
|
||
from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into(),
|
||
});
|
||
|
||
let first = ident(&mut e);
|
||
assert!(first.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value == "foo")), "first identify logs in: {first:?}");
|
||
|
||
let second = ident(&mut e);
|
||
assert!(!second.iter().any(|a| matches!(a, NetAction::Metadata { key, .. } if key == "accountname")), "second identify must not re-login: {second:?}");
|
||
assert!(second.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("already identified"))), "{second:?}");
|
||
}
|
||
|
||
// The event log is the source of truth: state survives a reopen, rebuilt by
|
||
// folding the log (with its origin/seq metadata) rather than a snapshot.
|
||
#[test]
|
||
fn account_store_survives_reopen() {
|
||
let path = std::env::temp_dir().join("echo-reopen.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
{
|
||
let mut db = Db::open(&path, "n1");
|
||
db.scram_iterations = 4096;
|
||
db.register("foo", "sesame", None).unwrap();
|
||
db.certfp_add("foo", "aabbccddeeff00112233445566778899").unwrap();
|
||
}
|
||
let db = Db::open(&path, "n1");
|
||
assert_eq!(db.certfps("foo").len(), 1, "cert replayed from log");
|
||
assert!(db.authenticate("foo", "sesame").is_some(), "account replayed from log");
|
||
}
|
||
|
||
// Regression: after a nick change (e.g. a guest rename, then back to your
|
||
// real nick), IDENTIFY must authenticate the CURRENT nick, not the one held
|
||
// at burst time — otherwise you log into the wrong account.
|
||
#[test]
|
||
fn identify_uses_current_nick_after_rename() {
|
||
let mut e = engine_with("renameident", "realnick", "sesame");
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "Guest99999".into(), host: "host.example".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::NickChange { uid: "000AAAAAB".into(), nick: "realnick".into() });
|
||
let out = e.handle(NetEvent::Privmsg {
|
||
from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into(),
|
||
});
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value == "realnick")), "must log into the current nick's account: {out:?}");
|
||
}
|
||
|
||
// Losing a name to a gossiped remote claim releases the local channels that
|
||
// account founded — and a bot assigned to one must part, not linger, even
|
||
// though this cleanup runs outside the dispatch path.
|
||
#[test]
|
||
fn gossip_account_drop_parts_orphaned_channel_bot() {
|
||
use echo_botserv::BotServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-gossipbot.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "test");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("alice".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
e.db.register_channel("#hers", "alice").unwrap();
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: "BOT ADD Bendy bot serv.host Helper".into() });
|
||
let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: "ASSIGN #hers Bendy".into() });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ServiceJoin { channel, .. } if channel == "#hers")), "bot joins the channel");
|
||
|
||
// alice is dropped on another node; the drop arrives by gossip and, being a
|
||
// genuine removal, releases her founderless channel.
|
||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||
e.set_irc_out(tx);
|
||
e.gossip_ingest(LogEntry::for_test("peer", 0, 1, db::Event::AccountDropped { account: "alice".into() })).unwrap();
|
||
|
||
assert!(e.db.channel("#hers").is_none(), "the dropped account's channel is released");
|
||
let parted = std::iter::from_fn(|| rx.try_recv().ok())
|
||
.any(|a| matches!(a, NetAction::ServicePart { channel, .. } if channel == "#hers"));
|
||
assert!(parted, "the assigned bot parts the released channel");
|
||
}
|
||
|
||
// A channel with an assigned bot: the bot (not ChanServ) fronts the in-channel
|
||
// auto-op, so it reads as the bot setting the mode.
|
||
#[test]
|
||
fn assigned_bot_fronts_channel_auto_op() {
|
||
use echo_botserv::BotServ;
|
||
use echo_chanserv::ChanServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-botfront.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "test");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", 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() }),
|
||
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("alice".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: "BOT ADD Bendy bot serv.host Helper".into() });
|
||
let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: "ASSIGN #room Bendy".into() });
|
||
let botuid = out.iter().find_map(|a| match a {
|
||
NetAction::ServiceJoin { uid, channel, .. } if channel == "#room" => Some(uid.clone()),
|
||
_ => None,
|
||
}).expect("bot joins #room");
|
||
|
||
// Founder alice joins: the auto-op is sourced from the bot, not ChanServ.
|
||
let join = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#room".into(), op: false });
|
||
let src = join.iter().find_map(|a| match a {
|
||
NetAction::ChannelMode { from, modes, .. } if modes.contains('o') => Some(from.clone()),
|
||
_ => None,
|
||
}).expect("alice auto-opped");
|
||
assert_eq!(src, botuid, "auto-op fronts as the assigned bot");
|
||
assert_ne!(src, "42SAAAAAB", "not sourced from ChanServ");
|
||
}
|
||
|
||
// !seen nick in a bot channel: the bot answers in the channel with the nick's
|
||
// last message there (fantasy injects the channel).
|
||
#[test]
|
||
fn fantasy_seen_reports_channel_last_message() {
|
||
use echo_botserv::BotServ;
|
||
use echo_chanserv::ChanServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-seenchan.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "test");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", 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() }),
|
||
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("alice".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAA".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
let assign = e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAD".into(), text: "BOT ADD Bendy bot serv.host Helper".into() });
|
||
let _ = assign;
|
||
let out = e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAD".into(), text: "ASSIGN #room Bendy".into() });
|
||
let botuid = out.iter().find_map(|a| match a {
|
||
NetAction::ServiceJoin { uid, channel, .. } if channel == "#room" => Some(uid.clone()),
|
||
_ => None,
|
||
}).expect("bot joins #room");
|
||
|
||
// bob speaks in the channel but never joins it (online, not a member).
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "bob".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "#room".into(), text: "bye bye friends".into() });
|
||
|
||
// Online elsewhere but not in #room -> report his last message, NOT "here".
|
||
let away = e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "#room".into(), text: "!seen bob".into() });
|
||
assert!(
|
||
away.iter().any(|a| matches!(a, NetAction::Privmsg { from, to, text }
|
||
if from == &botuid && to == "#room" && text.contains("bye bye friends") && !text.contains("here right now"))),
|
||
"online-elsewhere bob: last message, not 'here', got {away:?}"
|
||
);
|
||
|
||
// Now bob is actually in the channel -> "here right now".
|
||
e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#room".into(), op: false });
|
||
let here = e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "#room".into(), text: "!seen bob".into() });
|
||
assert!(
|
||
here.iter().any(|a| matches!(a, NetAction::Privmsg { from, to, text }
|
||
if from == &botuid && to == "#room" && text.contains("here right now"))),
|
||
"bob in channel: 'here right now', got {here:?}"
|
||
);
|
||
}
|
||
|
||
// A server split (SQUIT) forgets exactly the users behind it (uids carrying
|
||
// that SID), leaving users on other servers untouched.
|
||
#[test]
|
||
fn squit_forgets_users_behind_the_split() {
|
||
let mut e = engine_with("squit", "alice", "sesame");
|
||
e.db.register("bob", "hunter2", None).unwrap();
|
||
e.handle(NetEvent::UserConnect { uid: "0IRAAAAAB".into(), nick: "alice".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "0XYAAAAAB".into(), nick: "bob".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "0IRAAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
e.handle(NetEvent::Privmsg { from: "0XYAAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY hunter2".into() });
|
||
assert_eq!(e.network.account_of("0IRAAAAAB"), Some("alice"));
|
||
assert_eq!(e.network.account_of("0XYAAAAAB"), Some("bob"));
|
||
|
||
// Server 0IR splits away.
|
||
e.handle(NetEvent::ServerSplit { server: "0IR".into() });
|
||
assert!(e.network.account_of("0IRAAAAAB").is_none(), "users behind the split are forgotten");
|
||
assert_eq!(e.network.account_of("0XYAAAAAB"), Some("bob"), "users on other servers are kept");
|
||
}
|
||
|
||
// A hub's SQUIT cascades: users behind the hub AND behind every server in its
|
||
// subtree are forgotten, while unrelated servers' users are kept.
|
||
#[test]
|
||
fn squit_cascades_to_the_whole_subtree() {
|
||
let mut e = engine_with("squitcascade", "alice", "sesame");
|
||
e.db.register("bob", "hunter2", None).unwrap();
|
||
e.db.register("carol", "pw", None).unwrap();
|
||
// 0HB is a hub under our uplink; 0LF is a leaf behind the hub.
|
||
e.handle(NetEvent::ServerLink { sid: "0HB".into(), parent: "42S".into() });
|
||
e.handle(NetEvent::ServerLink { sid: "0LF".into(), parent: "0HB".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "0HBAAAAAA".into(), nick: "alice".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "0LFAAAAAA".into(), nick: "bob".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "0OKAAAAAA".into(), nick: "carol".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "0HBAAAAAA".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
e.handle(NetEvent::Privmsg { from: "0LFAAAAAA".into(), to: "42SAAAAAA".into(), text: "IDENTIFY hunter2".into() });
|
||
e.handle(NetEvent::Privmsg { from: "0OKAAAAAA".into(), to: "42SAAAAAA".into(), text: "IDENTIFY pw".into() });
|
||
|
||
// The hub splits — it and everything behind it are gone.
|
||
e.handle(NetEvent::ServerSplit { server: "0HB".into() });
|
||
assert!(e.network.account_of("0HBAAAAAA").is_none(), "hub user forgotten");
|
||
assert!(e.network.account_of("0LFAAAAAA").is_none(), "user on a leaf behind the hub forgotten");
|
||
assert_eq!(e.network.account_of("0OKAAAAAA"), Some("carol"), "unrelated server's user kept");
|
||
}
|
||
|
||
// A netburst that replays a user's accountname restores their services login
|
||
// (so a services restart doesn't silently log everyone out); empty = logout.
|
||
#[test]
|
||
fn account_login_event_restores_session() {
|
||
let mut e = engine_with("acctlogin", "alice", "sesame");
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
assert_eq!(e.network.account_of("000AAAAAB"), None, "not recognised before the metadata");
|
||
e.handle(NetEvent::AccountLogin { uid: "000AAAAAB".into(), account: "alice".into() });
|
||
assert_eq!(e.network.account_of("000AAAAAB"), Some("alice"), "login restored from the ircd");
|
||
e.handle(NetEvent::AccountLogin { uid: "000AAAAAB".into(), account: String::new() });
|
||
assert_eq!(e.network.account_of("000AAAAAB"), None, "empty account is a logout");
|
||
}
|
||
|
||
// OperServ REHASH is admin-only and hands the reload to the engine (resolved
|
||
// in the link layer) addressed back to the oper who ran it.
|
||
#[test]
|
||
fn operserv_rehash_is_root_only() {
|
||
use echo_operserv::OperServ;
|
||
let path = std::env::temp_dir().join("echo-opsrehash.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "test");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
db.register("bob", "sesame", 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("alice".to_string(), Privs::default().with(echo_api::Priv::Root));
|
||
opers.insert("bob".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAC".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
|
||
// Root: hands off the reload, addressed back to the requesting oper.
|
||
let a = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAH".into(), text: "REHASH".into() });
|
||
assert!(a.iter().any(|x| matches!(x, NetAction::Rehash { requester, agent } if requester == "000AAAAAB" && agent == "42SAAAAAH")), "{a:?}");
|
||
|
||
// An administrator (not root): denied, no reload handed off.
|
||
let b = e.handle(NetEvent::Privmsg { from: "000AAAAAC".into(), to: "42SAAAAAH".into(), text: "REHASH".into() });
|
||
assert!(!b.iter().any(|x| matches!(x, NetAction::Rehash { .. })), "a non-root oper must not rehash: {b:?}");
|
||
assert!(b.iter().any(|x| matches!(x, NetAction::Notice { text, .. } if text.contains("root"))), "{b:?}");
|
||
}
|
||
|
||
// REHASH re-reads the file, applies the reloadable settings, and announces the
|
||
// reload in the log channel as a DebugServ feed line.
|
||
#[test]
|
||
fn rehash_applies_config_and_announces_in_log_channel() {
|
||
use echo_operserv::OperServ;
|
||
let cfgpath = std::env::temp_dir().join("echo-rehash-fixture.toml");
|
||
std::fs::write(&cfgpath, concat!(
|
||
"[uplink]\nhost = \"127.0.0.1\"\nport = 7000\npassword = \"x\"\n\n",
|
||
"[server]\nname = \"services.test\"\nsid = \"42S\"\ndescription = \"t\"\n",
|
||
"standard_replies = true\nservices_channel = \"#svc\"\n\n",
|
||
"[log]\nchannel = \"#services\"\n\n",
|
||
"[[oper]]\naccount = \"reverse\"\nprivs = [\"admin\"]\n",
|
||
)).unwrap();
|
||
let path = std::env::temp_dir().join("echo-rehash-eng.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "test");
|
||
db.scram_iterations = 4096;
|
||
let mut e = Engine::new(vec![Box::new(OperServ { uid: "42SAAAAAH".into() })], db);
|
||
e.set_sid("42S".into());
|
||
e.set_config_path(cfgpath.to_string_lossy().to_string());
|
||
e.set_debugserv_uid("42SAAAAAO");
|
||
e.set_log_channel(Some("#services".to_string()));
|
||
|
||
let (ok, out) = e.rehash("000AAAAAB", "42SAAAAAH");
|
||
assert!(ok, "a valid config reloads successfully");
|
||
let feed = out.iter().find_map(|a| match a {
|
||
NetAction::Privmsg { from, to, text } if from == "42SAAAAAO" && to == "#services" => Some(text.clone()),
|
||
_ => None,
|
||
}).expect("DebugServ announces the reload in the log channel");
|
||
assert!(feed.contains("reloaded the configuration"), "{feed}");
|
||
assert!(feed.contains("#svc"), "the reloaded services channel is applied and shown: {feed}");
|
||
|
||
// A parse error must report failure (not a false "reloaded") so the control
|
||
// CLI shows ✗, and must keep the running config.
|
||
std::fs::write(&cfgpath, "this is not = valid toml [[[").unwrap();
|
||
let (ok, out) = e.rehash("000AAAAAB", "42SAAAAAH");
|
||
assert!(!ok, "a broken config reports failure");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("REHASH failed"))), "{out:?}");
|
||
let _ = std::fs::remove_file(&cfgpath);
|
||
}
|
||
|
||
// A login that finishes off the handle() path (the link layer's deferred
|
||
// password/keycard verify) must still make echo's OWN account map authoritative.
|
||
// Otherwise re-identifying an already-logged-in user after a services relink —
|
||
// a no-op accountname on the ircd, so nothing is reflected back — leaves them
|
||
// unable to use their access despite a successful login.
|
||
#[test]
|
||
fn deferred_login_makes_account_authoritative() {
|
||
let mut e = engine_with("deferlogin", "foo", "sesame");
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
assert_eq!(e.network.account_of("000AAAAAB"), None);
|
||
// Exactly what the link layer does after the off-thread verify — not via handle().
|
||
let then = echo_api::AuthThen::Identify { uid: "000AAAAAB".into(), agent: "42SAAAAAA".into(), name: "foo".into(), account: "foo".into() };
|
||
let out = e.complete_authenticate(true, then);
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value == "foo")), "emits accountname metadata for the ircd");
|
||
assert_eq!(e.network.account_of("000AAAAAB"), Some("foo"), "and is authoritative in echo's own map");
|
||
}
|
||
|
||
// 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]
|
||
fn gossiped_suspension_logs_out_local_sessions() {
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-gossipsusp.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "test");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db);
|
||
e.set_sid("42S".into());
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
assert_eq!(e.network.account_of("000AAAAAB"), Some("alice"), "logged in first");
|
||
|
||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||
e.set_irc_out(tx);
|
||
// A peer suspends alice; the suspension gossips in.
|
||
let entry = LogEntry::for_test("peer", 0, 1, db::Event::AccountSuspended { account: "alice".into(), by: "peer-oper".into(), reason: "spam".into(), ts: 0, expires: None });
|
||
e.gossip_ingest(entry).unwrap();
|
||
|
||
assert!(e.db.is_suspended("alice"), "suspension applied locally");
|
||
assert!(e.network.account_of("000AAAAAB").is_none(), "the live session is logged out");
|
||
assert!(e.db.exists("alice"), "the account itself is kept");
|
||
let (mut cleared, mut told) = (false, false);
|
||
while let Ok(a) = rx.try_recv() {
|
||
match a {
|
||
NetAction::Metadata { target, key, value } if target == "000AAAAAB" && key == "accountname" && value.is_empty() => cleared = true,
|
||
NetAction::Notice { to, text, .. } if to == "000AAAAAB" && text.contains("suspended") => told = true,
|
||
_ => {}
|
||
}
|
||
}
|
||
assert!(cleared, "accountname metadata cleared on the ircd");
|
||
assert!(told, "the user is told they were suspended");
|
||
}
|
||
|
||
// A network ban that arrives by gossip is pushed to the local ircd right
|
||
// away, not just held for the next netburst — otherwise a banned user could
|
||
// hop to a peer network until its next relink.
|
||
#[test]
|
||
fn gossiped_network_ban_is_pushed_to_the_ircd() {
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-gossipban.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let db = Db::open(&path, "test");
|
||
let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db);
|
||
e.set_sid("42S".into());
|
||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||
e.set_irc_out(tx);
|
||
|
||
// A peer sets a permanent G-line; it must be applied to our ircd now.
|
||
e.gossip_ingest(LogEntry::for_test("peer", 0, 1, db::Event::AkillAdded {
|
||
kind: "G".into(), mask: "*@evil.example".into(), setter: "peer-oper".into(), reason: "spam".into(), ts: 0, expires: None,
|
||
})).unwrap();
|
||
let added = std::iter::from_fn(|| rx.try_recv().ok())
|
||
.any(|a| matches!(a, NetAction::AddLine { kind, mask, duration, .. } if kind == "G" && mask == "*@evil.example" && duration == 0));
|
||
assert!(added, "gossiped ban pushed to the ircd immediately");
|
||
|
||
// A peer lifts it; the ircd is told to drop the line.
|
||
e.gossip_ingest(LogEntry::for_test("peer", 1, 2, db::Event::AkillRemoved {
|
||
kind: "G".into(), mask: "*@evil.example".into(),
|
||
})).unwrap();
|
||
let lifted = std::iter::from_fn(|| rx.try_recv().ok())
|
||
.any(|a| matches!(a, NetAction::DelLine { kind, mask } if kind == "G" && mask == "*@evil.example"));
|
||
assert!(lifted, "gossiped ban lift removes the line from the ircd");
|
||
}
|
||
|
||
// Losing a registration conflict logs out the local session on that name and
|
||
// notifies them over the services-initiated outbound path.
|
||
#[test]
|
||
fn lost_conflict_logs_out_local_session() {
|
||
use echo_chanserv::ChanServ;
|
||
let path = std::env::temp_dir().join("echo-lostconf.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "test");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 12345 };
|
||
let cs = ChanServ { uid: "42SAAAAAB".into() };
|
||
let mut e = Engine::new(vec![Box::new(ns), Box::new(cs)], db);
|
||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||
e.set_irc_out(tx);
|
||
// A local user logs into alice and registers a channel as founder.
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
assert_eq!(e.network.account_of("000AAAAAB"), Some("alice"), "logged in before the conflict");
|
||
e.db.register_channel("#hers", "alice").unwrap();
|
||
|
||
// An earlier claim from another node wins and takes the name over.
|
||
let winner = db::Account {
|
||
name: "alice".into(), email: None,
|
||
ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, language: None, vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, oper_note: None,
|
||
};
|
||
let entry = LogEntry::for_test("peer", 0, 1, db::Event::AccountRegistered(Box::new(winner)));
|
||
e.gossip_ingest(entry).unwrap();
|
||
|
||
// The local session is logged out (its credential moved to the winner), but
|
||
// the channel it founded is KEPT — the account still exists, home just moved.
|
||
assert!(e.network.account_of("000AAAAAB").is_none(), "session cleared after losing the name");
|
||
assert!(e.db.channel("#hers").is_some(), "the channel is kept on a takeover (the account still exists)");
|
||
// The outbound path got a logout and a notice, but NO -r and NO release notice.
|
||
let (mut logout, mut notice, mut unreg, mut released) = (false, false, false, false);
|
||
while let Ok(a) = rx.try_recv() {
|
||
match a {
|
||
NetAction::Metadata { target, key, value } if target == "000AAAAAB" && key == "accountname" && value.is_empty() => logout = true,
|
||
NetAction::Notice { to, text, .. } if to == "000AAAAAB" && text.contains("collided") => notice = true,
|
||
NetAction::Notice { to, text, .. } if to == "000AAAAAB" && text.contains("released") => released = true,
|
||
NetAction::ChannelMode { channel, modes, .. } if channel == "#hers" && modes == "-r" => unreg = true,
|
||
_ => {}
|
||
}
|
||
}
|
||
assert!(logout, "a logout must be pushed to the uplink");
|
||
assert!(notice, "the user must be told why");
|
||
assert!(!unreg, "a takeover must NOT de-register the channel");
|
||
assert!(!released, "no channels are released on a takeover");
|
||
}
|
||
|
||
// NickServ INFO shows registration details (email only to the owner); ALIST
|
||
// lists the channels the account founds or has access on.
|
||
#[test]
|
||
fn nickserv_info_and_alist() {
|
||
use echo_chanserv::ChanServ;
|
||
let path = std::env::temp_dir().join("echo-nsinfo.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "test");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
db.register("bob", "hunter2", None).unwrap();
|
||
db.register_channel("#a", "alice").unwrap();
|
||
let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 };
|
||
let cs = ChanServ { uid: "42SAAAAAB".into() };
|
||
let mut e = Engine::new(vec![Box::new(ns), Box::new(cs)], db);
|
||
let to_ns = |e: &mut Engine, text: &str| {
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: text.into() })
|
||
};
|
||
let notice = |out: &[NetAction], needle: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle)));
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
|
||
let info = to_ns(&mut e, "INFO");
|
||
assert!(notice(&info, "Information for \x02alice\x02"));
|
||
assert!(notice(&info, "Registered"));
|
||
assert!(notice(&info, "Email"), "owner sees their email line: {info:?}");
|
||
// alice is identified, so her INFO reports her as online.
|
||
assert!(notice(&info, "Last seen"), "INFO has a last-seen line: {info:?}");
|
||
assert!(notice(&info, "online"), "an identified account reads as online: {info:?}");
|
||
// bob is registered but not connected: last-seen shows a time, not online.
|
||
let binfo = to_ns(&mut e, "INFO bob");
|
||
assert!(notice(&binfo, "Last seen"), "offline account still shows last-seen: {binfo:?}");
|
||
assert!(!notice(&binfo, "online"), "an offline account is not reported online: {binfo:?}");
|
||
assert!(notice(&to_ns(&mut e, "INFO ghost"), "isn't registered"));
|
||
assert!(notice(&to_ns(&mut e, "ALIST"), "#a"));
|
||
}
|
||
|
||
// A user authenticated via SASL during registration never runs the IDENTIFY
|
||
// path, so the engine applies their auto-join and vhost when they connect.
|
||
#[test]
|
||
fn sasl_login_applies_ajoin_and_vhost_on_connect() {
|
||
let path = std::env::temp_dir().join("echo-saslajoin.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
db.register_channel("#auto", "alice").unwrap();
|
||
db.ajoin_add("alice", "#auto", "").unwrap();
|
||
db.set_vhost("alice", "cool.example", "boss", None).unwrap();
|
||
let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db);
|
||
e.set_sid("42S".into());
|
||
|
||
// SASL PLAIN logs uid 000AAAAAB into alice during registration.
|
||
e.handle(NetEvent::Sasl { client: "000AAAAAB".into(), agent: "*".into(), mode: "S".into(), data: vec!["PLAIN".into()] });
|
||
let out = e.handle(NetEvent::Sasl { client: "000AAAAAB".into(), agent: "*".into(), mode: "C".into(), data: vec![plain(b"", b"alice", b"sesame")] });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value == "alice")), "sasl login set the account: {out:?}");
|
||
|
||
// On connect the SASL user is force-joined and their vhost is applied,
|
||
// exactly as an IDENTIFY would have done.
|
||
let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ForceJoin { uid, channel, .. } if uid == "000AAAAAB" && channel == "#auto")), "auto-join applied on SASL connect: {out:?}");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::SetHost { uid, host } if uid == "000AAAAAB" && host == "cool.example")), "vhost applied on SASL connect: {out:?}");
|
||
|
||
// A plain (unauthenticated) connection gets neither.
|
||
let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
assert!(!out.iter().any(|a| matches!(a, NetAction::ForceJoin { .. } | NetAction::SetHost { .. })), "no effects for an unauthenticated connect: {out:?}");
|
||
}
|
||
|
||
// SET HIDE STATUS keeps the last-seen/online line to the owner and opers.
|
||
#[test]
|
||
fn nickserv_set_hide_status() {
|
||
let path = std::env::temp_dir().join("echo-nshide.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "test");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
db.register("bob", "hunter2", None).unwrap();
|
||
let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 };
|
||
let mut e = Engine::new(vec![Box::new(ns)], db);
|
||
let send = |e: &mut Engine, uid: &str, text: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: text.into() });
|
||
let notice = |out: &[NetAction], needle: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle)));
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAA".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
send(&mut e, "000AAAAAA", "IDENTIFY sesame");
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "bob".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
send(&mut e, "000AAAAAB", "IDENTIFY hunter2");
|
||
|
||
// Default: a stranger (bob) sees alice's last-seen line.
|
||
assert!(notice(&send(&mut e, "000AAAAAB", "INFO alice"), "Last seen"), "last-seen public by default");
|
||
|
||
// alice hides it: strangers lose the line, owner keeps it.
|
||
assert!(notice(&send(&mut e, "000AAAAAA", "SET HIDE STATUS ON"), "hidden"), "hide confirmed");
|
||
let stranger = send(&mut e, "000AAAAAB", "INFO alice");
|
||
assert!(notice(&stranger, "Registered"), "stranger still sees registration: {stranger:?}");
|
||
assert!(!notice(&stranger, "Last seen"), "stranger no longer sees last-seen: {stranger:?}");
|
||
assert!(notice(&send(&mut e, "000AAAAAA", "INFO"), "Last seen"), "owner still sees their own last-seen");
|
||
|
||
// Turn it off: public again.
|
||
assert!(notice(&send(&mut e, "000AAAAAA", "SET HIDE STATUS OFF"), "visible"), "unhide confirmed");
|
||
assert!(notice(&send(&mut e, "000AAAAAB", "INFO alice"), "Last seen"), "last-seen public again");
|
||
}
|
||
|
||
// SET EMAIL stores an email; SET PASSWORD defers derivation, and once
|
||
// completed the new password authenticates and the old one no longer does.
|
||
#[test]
|
||
fn nickserv_set_password_and_email() {
|
||
let mut e = engine_with("nsset", "alice", "sesame");
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
let to_ns = |e: &mut Engine, text: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: text.into() });
|
||
let notice = |out: &[NetAction], needle: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle)));
|
||
|
||
assert!(notice(&to_ns(&mut e, "SET EMAIL alice@example.org"), "updated"));
|
||
assert!(notice(&to_ns(&mut e, "INFO"), "alice@example.org"), "owner INFO shows the email");
|
||
|
||
let out = to_ns(&mut e, "SET PASSWORD newsecret");
|
||
let deferred = out.iter().find_map(|a| match a {
|
||
NetAction::DeferPassword { account, password, agent, uid } => Some((account.clone(), password.clone(), agent.clone(), uid.clone())),
|
||
_ => None,
|
||
});
|
||
let (account, password, agent, uid) = deferred.expect("SET PASSWORD defers derivation");
|
||
assert_eq!(password, "newsecret");
|
||
let creds = Db::derive_credentials(&password, 4096);
|
||
e.complete_password_change(&account, creds, &agent, &uid);
|
||
assert!(e.db.authenticate("alice", "newsecret").is_some(), "new password works");
|
||
assert!(e.db.authenticate("alice", "sesame").is_none(), "old password rejected");
|
||
}
|
||
|
||
// DROP deletes the account, releases and drops the channels it founded, and
|
||
// logs the session out; a wrong password is refused.
|
||
#[test]
|
||
fn nickserv_drop_account() {
|
||
let mut e = engine_with("nsdrop", "alice", "sesame");
|
||
e.db.register_channel("#a", "alice").unwrap();
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
let to_ns = |e: &mut Engine, text: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: text.into() });
|
||
let notice = |out: &[NetAction], needle: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle)));
|
||
|
||
assert!(notice(&to_ns(&mut e, "DROP wrongpass"), "Invalid password"));
|
||
assert!(e.db.account("alice").is_some(), "wrong password keeps the account");
|
||
|
||
let out = to_ns(&mut e, "DROP sesame");
|
||
assert!(notice(&out, "has been dropped"));
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#a" && modes == "-r")), "channel released: {out:?}");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Metadata { target, key, value } if target == "000AAAAAB" && key == "accountname" && value.is_empty())), "session logged out: {out:?}");
|
||
assert!(e.db.account("alice").is_none(), "account gone");
|
||
assert!(e.db.channel("#a").is_none(), "founded channel gone");
|
||
}
|
||
|
||
// GROUP links a nick to an account so it identifies there too; GLIST lists the
|
||
// aliases; UNGROUP removes one, after which the nick no longer resolves.
|
||
#[test]
|
||
fn nickserv_grouped_nicks() {
|
||
let mut e = engine_with("nsgroup", "alice", "sesame");
|
||
let to_ns = |e: &mut Engine, uid: &str, text: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: text.into() });
|
||
let notice = |out: &[NetAction], needle: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle)));
|
||
// A user on a different nick groups it to alice by password.
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "ali".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
assert!(notice(&to_ns(&mut e, "000AAAAAB", "GROUP alice sesame"), "grouped to \x02alice\x02"));
|
||
// Identifying as the grouped nick logs into alice.
|
||
let out = to_ns(&mut e, "000AAAAAB", "IDENTIFY sesame");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value == "alice")), "grouped nick identifies to alice: {out:?}");
|
||
assert!(notice(&to_ns(&mut e, "000AAAAAB", "GLIST"), "ali"));
|
||
// Ungroup it; a fresh session on that nick no longer resolves.
|
||
assert!(notice(&to_ns(&mut e, "000AAAAAB", "UNGROUP ali"), "no longer grouped"));
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "ali".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
assert!(notice(&to_ns(&mut e, "000AAAAAC", "IDENTIFY sesame"), "isn't registered"));
|
||
}
|
||
|
||
// RESETPASS emails a code, and that code + a new password completes the reset;
|
||
// the new password then authenticates and the old one no longer does.
|
||
#[test]
|
||
fn nickserv_resetpass() {
|
||
let mut e = engine_with("nsreset", "alice", "sesame");
|
||
e.db.set_email_enabled(true);
|
||
e.db.set_email("alice", Some("alice@example.org".into())).unwrap();
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "someone".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
let to_ns = |e: &mut Engine, text: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: text.into() });
|
||
|
||
// Request: a code is emailed to the address on file.
|
||
let out = to_ns(&mut e, "RESETPASS alice");
|
||
let (to, body) = out.iter().find_map(|a| match a {
|
||
NetAction::SendEmail { to, text, .. } => Some((to.clone(), text.clone())),
|
||
_ => None,
|
||
}).expect("a reset code is emailed");
|
||
assert_eq!(to, "alice@example.org");
|
||
let code = body.split("is: ").nth(1).unwrap().split_whitespace().next().unwrap().to_string();
|
||
|
||
// Complete: the code + a new password defers the change.
|
||
let out = to_ns(&mut e, &format!("RESETPASS alice {code} brandnew"));
|
||
let (account, password, agent, uid) = out.iter().find_map(|a| match a {
|
||
NetAction::DeferPassword { account, password, agent, uid } => Some((account.clone(), password.clone(), agent.clone(), uid.clone())),
|
||
_ => None,
|
||
}).expect("a valid code defers the new password");
|
||
assert_eq!(password, "brandnew");
|
||
e.complete_password_change(&account, Db::derive_credentials(&password, 4096), &agent, &uid);
|
||
assert!(e.db.authenticate("alice", "brandnew").is_some(), "new password works");
|
||
assert!(e.db.authenticate("alice", "sesame").is_none(), "old password rejected");
|
||
|
||
// A used/wrong code is refused.
|
||
assert!(to_ns(&mut e, "RESETPASS alice 000000 x").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Invalid or expired"))));
|
||
}
|
||
|
||
// RESETPASS is throttled per account so it can't be used to email-bomb the
|
||
// address on file: a second request inside the cooldown sends no email.
|
||
#[test]
|
||
fn resetpass_is_throttled_against_email_bombing() {
|
||
let mut e = engine_with("nsresetrl", "alice", "sesame");
|
||
e.db.set_email_enabled(true);
|
||
e.db.set_email("alice", Some("alice@example.org".into())).unwrap();
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "someone".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
let to_ns = |e: &mut Engine, text: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: text.into() });
|
||
|
||
assert!(to_ns(&mut e, "RESETPASS alice").iter().any(|a| matches!(a, NetAction::SendEmail { .. })), "first request emails a code");
|
||
let out = to_ns(&mut e, "RESETPASS alice");
|
||
assert!(!out.iter().any(|a| matches!(a, NetAction::SendEmail { .. })), "second immediate request sends no email: {out:?}");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("wait"))), "and tells the caller to wait: {out:?}");
|
||
}
|
||
|
||
// With email configured, registering with an address creates an unverified
|
||
// account and emails a confirmation code; CONFIRM <code> verifies it.
|
||
#[test]
|
||
fn nickserv_confirm() {
|
||
let path = std::env::temp_dir().join("echo-nsconfirm.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "test");
|
||
db.scram_iterations = 4096;
|
||
db.set_email_enabled(true);
|
||
let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "newbie".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
|
||
// REGISTER with an email defers; complete it as the link layer would.
|
||
let reg = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "REGISTER correcthorse newbie@example.org".into() });
|
||
let (account, password, email, reply) = reg.iter().find_map(|a| match a {
|
||
NetAction::DeferRegister { account, password, email, reply } => Some((account.clone(), password.clone(), email.clone(), reply.clone())),
|
||
_ => None,
|
||
}).expect("register defers");
|
||
let out = e.complete_register(&account, Db::derive_credentials(&password, 4096), email, reply);
|
||
assert!(!e.db.is_verified("newbie"), "registering with email starts unverified");
|
||
let body = out.iter().find_map(|a| match a {
|
||
NetAction::SendEmail { text, .. } => Some(text.clone()),
|
||
_ => None,
|
||
}).expect("a confirm code is emailed");
|
||
let code = body.split("CONFIRM ").nth(1).unwrap().split_whitespace().next().unwrap().to_string();
|
||
|
||
// CONFIRM verifies (the user was auto-logged-in by register).
|
||
let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: format!("CONFIRM {code}") });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("now confirmed"))), "{out:?}");
|
||
assert!(e.db.is_verified("newbie"), "confirmed after CONFIRM");
|
||
}
|
||
|
||
// draft/account-registration over the ircd relay: REGISTER defers, replies
|
||
// verification_required and emails a code; VERIFY confirms; STATUS reports it.
|
||
#[test]
|
||
fn account_registration_relay_verify_flow() {
|
||
let path = std::env::temp_dir().join("echo-acctreg-relay.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "test");
|
||
db.scram_iterations = 4096;
|
||
db.set_email_enabled(true);
|
||
let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db);
|
||
|
||
// REGISTER <account> <email> :<password> relayed from the ircd.
|
||
let reg = e.account_request("r1".into(), "leaf".into(), "REGISTER".into(), "neo".into(), "neo@example.org".into(), "trinity".into());
|
||
let (account, password, email, reply) = reg.iter().find_map(|a| match a {
|
||
NetAction::DeferRegister { account, password, email, reply } => Some((account.clone(), password.clone(), email.clone(), reply.clone())),
|
||
_ => None,
|
||
}).expect("relay register defers");
|
||
let out = e.complete_register(&account, Db::derive_credentials(&password, 4096), email, reply);
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::AccountResponse { status, .. } if status == "verification_required")), "{out:?}");
|
||
assert!(!e.db.is_verified("neo"), "unverified until the code is confirmed");
|
||
let body = out.iter().find_map(|a| match a { NetAction::SendEmail { text, .. } => Some(text.clone()), _ => None }).expect("a code is emailed on the relay path too");
|
||
let code = body.split("CONFIRM ").nth(1).unwrap().split_whitespace().next().unwrap().to_string();
|
||
|
||
// VERIFY with the code succeeds; a stale/duplicate VERIFY then fails.
|
||
let ok = e.account_request("r2".into(), "leaf".into(), "VERIFY".into(), "neo".into(), code, String::new());
|
||
assert!(ok.iter().any(|a| matches!(a, NetAction::AccountResponse { status, .. } if status == "success")), "{ok:?}");
|
||
assert!(e.db.is_verified("neo"), "verified after VERIFY");
|
||
let again = e.account_request("r3".into(), "leaf".into(), "VERIFY".into(), "neo".into(), "000000".into(), String::new());
|
||
assert!(again.iter().any(|a| matches!(a, NetAction::AccountResponse { code, .. } if code == "INVALID_CODE")), "{again:?}");
|
||
|
||
// STATUS reports verified; VERIFY on an unknown account is a clean error.
|
||
let st = e.account_request("r4".into(), "leaf".into(), "STATUS".into(), "neo".into(), String::new(), String::new());
|
||
assert!(st.iter().any(|a| matches!(a, NetAction::AccountResponse { code, .. } if code == "VERIFIED")), "{st:?}");
|
||
let unknown = e.account_request("r5".into(), "leaf".into(), "VERIFY".into(), "ghost".into(), "x".into(), String::new());
|
||
assert!(unknown.iter().any(|a| matches!(a, NetAction::AccountResponse { code, .. } if code == "ACCOUNT_UNKNOWN")), "{unknown:?}");
|
||
}
|
||
|
||
// Nick protection: a user on a registered nick is prompted, then renamed to a
|
||
// guest if they don't identify before the grace period; the burst is exempt.
|
||
#[test]
|
||
fn nick_protection_enforces_after_grace() {
|
||
let mut e = engine_with("nickprot", "alice", "sesame");
|
||
e.set_sid("42S".into());
|
||
e.now_override = Some(1000);
|
||
|
||
// During the uplink burst (not yet synced) nobody is enforced.
|
||
let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAZ".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
assert!(!out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("registered"))), "no enforcement during burst: {out:?}");
|
||
e.handle(NetEvent::Quit { uid: "000AAAAAZ".into(), reason: String::new() });
|
||
e.handle(NetEvent::EndBurst);
|
||
|
||
// Live: an unidentified user lands on the registered nick and is prompted.
|
||
let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { to, text, .. } if to == "000AAAAAB" && text.contains("registered"))), "prompted to identify: {out:?}");
|
||
|
||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||
e.set_irc_out(tx);
|
||
e.now_override = Some(1000 + ENFORCE_GRACE - 1);
|
||
e.enforce_sweep();
|
||
assert!(rx.try_recv().is_err(), "no rename before the grace period");
|
||
e.now_override = Some(1000 + ENFORCE_GRACE + 1);
|
||
e.enforce_sweep();
|
||
let renamed = std::iter::from_fn(|| rx.try_recv().ok())
|
||
.any(|a| matches!(a, NetAction::ForceNick { uid, nick } if uid == "000AAAAAB" && nick.starts_with("Guest")));
|
||
assert!(renamed, "unidentified user on a registered nick is renamed after grace");
|
||
}
|
||
|
||
// NickServ SET KILL OFF opts an account out of nick protection: an
|
||
// unidentified user on the nick is neither prompted nor renamed.
|
||
#[test]
|
||
fn nick_protection_disabled_by_set_kill_off() {
|
||
let mut e = engine_with("nickkill", "alice", "sesame");
|
||
e.set_sid("42S".into());
|
||
e.now_override = Some(1000);
|
||
e.handle(NetEvent::EndBurst);
|
||
|
||
// Owner identifies (holding the nick) and turns protection off.
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAA".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
let out = e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAA".into(), text: "SET KILL OFF".into() });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("off"))), "KILL OFF confirmed: {out:?}");
|
||
assert!(!e.db.account_wants_protect("alice"), "protection preference stored off");
|
||
e.handle(NetEvent::Quit { uid: "000AAAAAA".into(), reason: String::new() });
|
||
|
||
// An unidentified intruder takes the nick: no prompt, and no rename at grace.
|
||
let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
assert!(!out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("registered"))), "no prompt when KILL is off: {out:?}");
|
||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||
e.set_irc_out(tx);
|
||
e.now_override = Some(1000 + ENFORCE_GRACE + 1);
|
||
e.enforce_sweep();
|
||
assert!(rx.try_recv().is_err(), "no rename when KILL is off");
|
||
|
||
// Turning it back on restores enforcement for the next arrival.
|
||
e.handle(NetEvent::Quit { uid: "000AAAAAB".into(), reason: String::new() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAA".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAA".into(), text: "SET KILL ON".into() });
|
||
assert!(e.db.account_wants_protect("alice"), "protection preference stored on");
|
||
e.handle(NetEvent::Quit { uid: "000AAAAAA".into(), reason: String::new() });
|
||
let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("registered"))), "prompt returns when KILL back on: {out:?}");
|
||
}
|
||
|
||
// Identifying before the grace period cancels the pending rename.
|
||
#[test]
|
||
fn nick_protection_cancelled_by_identify() {
|
||
let mut e = engine_with("nickprot2", "alice", "sesame");
|
||
e.set_sid("42S".into());
|
||
e.now_override = Some(1000);
|
||
e.handle(NetEvent::EndBurst);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
|
||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||
e.set_irc_out(tx);
|
||
e.now_override = Some(1000 + ENFORCE_GRACE + 1);
|
||
e.enforce_sweep();
|
||
assert!(rx.try_recv().is_err(), "an identified user is never renamed");
|
||
}
|
||
|
||
// GHOST renames off a session using a nick the caller owns.
|
||
#[test]
|
||
fn nickserv_ghost() {
|
||
let mut e = engine_with("nsghost", "alice", "sesame");
|
||
let to_ns = |e: &mut Engine, uid: &str, text: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: text.into() });
|
||
// A ghost sits on nick alice; the owner identifies from another nick.
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "owner".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAC".into(), to: "42SAAAAAA".into(), text: "IDENTIFY alice sesame".into() });
|
||
let out = to_ns(&mut e, "000AAAAAC", "GHOST alice");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ForceNick { uid, .. } if uid == "000AAAAAB")), "ghost renamed off: {out:?}");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("freed"))), "{out:?}");
|
||
}
|
||
|
||
// IDENTIFY accepts the two-arg account+password form: log into a named
|
||
// account even when the current nick differs; a wrong password is refused.
|
||
#[test]
|
||
fn identify_two_arg_account_form() {
|
||
let mut e = engine_with("twoarg", "realacct", "sesame");
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "someguest".into(), host: "host.example".into() , ip: "0.0.0.0".into() });
|
||
let out = e.handle(NetEvent::Privmsg {
|
||
from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY realacct sesame".into(),
|
||
});
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value == "realacct")), "two-arg identify logs into the named account: {out:?}");
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "other".into(), host: "host.example".into() , ip: "0.0.0.0".into() });
|
||
let bad = e.handle(NetEvent::Privmsg {
|
||
from: "000AAAAAC".into(), to: "42SAAAAAA".into(), text: "IDENTIFY realacct wrongpw".into(),
|
||
});
|
||
assert!(bad.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Invalid password"))), "{bad:?}");
|
||
assert!(!bad.iter().any(|a| matches!(a, NetAction::Metadata { .. })), "wrong password must not log in: {bad:?}");
|
||
|
||
// An unregistered account says so, rather than "Invalid password".
|
||
let missing = e.handle(NetEvent::Privmsg {
|
||
from: "000AAAAAC".into(), to: "42SAAAAAA".into(), text: "IDENTIFY ghost whatever".into(),
|
||
});
|
||
assert!(missing.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("isn't registered"))), "{missing:?}");
|
||
}
|
||
|
||
// SECUREOPS strips channel-operator status from a user without op-level access.
|
||
#[test]
|
||
fn secureops_strips_op_from_a_user_without_access() {
|
||
use echo_chanserv::ChanServ;
|
||
let path = std::env::temp_dir().join("echo-secureops.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.register_channel("#c", "boss").unwrap();
|
||
db.set_channel_setting("#c", db::ChanSetting::SecureOps, true).unwrap();
|
||
let mut e = Engine::new(vec![Box::new(ChanServ { uid: "42SAAAAAB".into() })], db);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "rando".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
// rando holds no access; being opped triggers a -o from ChanServ.
|
||
let out = e.handle(NetEvent::ChannelOp { channel: "#c".into(), uid: "000AAAAAB".into(), op: true });
|
||
assert!(
|
||
out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "-o 000AAAAAB")),
|
||
"SECUREOPS strips the op: {out:?}"
|
||
);
|
||
// With SECUREOPS off, the same op is left alone.
|
||
e.db.set_channel_setting("#c", db::ChanSetting::SecureOps, false).unwrap();
|
||
let out = e.handle(NetEvent::ChannelOp { channel: "#c".into(), uid: "000AAAAAB".into(), op: true });
|
||
assert!(out.is_empty(), "no enforcement when SECUREOPS is off: {out:?}");
|
||
}
|
||
|
||
// ChanServ UP applies the status your access entitles you to; DOWN strips it.
|
||
#[test]
|
||
fn chanserv_up_down_status() {
|
||
use echo_chanserv::ChanServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-csupdown.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
db.register_channel("#c", "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,
|
||
);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false });
|
||
|
||
let cs = |e: &mut Engine, text: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: text.into() });
|
||
let out = cs(&mut e, "UP #c");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+qo 000AAAAAB 000AAAAAB")), "UP applies the founder's +qo: {out:?}");
|
||
let out = cs(&mut e, "DOWN #c");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes.starts_with("-qaohv"))), "DOWN strips status: {out:?}");
|
||
let out = cs(&mut e, "UP #nope");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("isn't registered"))), "unknown channel refused: {out:?}");
|
||
}
|
||
|
||
// A SOP auto-gets op + protect (+ao, so it shows @ and outranks AOP) on join,
|
||
// and secureops never strips it — op access is a capability, not literal "+o".
|
||
#[test]
|
||
fn sop_join_gets_protect_and_op_and_survives_secureops() {
|
||
use echo_chanserv::ChanServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-sopjoin.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
db.register("mik", "sesame", None).unwrap();
|
||
db.register_channel("#c", "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,
|
||
);
|
||
for (uid, nick) in [("000AAAAAA", "alice"), ("000AAAAAB", "mik")] {
|
||
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 sesame".into() });
|
||
}
|
||
// Founder makes mik a SOP and turns secureops on.
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAB".into(), text: "SOP #c ADD mik".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAB".into(), text: "SET #c SECUREOPS ON".into() });
|
||
|
||
// On join mik is granted op + protect (+ao, the nick once per mode).
|
||
let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false });
|
||
assert!(
|
||
out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+ao 000AAAAAB 000AAAAAB")),
|
||
"SOP auto-gets op + protect: {out:?}"
|
||
);
|
||
// Arriving already opped, secureops must NOT strip a SOP's op.
|
||
let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: true });
|
||
assert!(
|
||
!out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes.starts_with("-o"))),
|
||
"secureops keeps a SOP opped: {out:?}"
|
||
);
|
||
}
|
||
|
||
// DEOP with no target deops yourself; PEACE (no acting against equal-or-higher
|
||
// access) must NOT block that — you can always drop your own status.
|
||
#[test]
|
||
fn chanserv_deop_self_allowed_under_peace() {
|
||
use echo_chanserv::ChanServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-csdeopself.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
db.register_channel("#c", "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,
|
||
);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: true });
|
||
let cs = |e: &mut Engine, text: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: text.into() });
|
||
cs(&mut e, "SET #c PEACE ON");
|
||
let out = cs(&mut e, "DEOP #c"); // no target = self
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "-o 000AAAAAB")), "self-deop applies -o: {out:?}");
|
||
assert!(!out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("PEACE"))), "self-deop must not be PEACE-blocked: {out:?}");
|
||
}
|
||
|
||
// The ordered rank in action: with PEACE on, an AOP can't kick a SOP, but a
|
||
// SOP (higher tier) can kick an AOP. The old collapsed u8 rank made SOP and
|
||
// AOP equal, so neither could act on the other.
|
||
#[test]
|
||
fn peace_lets_sop_act_on_aop_not_the_reverse() {
|
||
use echo_chanserv::ChanServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-peacetiers.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, "sesame", None).unwrap();
|
||
}
|
||
db.register_channel("#c", "alice").unwrap();
|
||
db.access_add("#c", "bob", "sop").unwrap();
|
||
db.access_add("#c", "carol", "op").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,
|
||
);
|
||
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 sesame".into() });
|
||
e.handle(NetEvent::Join { uid: uid.into(), channel: "#c".into(), op: true });
|
||
}
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAB".into(), text: "SET #c PEACE ON".into() });
|
||
|
||
let kick = |e: &mut Engine, from: &str, target: &str| {
|
||
e.handle(NetEvent::Privmsg { from: from.into(), to: "42SAAAAAB".into(), text: format!("KICK #c {target}") })
|
||
};
|
||
let blocked = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("PEACE")));
|
||
let kicked = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Kick { .. }));
|
||
|
||
// AOP carol tries to kick SOP bob -> PEACE blocks it.
|
||
let out = kick(&mut e, "000AAAAAC", "bob");
|
||
assert!(blocked(&out) && !kicked(&out), "AOP can't kick a SOP under PEACE: {out:?}");
|
||
// SOP bob kicks AOP carol -> allowed (SOP outranks AOP).
|
||
let out = kick(&mut e, "000AAAAAB", "carol");
|
||
assert!(kicked(&out) && !blocked(&out), "SOP can kick an AOP: {out:?}");
|
||
}
|
||
|
||
// Regression: FLAGS-editing a HOP/SOP entry must not corrupt it. Adding +v to a
|
||
// halfop once escalated them to founder — the preset name "halfop" got reparsed
|
||
// as flags (f,o,h,a — 'f' = founder). It must stay in the halfop tier.
|
||
#[test]
|
||
fn flags_edit_of_a_preset_entry_does_not_escalate() {
|
||
use echo_chanserv::ChanServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-flagsesc.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
db.register("bob", "sesame", None).unwrap();
|
||
db.register_channel("#c", "alice").unwrap();
|
||
db.access_add("#c", "bob", "halfop").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,
|
||
);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: "FLAGS #c bob +v".into() });
|
||
|
||
let ci = e.db.channel("#c").unwrap();
|
||
assert!(!ci.is_op("bob"), "halfop + voice must not gain op or founder");
|
||
let lvl = ci.access.iter().find(|a| a.account == "bob").unwrap().level.clone();
|
||
assert!(!lvl.contains('f'), "must not acquire the founder flag: got {lvl:?}");
|
||
}
|
||
|
||
// NickServ LIST is auspex-gated and glob-matches; UPDATE refreshes a session.
|
||
#[test]
|
||
fn nickserv_list_and_update() {
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-nslistupd.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("alice", "pw", None).unwrap();
|
||
db.register("albert", "pw", None).unwrap();
|
||
let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db);
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("staff".to_string(), Privs::default().with(echo_api::Priv::Auspex));
|
||
e.set_opers(opers);
|
||
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
let notice = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "staff".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "rando".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
|
||
assert!(notice(&ns(&mut e, "000AAAAAB", "LIST *"), "Access denied"), "non-oper LIST refused");
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAC".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
let out = ns(&mut e, "000AAAAAC", "LIST al*");
|
||
assert!(notice(&out, "alice") && notice(&out, "albert"), "LIST al* shows the matches: {out:?}");
|
||
assert!(!notice(&out, "\x02staff\x02"), "LIST al* excludes non-matches: {out:?}");
|
||
|
||
let out = ns(&mut e, "000AAAAAC", "UPDATE");
|
||
assert!(notice(&out, "refreshed"), "UPDATE confirms: {out:?}");
|
||
}
|
||
|
||
// MemoServ SENDALL (admin) drops a memo on every registered account.
|
||
#[test]
|
||
fn memoserv_sendall() {
|
||
use echo_memoserv::MemoServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-mssendall.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "pw", None).unwrap();
|
||
db.register("alice", "pw", None).unwrap();
|
||
db.register("bob", "pw", None).unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(MemoServ { uid: "42SAAAAAE".into() }),
|
||
],
|
||
db,
|
||
);
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY pw".into() });
|
||
let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAE".into(), text: "SENDALL hello everyone".into() });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("sent to") && text.contains('3'))), "sent to all 3: {out:?}");
|
||
assert_eq!(e.db.unread_memos("alice"), 1);
|
||
assert_eq!(e.db.unread_memos("bob"), 1);
|
||
}
|
||
|
||
// MemoServ STAFF memos every operator (config opers here), and only the
|
||
// operators — a plain account gets nothing.
|
||
#[test]
|
||
fn memoserv_staff_memos_operators() {
|
||
use echo_memoserv::MemoServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-msstaff.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "pw", None).unwrap();
|
||
db.register("mod", "pw", None).unwrap();
|
||
db.register("alice", "pw", None).unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(MemoServ { uid: "42SAAAAAE".into() }),
|
||
],
|
||
db,
|
||
);
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
opers.insert("mod".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY pw".into() });
|
||
let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAE".into(), text: "STAFF heads up team".into() });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("2"))), "memoed 2 opers: {out:?}");
|
||
assert_eq!(e.db.unread_memos("mod"), 1, "the other operator got it");
|
||
assert_eq!(e.db.unread_memos("alice"), 0, "a non-operator did not");
|
||
}
|
||
|
||
// NickServ SASET lets an operator edit another account's settings, and is
|
||
// refused to non-operators.
|
||
#[test]
|
||
fn nickserv_saset_greet_by_operator() {
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-nssaset.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "pw", None).unwrap();
|
||
db.register("alice", "pw", None).unwrap();
|
||
let mut e = Engine::new(
|
||
vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })],
|
||
db,
|
||
);
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
let notice = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
ns(&mut e, "000AAAAAB", "IDENTIFY pw");
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
ns(&mut e, "000AAAAAC", "IDENTIFY pw");
|
||
|
||
// A non-operator cannot use it.
|
||
assert!(notice(&ns(&mut e, "000AAAAAC", "SASET boss GREET nope"), "Access denied"), "non-oper refused");
|
||
// The operator sets alice's greet.
|
||
assert!(notice(&ns(&mut e, "000AAAAAB", "SASET alice GREET hello there"), "is now"), "oper sets greet");
|
||
assert_eq!(e.db.account("alice").map(|a| a.greet.clone()), Some("hello there".to_string()), "greet stored");
|
||
// And clears it.
|
||
assert!(notice(&ns(&mut e, "000AAAAAB", "SASET alice GREET"), "cleared"), "oper clears greet");
|
||
assert_eq!(e.db.account("alice").map(|a| a.greet.clone()), Some(String::new()), "greet cleared");
|
||
}
|
||
|
||
// MemoServ IGNORE: a memo from an ignored sender is silently dropped, and the
|
||
// sender is still told it was sent (so the ignore isn't revealed).
|
||
#[test]
|
||
fn memoserv_ignore_drops_memo() {
|
||
use echo_memoserv::MemoServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-msignore.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "pw", None).unwrap();
|
||
db.register("bob", "pw", None).unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(MemoServ { uid: "42SAAAAAE".into() }),
|
||
],
|
||
db,
|
||
);
|
||
let ms = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAE".into(), text: t.into() });
|
||
let notice = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY pw".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAC".into(), to: "42SAAAAAA".into(), text: "IDENTIFY pw".into() });
|
||
|
||
// alice ignores bob.
|
||
assert!(notice(&ms(&mut e, "000AAAAAB", "IGNORE ADD bob"), "ignoring"), "ignore added");
|
||
// bob's memo is accepted-looking but dropped.
|
||
assert!(notice(&ms(&mut e, "000AAAAAC", "SEND alice hey"), "Memo sent"), "sender told it was sent");
|
||
assert_eq!(e.db.unread_memos("alice"), 0, "the memo was dropped");
|
||
// After un-ignoring, delivery resumes.
|
||
ms(&mut e, "000AAAAAB", "IGNORE DEL bob");
|
||
ms(&mut e, "000AAAAAC", "SEND alice hey again");
|
||
assert_eq!(e.db.unread_memos("alice"), 1, "delivered once no longer ignored");
|
||
}
|
||
|
||
// MemoServ SET NOTIFY/LIMIT change the account's memo preferences, and LIMIT
|
||
// caps the mailbox on SEND.
|
||
#[test]
|
||
fn memoserv_set_notify_and_limit() {
|
||
use echo_memoserv::MemoServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-msset.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "pw", None).unwrap();
|
||
db.register("bob", "pw", None).unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(MemoServ { uid: "42SAAAAAE".into() }),
|
||
],
|
||
db,
|
||
);
|
||
let ms = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAE".into(), text: t.into() });
|
||
let notice = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY pw".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAC".into(), to: "42SAAAAAA".into(), text: "IDENTIFY pw".into() });
|
||
|
||
assert!(notice(&ms(&mut e, "000AAAAAB", "SET NOTIFY OFF"), "off"), "notify off");
|
||
assert!(!e.db.memo_notify_on("alice"), "notify pref stored");
|
||
assert!(notice(&ms(&mut e, "000AAAAAB", "SET LIMIT 2"), "2"), "limit set");
|
||
assert_eq!(e.db.memo_limit_of("alice"), Some(2), "limit stored");
|
||
|
||
// bob fills alice's 2-memo mailbox; the third is rejected.
|
||
ms(&mut e, "000AAAAAC", "SEND alice one");
|
||
ms(&mut e, "000AAAAAC", "SEND alice two");
|
||
assert!(notice(&ms(&mut e, "000AAAAAC", "SEND alice three"), "mailbox is full"), "limit enforced on SEND");
|
||
assert_eq!(e.db.unread_memos("alice"), 2, "capped at the limit");
|
||
}
|
||
|
||
// MemoServ RSEND: when the recipient reads the memo, the sender is memoed back.
|
||
#[test]
|
||
fn memoserv_rsend_read_receipt() {
|
||
use echo_memoserv::MemoServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-msrsend.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "pw", None).unwrap();
|
||
db.register("bob", "pw", None).unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(MemoServ { uid: "42SAAAAAE".into() }),
|
||
],
|
||
db,
|
||
);
|
||
let ms = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAE".into(), text: t.into() });
|
||
let notice = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY pw".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAC".into(), to: "42SAAAAAA".into(), text: "IDENTIFY pw".into() });
|
||
|
||
assert!(notice(&ms(&mut e, "000AAAAAC", "RSEND alice testing"), "Memo sent"), "rsend delivered");
|
||
assert_eq!(e.db.unread_memos("bob"), 0, "bob has no receipt yet");
|
||
// alice reads it; bob gets a receipt memo.
|
||
assert!(notice(&ms(&mut e, "000AAAAAB", "READ 1"), "testing"), "alice reads the memo");
|
||
assert_eq!(e.db.unread_memos("bob"), 1, "bob got a read receipt");
|
||
assert!(notice(&ms(&mut e, "000AAAAAC", "READ 1"), "has read the memo"), "receipt text delivered");
|
||
// Reading again does not fire a second receipt.
|
||
ms(&mut e, "000AAAAAB", "READ 1");
|
||
assert_eq!(e.db.unread_memos("bob"), 0, "no duplicate receipt on re-read");
|
||
}
|
||
|
||
// ChanServ AKICK CLEAR empties the auto-kick list.
|
||
#[test]
|
||
fn chanserv_akick_clear() {
|
||
use echo_chanserv::ChanServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-csakickclear.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "pw", None).unwrap();
|
||
db.register_channel("#c", "boss").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,
|
||
);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY pw".into() });
|
||
let cs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: t.into() });
|
||
cs(&mut e, "AKICK #c ADD *!*@bad1");
|
||
cs(&mut e, "AKICK #c ADD *!*@bad2");
|
||
let out = cs(&mut e, "AKICK #c CLEAR");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Cleared") && text.contains('2'))), "clears 2: {out:?}");
|
||
let out = cs(&mut e, "AKICK #c LIST");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("empty"))), "now empty: {out:?}");
|
||
}
|
||
|
||
// ChanServ OWNER (+q) is founder-only; PROTECT (+a) and HALFOP (+h) need op.
|
||
#[test]
|
||
fn chanserv_owner_protect_halfop() {
|
||
use echo_chanserv::ChanServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-csstatus.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
db.register("bob", "pw", None).unwrap();
|
||
db.register_channel("#c", "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,
|
||
);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAC".into(), to: "42SAAAAAA".into(), text: "IDENTIFY pw".into() });
|
||
|
||
let cs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAB".into(), text: t.into() });
|
||
let mode = |out: &[NetAction], m: &str| out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == m));
|
||
|
||
assert!(mode(&cs(&mut e, "000AAAAAB", "OWNER #c"), "+q 000AAAAAB"), "founder grants +q to self");
|
||
assert!(mode(&cs(&mut e, "000AAAAAB", "PROTECT #c"), "+a 000AAAAAB"), "founder grants +a");
|
||
assert!(mode(&cs(&mut e, "000AAAAAB", "HALFOP #c"), "+h 000AAAAAB"), "founder grants +h");
|
||
|
||
// bob has no access: OWNER is refused (no +q emitted for him).
|
||
let out = cs(&mut e, "000AAAAAC", "OWNER #c");
|
||
assert!(!mode(&out, "+q 000AAAAAC"), "non-founder cannot grant owner: {out:?}");
|
||
}
|
||
|
||
// ChanServ SET SUCCESSOR: the successor inherits the channel when the founder
|
||
// account is dropped, instead of the channel being released.
|
||
#[test]
|
||
fn chanserv_successor_inherits_on_drop() {
|
||
use echo_chanserv::ChanServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-cssucc.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
db.register("bob", "pw", None).unwrap();
|
||
db.register_channel("#c", "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,
|
||
);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: "SET #c SUCCESSOR bob".into() });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Successor") && text.contains("bob"))), "successor set: {out:?}");
|
||
|
||
// alice drops her account -> #c is inherited by bob, not released.
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "DROP sesame".into() });
|
||
assert_eq!(e.db.channel("#c").map(|c| c.founder.clone()), Some("bob".to_string()), "channel inherited by the successor");
|
||
assert!(e.db.account("alice").is_none(), "the dropped account is gone");
|
||
}
|
||
|
||
// OperServ FORBID bans a nick/channel pattern from registration.
|
||
#[test]
|
||
fn operserv_forbid_blocks_registration() {
|
||
use echo_nickserv::NickServ;
|
||
use echo_operserv::OperServ;
|
||
let path = std::env::temp_dir().join("echo-osforbid.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "pw", 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,
|
||
);
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY pw".into() });
|
||
let os = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAH".into(), text: t.into() });
|
||
let notice = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
|
||
assert!(notice(&os(&mut e, "FORBID ADD NICK evil* squatting"), "Forbade"), "nick forbid added");
|
||
assert!(notice(&os(&mut e, "FORBID ADD CHAN #warez piracy"), "Forbade"), "chan forbid added");
|
||
|
||
let reply = crate::proto::RegReply::NickServ { agent: "42SAAAAAA".into(), uid: "000AAAAAC".into(), nick: "evilbob".into() };
|
||
assert!(e.pre_register_check("evilbob", &reply).is_some(), "forbidden nick refused");
|
||
assert!(e.pre_register_check("cleanname", &reply).is_none(), "clean nick allowed");
|
||
assert!(e.db.is_forbidden(echo_api::ForbidKind::Chan, "#warez").is_some(), "channel is forbidden");
|
||
|
||
// EMAIL forbids block registration and SET EMAIL.
|
||
assert!(notice(&os(&mut e, "FORBID ADD EMAIL *@spam.tld disposable"), "Forbade"), "email forbid added");
|
||
let ereply = || crate::proto::RegReply::NickServ { agent: "42SAAAAAA".into(), uid: "000AAAAAE".into(), nick: "newbie".into() };
|
||
let out = e.complete_register("newbie", Db::derive_credentials("pw", 4096), Some("evil@spam.tld".into()), ereply());
|
||
assert!(notice(&out, "email address is forbidden"), "forbidden email blocks registration: {out:?}");
|
||
assert!(!e.db.exists("newbie"), "no account created with a forbidden email");
|
||
e.complete_register("newbie", Db::derive_credentials("pw", 4096), Some("ok@good.tld".into()), ereply());
|
||
assert!(e.db.exists("newbie"), "a clean email registers fine");
|
||
// boss (identified) can't SET EMAIL to a forbidden address either.
|
||
let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "SET EMAIL nope@spam.tld".into() });
|
||
assert!(notice(&out, "email address is forbidden"), "SET EMAIL rejects a forbidden address: {out:?}");
|
||
|
||
assert!(notice(&os(&mut e, "FORBID LIST"), "evil*"), "list shows the nick forbid");
|
||
assert!(notice(&os(&mut e, "FORBID DEL NICK evil*"), "Removed"), "nick forbid removed");
|
||
assert!(e.pre_register_check("evilbob", &reply).is_none(), "no longer forbidden");
|
||
|
||
// A non-oper cannot FORBID.
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAD".into(), nick: "rando".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
assert!(notice(&e.handle(NetEvent::Privmsg { from: "000AAAAAD".into(), to: "42SAAAAAH".into(), text: "FORBID ADD NICK x y".into() }), "Access denied"), "non-oper refused");
|
||
}
|
||
|
||
// OperServ SET READONLY locks out writes: while on, a channel can't be
|
||
// registered; turning it off restores normal service.
|
||
#[test]
|
||
fn operserv_set_readonly_blocks_writes() {
|
||
use echo_chanserv::ChanServ;
|
||
use echo_nickserv::NickServ;
|
||
use echo_operserv::OperServ;
|
||
let path = std::env::temp_dir().join("echo-osreadonly.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "pw", None).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() }),
|
||
Box::new(OperServ { uid: "42SAAAAAH".into() }),
|
||
],
|
||
db,
|
||
);
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Root));
|
||
e.set_opers(opers);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY pw".into() });
|
||
e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: true });
|
||
let os = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAH".into(), text: t.into() });
|
||
let cs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: t.into() });
|
||
let notice = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
|
||
assert!(notice(&os(&mut e, "SET READONLY ON"), "read-only"), "readonly enabled");
|
||
assert!(e.db.readonly(), "flag set");
|
||
let out = cs(&mut e, "REGISTER #c");
|
||
assert!(!notice(&out, "now registered"), "register blocked while read-only: {out:?}");
|
||
assert!(e.db.channel("#c").is_none(), "no channel written");
|
||
|
||
assert!(notice(&os(&mut e, "SET READONLY OFF"), "no longer"), "readonly cleared");
|
||
assert!(!e.db.readonly(), "flag cleared");
|
||
assert!(notice(&cs(&mut e, "REGISTER #c"), "now registered"), "register works again");
|
||
assert!(e.db.channel("#c").is_some(), "channel written");
|
||
}
|
||
|
||
// OperServ SHUTDOWN/RESTART (admin) emit a Shutdown action (which the link
|
||
// layer turns into a process exit) and are refused to non-operators.
|
||
#[test]
|
||
fn operserv_shutdown_and_restart() {
|
||
use echo_nickserv::NickServ;
|
||
use echo_operserv::OperServ;
|
||
let path = std::env::temp_dir().join("echo-osshutdown.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "pw", 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,
|
||
);
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Root));
|
||
e.set_opers(opers);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY pw".into() });
|
||
let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() });
|
||
|
||
// A non-oper is refused and no Shutdown action is produced.
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAD".into(), nick: "rando".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
let out = os(&mut e, "000AAAAAD", "SHUTDOWN now");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "non-oper refused");
|
||
assert!(!out.iter().any(|a| matches!(a, NetAction::Shutdown { .. })), "no shutdown for non-oper");
|
||
|
||
// The admin's SHUTDOWN emits a Shutdown (restart=false) with the reason.
|
||
let out = os(&mut e, "000AAAAAB", "SHUTDOWN maintenance");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Shutdown { restart, reason } if !*restart && reason.contains("maintenance"))), "shutdown action: {out:?}");
|
||
// RESTART sets restart=true.
|
||
let out = os(&mut e, "000AAAAAB", "RESTART");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Shutdown { restart, .. } if *restart)), "restart action: {out:?}");
|
||
}
|
||
|
||
// OperServ SVSPART (admin) forces a user out of a channel.
|
||
#[test]
|
||
fn operserv_svspart() {
|
||
use echo_nickserv::NickServ;
|
||
use echo_operserv::OperServ;
|
||
let path = std::env::temp_dir().join("echo-svspart.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "pw", 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,
|
||
);
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY pw".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "victim".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
|
||
let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAH".into(), text: "SVSPART victim #trouble go away".into() });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ForcePart { uid, channel, .. } if uid == "000AAAAAC" && channel == "#trouble")), "victim parted: {out:?}");
|
||
// A non-oper can't.
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAD".into(), nick: "rando".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
let out = e.handle(NetEvent::Privmsg { from: "000AAAAAD".into(), to: "42SAAAAAH".into(), text: "SVSPART victim #trouble".into() });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "non-oper refused: {out:?}");
|
||
}
|
||
|
||
// NickServ GETEMAIL (oper) finds accounts by their email glob.
|
||
#[test]
|
||
fn nickserv_getemail() {
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-getemail.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("staff", "pw", None).unwrap();
|
||
db.register("alice", "pw", Some("alice@x.com".into())).unwrap();
|
||
db.register("bob", "pw", Some("bob@x.com".into())).unwrap();
|
||
let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db);
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("staff".to_string(), Privs::default().with(echo_api::Priv::Auspex));
|
||
e.set_opers(opers);
|
||
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
let notice = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "staff".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "rando".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
|
||
assert!(notice(&ns(&mut e, "000AAAAAC", "GETEMAIL *@x.com"), "Access denied"), "non-oper refused");
|
||
ns(&mut e, "000AAAAAB", "IDENTIFY pw");
|
||
let out = ns(&mut e, "000AAAAAB", "GETEMAIL *@x.com");
|
||
assert!(notice(&out, "alice") && notice(&out, "bob"), "finds both: {out:?}");
|
||
assert!(notice(&ns(&mut e, "000AAAAAB", "GETEMAIL nobody@z.com"), "No accounts"), "no match reported");
|
||
}
|
||
|
||
// NickServ RECOVER frees a ghost off your nick and puts you back onto it.
|
||
#[test]
|
||
fn nickserv_recover_regains_nick() {
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-recover.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db);
|
||
// A ghost sits on nick "alice"; the owner is on another nick and identifies.
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "owner".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAC".into(), to: "42SAAAAAA".into(), text: "IDENTIFY alice sesame".into() });
|
||
|
||
let out = e.handle(NetEvent::Privmsg { from: "000AAAAAC".into(), to: "42SAAAAAA".into(), text: "RECOVER alice".into() });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ForceNick { uid, nick } if uid == "000AAAAAB" && nick.starts_with("Guest"))), "ghost freed: {out:?}");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ForceNick { uid, nick } if uid == "000AAAAAC" && nick == "alice")), "caller regained the nick: {out:?}");
|
||
}
|
||
|
||
// ChanServ SET RESTRICTED kicks joining users who have no channel access.
|
||
#[test]
|
||
fn chanserv_restricted_kicks_users_without_access() {
|
||
use echo_chanserv::ChanServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-restricted.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
db.register_channel("#c", "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,
|
||
);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: "SET #c RESTRICTED ON".into() });
|
||
|
||
// The founder has access and is not kicked.
|
||
let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false });
|
||
assert!(!out.iter().any(|a| matches!(a, NetAction::Kick { uid, .. } if uid == "000AAAAAB")), "founder not kicked: {out:?}");
|
||
// A user with no access is kicked.
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "rando".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
let out = e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#c".into(), op: false });
|
||
let kick = out.iter().find_map(|a| match a {
|
||
NetAction::Kick { uid, channel, reason, .. } if uid == "000AAAAAC" && channel == "#c" => Some(reason.clone()),
|
||
_ => None,
|
||
});
|
||
let reason = kick.expect(&format!("no-access user kicked: {out:?}"));
|
||
// The restricted kick must be incident-stamped like every other removal —
|
||
// it early-returns, so this proves the return routes through finish() and
|
||
// reaches stamp_incidents (which appends the searchable [#id] tag).
|
||
assert!(reason.contains("[#"), "restricted kick is incident-stamped: {reason:?}");
|
||
}
|
||
|
||
// ChanServ SET AUTOOP OFF suppresses auto-op on join (on by default).
|
||
#[test]
|
||
fn chanserv_autoop_off_suppresses_status() {
|
||
use echo_chanserv::ChanServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-autoop.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
db.register_channel("#c", "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,
|
||
);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
let opped = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+qo 000AAAAAB 000AAAAAB"));
|
||
|
||
// Default: the founder is auto-opped on join.
|
||
assert!(opped(&e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false })), "auto-op on by default");
|
||
e.handle(NetEvent::Part { uid: "000AAAAAB".into(), channel: "#c".into(), reason: String::new() });
|
||
// Turn AUTOOP off; now a join is not auto-opped.
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: "SET #c AUTOOP OFF".into() });
|
||
assert!(!opped(&e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false })), "no auto-op when AUTOOP is off");
|
||
}
|
||
|
||
// NickServ SET AUTOOP OFF is a per-account opt-out: the user isn't auto-opped
|
||
// anywhere, even where the channel allows it.
|
||
#[test]
|
||
fn nickserv_set_autoop_off_opts_out_of_status() {
|
||
use echo_chanserv::ChanServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-nsautoop.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
db.register_channel("#c", "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,
|
||
);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
let ns = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
let opped = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+qo 000AAAAAB 000AAAAAB"));
|
||
let notice = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
|
||
// Default: founder is auto-opped (channel AUTOOP on, account AUTOOP on).
|
||
assert!(opped(&e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false })), "auto-op on by default");
|
||
e.handle(NetEvent::Part { uid: "000AAAAAB".into(), channel: "#c".into(), reason: String::new() });
|
||
|
||
// Account opts out via NickServ; now no auto-op despite channel allowing it.
|
||
assert!(notice(&ns(&mut e, "SET AUTOOP OFF"), "no longer be auto-opped"), "pref set off");
|
||
assert!(!e.db.account_wants_autoop("alice"), "preference stored");
|
||
assert!(!opped(&e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false })), "no auto-op with account AUTOOP off");
|
||
|
||
// Turn it back on: auto-op resumes.
|
||
e.handle(NetEvent::Part { uid: "000AAAAAB".into(), channel: "#c".into(), reason: String::new() });
|
||
assert!(notice(&ns(&mut e, "SET AUTOOP ON"), "auto-opped where you have"), "pref set on");
|
||
assert!(opped(&e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false })), "auto-op resumes");
|
||
}
|
||
|
||
// BotServ BOT ADD/LIST is oper-gated (Priv::Admin) and the bot is remembered.
|
||
#[test]
|
||
fn botserv_bot_add_list_is_oper_gated() {
|
||
use echo_botserv::BotServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-botserv.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("staff", "password1", None).unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||
],
|
||
db,
|
||
);
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("staff".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let bs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
let notice = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "rando".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "staff".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
|
||
// A non-oper cannot add a bot.
|
||
assert!(notice(&bs(&mut e, "000AAAAAB", "BOT ADD Bendy bot serv.host Helper"), "Access denied"));
|
||
// The admin oper identifies, adds a bot, and sees it listed.
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAC".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
assert!(notice(&bs(&mut e, "000AAAAAC", "BOT ADD Bendy bot serv.host A Helper"), "added"));
|
||
assert!(notice(&bs(&mut e, "000AAAAAC", "BOT LIST"), "Bendy"));
|
||
}
|
||
|
||
// BotServ AUTOASSIGN sets a default bot that new channels get automatically,
|
||
// and BOTLIST shows assignable bots to anyone.
|
||
#[test]
|
||
fn botserv_autoassign_and_botlist() {
|
||
use echo_botserv::BotServ;
|
||
use echo_chanserv::ChanServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-bsautoassign.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("staff", "password1", None).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() }),
|
||
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||
],
|
||
db,
|
||
);
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("staff".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let bs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
let cs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAB".into(), text: t.into() });
|
||
let notice = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "staff".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAC".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
bs(&mut e, "000AAAAAC", "BOT ADD Bendy bot serv.host A Helper");
|
||
|
||
// BOTLIST shows the bot; setting a nonexistent default is rejected.
|
||
assert!(notice(&bs(&mut e, "000AAAAAC", "BOTLIST"), "Bendy"), "botlist shows the bot");
|
||
assert!(notice(&bs(&mut e, "000AAAAAC", "AUTOASSIGN Ghost"), "no bot named"), "unknown default rejected");
|
||
assert!(notice(&bs(&mut e, "000AAAAAC", "AUTOASSIGN Bendy"), "auto-assigned"), "default set");
|
||
assert_eq!(e.db.default_bot(), Some("Bendy"), "default bot stored canonically");
|
||
|
||
// Registering a new channel auto-assigns the default bot.
|
||
e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#new".into(), op: true });
|
||
let out = cs(&mut e, "000AAAAAC", "REGISTER #new");
|
||
assert!(notice(&out, "Assigned"), "bot auto-assigned on register: {out:?}");
|
||
assert_eq!(e.db.channel("#new").and_then(|c| c.assigned_bot.clone()), Some("Bendy".to_string()), "assignment recorded");
|
||
|
||
// Turning it off stops auto-assignment.
|
||
assert!(notice(&bs(&mut e, "000AAAAAC", "AUTOASSIGN OFF"), "no longer"), "default cleared");
|
||
assert_eq!(e.db.default_bot(), None, "default bot cleared");
|
||
}
|
||
|
||
// A bot is introduced on the network when added, and quit when deleted.
|
||
#[test]
|
||
fn botserv_introduces_and_quits_bots() {
|
||
use echo_botserv::BotServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-botintro.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("staff", "password1", None).unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("staff".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let bs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "staff".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAC".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
|
||
// Adding a bot introduces it on the network with a SID-prefixed uid.
|
||
let out = bs(&mut e, "000AAAAAC", "BOT ADD Bendy bot serv.host A Helper");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::IntroduceUser { nick, uid, .. } if nick == "Bendy" && uid.starts_with("42SB"))), "introduced: {out:?}");
|
||
// A later command doesn't re-introduce an already-live bot.
|
||
assert!(!bs(&mut e, "000AAAAAC", "BOT LIST").iter().any(|a| matches!(a, NetAction::IntroduceUser { .. })), "no duplicate introduce");
|
||
// Deleting it quits the pseudo-client.
|
||
assert!(bs(&mut e, "000AAAAAC", "BOT DEL Bendy").iter().any(|a| matches!(a, NetAction::QuitUser { .. })), "quit on delete");
|
||
// A registered bot is introduced at burst.
|
||
bs(&mut e, "000AAAAAC", "BOT ADD Roger svc host Bot");
|
||
assert!(e.startup_actions().iter().any(|a| matches!(a, NetAction::IntroduceUser { nick, .. } if nick == "Roger")), "introduced at burst");
|
||
}
|
||
|
||
// ASSIGN puts the bot in the channel; UNASSIGN takes it out.
|
||
#[test]
|
||
fn botserv_assign_joins_and_unassign_parts() {
|
||
use echo_botserv::BotServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-bsassign.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register("staff", "password1", None).unwrap();
|
||
db.register_channel("#c", "boss").unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("staff".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
let bs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "staff".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
ns(&mut e, "000AAAAAB", "IDENTIFY password1");
|
||
ns(&mut e, "000AAAAAC", "IDENTIFY password1");
|
||
bs(&mut e, "000AAAAAC", "BOT ADD Bendy bot serv.host Helper"); // goes live
|
||
|
||
// The founder assigns the bot: it joins as a protected admin + op (+ao).
|
||
let out = bs(&mut e, "000AAAAAB", "ASSIGN #c Bendy");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ServiceJoin { channel, modes, .. } if channel == "#c" && modes == "ao")), "joins +ao on assign: {out:?}");
|
||
// Unassigning parts it.
|
||
let out = bs(&mut e, "000AAAAAB", "UNASSIGN #c");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ServicePart { channel, .. } if channel == "#c")), "parts on unassign: {out:?}");
|
||
}
|
||
|
||
// A services bot kicked from a channel it's assigned to rejoins at once — an
|
||
// op can't evict it.
|
||
#[test]
|
||
fn kicked_bot_rejoins_its_channel() {
|
||
use echo_botserv::BotServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-kickbot.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register_channel("#c", "boss").unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let ns = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
ns(&mut e, "IDENTIFY password1");
|
||
bs(&mut e, "BOT ADD Bendy bot serv.host Helper");
|
||
let bot_uid = bs(&mut e, "ASSIGN #c Bendy").iter().find_map(|a| match a {
|
||
NetAction::ServiceJoin { uid, channel, .. } if channel == "#c" => Some(uid.clone()),
|
||
_ => None,
|
||
}).expect("bot joined on assign");
|
||
|
||
// The bot is kicked out (the ircd relays this as a PART for its uid).
|
||
let out = e.handle(NetEvent::Part { uid: bot_uid.clone(), channel: "#c".into(), reason: String::new() });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ServiceJoin { uid, channel, .. } if uid == &bot_uid && channel == "#c")), "kicked bot rejoins: {out:?}");
|
||
}
|
||
|
||
// A KILL forgets a real user, but a killed services bot is reintroduced and
|
||
// rejoins its channels — an oper can't take a bot down for good.
|
||
#[test]
|
||
fn kill_forgets_a_user_and_reintroduces_a_bot() {
|
||
use echo_botserv::BotServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-killbot.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register_channel("#c", "boss").unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let ns = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
ns(&mut e, "IDENTIFY password1");
|
||
bs(&mut e, "BOT ADD Bendy bot serv.host Helper");
|
||
let bot_uid = bs(&mut e, "ASSIGN #c Bendy").iter().find_map(|a| match a {
|
||
NetAction::ServiceJoin { uid, channel, .. } if channel == "#c" => Some(uid.clone()),
|
||
_ => None,
|
||
}).expect("bot joined on assign");
|
||
|
||
// Killing a real user forgets their session.
|
||
assert_eq!(e.network.account_of("000AAAAAB"), Some("boss"), "identified before the kill");
|
||
e.handle(NetEvent::UserKilled { uid: "000AAAAAB".into() });
|
||
assert!(e.network.account_of("000AAAAAB").is_none(), "killed user forgotten");
|
||
|
||
// Killing the bot brings it right back and rejoins its channel.
|
||
let out = e.handle(NetEvent::UserKilled { uid: bot_uid });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::IntroduceUser { nick, .. } if nick == "Bendy")), "killed bot reintroduced: {out:?}");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ServiceJoin { channel, .. } if channel == "#c")), "reintroduced bot rejoins #c: {out:?}");
|
||
}
|
||
|
||
// Killing a service agent (NickServ etc.) reintroduces it — an oper can't
|
||
// take services off the network with a KILL.
|
||
#[test]
|
||
fn killed_service_agent_is_reintroduced() {
|
||
let mut e = engine_with("killagent", "alice", "sesame");
|
||
// engine_with wires NickServ at uid 42SAAAAAA.
|
||
let out = e.handle(NetEvent::UserKilled { uid: "42SAAAAAA".into() });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::IntroduceUser { uid, nick, .. } if uid == "42SAAAAAA" && !nick.is_empty())), "killed agent reintroduced: {out:?}");
|
||
}
|
||
|
||
// A channel that expires with an assigned bot parts the bot in the same
|
||
// sweep — it must not linger in a channel it no longer serves.
|
||
#[test]
|
||
fn expired_channel_parts_its_assigned_bot() {
|
||
use echo_botserv::BotServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-expirebot.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register_channel("#c", "boss").unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
// boss is an oper so its account is spared — only the channel expires.
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let ns = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
ns(&mut e, "IDENTIFY password1");
|
||
bs(&mut e, "BOT ADD Bendy bot serv.host Helper");
|
||
assert!(bs(&mut e, "ASSIGN #c Bendy").iter().any(|a| matches!(a, NetAction::ServiceJoin { channel, .. } if channel == "#c")), "bot joins on assign");
|
||
|
||
// Expire #c; the sweep drops it and must part the bot.
|
||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||
e.set_irc_out(tx);
|
||
e.now_override = Some(10_000_000_000);
|
||
e.set_expiry(Some(100), Some(100), None);
|
||
e.expire_sweep();
|
||
assert!(e.db.channel("#c").is_none(), "channel expired");
|
||
let parted = std::iter::from_fn(|| rx.try_recv().ok())
|
||
.any(|a| matches!(a, NetAction::ServicePart { channel, .. } if channel == "#c"));
|
||
assert!(parted, "the assigned bot parts the expired channel");
|
||
}
|
||
|
||
// A truly-empty channel is forgotten (so a stale `+k` key can't leak to a later
|
||
// channel of the same name), but a channel a services bot still sits in is kept
|
||
// alive (bots aren't tracked as members) so its live key/modes survive.
|
||
#[test]
|
||
fn emptied_channel_pruned_but_botted_channel_kept() {
|
||
let path = std::env::temp_dir().join("echo-emptykey.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut e = Engine::new(vec![], Db::open(&path, "42S"));
|
||
|
||
// Bot-less channel: once the last member leaves, it's gone (no stale key).
|
||
e.network.channel_join("#x", "000AAAAAB", true);
|
||
e.network.set_channel_key("#x", Some("secret".into()));
|
||
assert_eq!(e.network.channel_key("#x"), Some("secret"));
|
||
e.network.channel_part("#x", "000AAAAAB");
|
||
e.prune_channel_if_gone("#x");
|
||
assert_eq!(e.network.channel_key("#x"), None, "empty bot-less channel is forgotten");
|
||
e.network.channel_join("#x", "000AAAAAD", true); // recreated, fresh
|
||
assert_eq!(e.network.channel_key("#x"), None, "recreated channel starts keyless");
|
||
|
||
// Botted channel: emptied of humans, but the bot keeps it alive on the ircd,
|
||
// so echo must NOT drop its live key.
|
||
e.network.channel_join("#y", "000AAAAAC", true);
|
||
e.network.set_channel_key("#y", Some("hunter2".into()));
|
||
e.bot_channels.insert(("bendy".to_string(), "#y".to_string()));
|
||
e.network.channel_part("#y", "000AAAAAC");
|
||
e.prune_channel_if_gone("#y");
|
||
assert_eq!(e.network.channel_key("#y"), Some("hunter2"), "botted channel keeps its key");
|
||
}
|
||
|
||
// The gRPC login path applies the same guards as IRC IDENTIFY: a suspended or
|
||
// throttled account is refused before the verify (and the throttle clears on success).
|
||
#[test]
|
||
fn authority_auth_begin_refuses_suspended_or_throttled() {
|
||
let path = std::env::temp_dir().join("echo-authbegin.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("carol", "hunter2", None).unwrap();
|
||
db.register("dave", "hunter2", None).unwrap();
|
||
let mut e = Engine::new(vec![], db);
|
||
assert!(e.authority_auth_begin("carol").is_some(), "a normal account resolves a verifier");
|
||
|
||
e.db.suspend_account("carol", "staff", "x", None).unwrap();
|
||
assert!(e.authority_auth_begin("carol").is_none(), "suspended account refused");
|
||
|
||
for _ in 0..10 {
|
||
e.db.note_auth("dave", false);
|
||
}
|
||
assert!(e.authority_auth_begin("dave").is_none(), "throttled account refused");
|
||
e.db.note_auth("dave", true);
|
||
assert!(e.authority_auth_begin("dave").is_some(), "cleared after a success");
|
||
}
|
||
|
||
// groups_founded bounds group registration: it counts only groups the account founds.
|
||
#[test]
|
||
fn groups_founded_counts_only_a_founders_own_groups() {
|
||
let path = std::env::temp_dir().join("echo-groupcount.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register("other", "password1", None).unwrap();
|
||
db.group_register("!a", "boss").unwrap();
|
||
db.group_register("!b", "boss").unwrap();
|
||
db.group_register("!c", "other").unwrap();
|
||
assert_eq!(db.groups_founded("boss"), 2);
|
||
assert_eq!(db.groups_founded("other"), 1);
|
||
assert_eq!(db.groups_founded("nobody"), 0);
|
||
}
|
||
|
||
// BOT DEL * removes every bot at once, quitting each.
|
||
#[test]
|
||
fn botserv_mass_bot_removal() {
|
||
use echo_botserv::BotServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-bsmass.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
bs(&mut e, "BOT ADD One a serv.host Bot");
|
||
bs(&mut e, "BOT ADD Two b serv.host Bot");
|
||
|
||
let out = bs(&mut e, "BOT DEL *");
|
||
let quits = out.iter().filter(|a| matches!(a, NetAction::QuitUser { .. })).count();
|
||
assert_eq!(quits, 2, "both bots quit: {out:?}");
|
||
// The registry is empty afterwards.
|
||
assert!(!bs(&mut e, "BOT LIST").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("One") || text.contains("Two"))), "registry cleared");
|
||
}
|
||
|
||
// BOT CHANGE renames a bot (moving its channel assignments) and reintroduces
|
||
// it when only the identity changes; the network client is refreshed both ways.
|
||
#[test]
|
||
fn botserv_bot_change_renames_and_reidentifies() {
|
||
use echo_botserv::BotServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-bschange.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register_channel("#c", "boss").unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let ns = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
ns(&mut e, "IDENTIFY password1");
|
||
bs(&mut e, "BOT ADD Bendy bot serv.host Helper");
|
||
bs(&mut e, "ASSIGN #c Bendy");
|
||
|
||
// Rename: the old client quits, a Roger client is introduced and rejoins #c.
|
||
let out = bs(&mut e, "BOT CHANGE Bendy Roger");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::QuitUser { .. })), "old client quit: {out:?}");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::IntroduceUser { nick, .. } if nick == "Roger")), "Roger introduced");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ServiceJoin { channel, .. } if channel == "#c")), "Roger rejoins #c");
|
||
|
||
// Same nick, new identity: the client is refreshed with the new host.
|
||
let out = bs(&mut e, "BOT CHANGE Roger Roger newbot new.host A New Bot");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::QuitUser { .. })), "stale client quit");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::IntroduceUser { nick, host, .. } if nick == "Roger" && host == "new.host")), "reintroduced with new host: {out:?}");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ServiceJoin { channel, .. } if channel == "#c")), "rejoins #c after re-identify");
|
||
}
|
||
|
||
// NOBOT (channel) and PRIVATE (bot) both reserve assignment for operators:
|
||
// the founder is refused, a services admin is allowed.
|
||
#[test]
|
||
fn botserv_nobot_and_private_gate_assign() {
|
||
use echo_botserv::BotServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-bsprot.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap(); // admin
|
||
db.register("owner", "password1", None).unwrap(); // plain founder
|
||
db.register_channel("#c", "owner").unwrap();
|
||
db.register_channel("#d", "owner").unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
let boss = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
let owner = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAO".into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAO".into(), nick: "owner".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
ns(&mut e, "000AAAAAB", "IDENTIFY password1");
|
||
ns(&mut e, "000AAAAAO", "IDENTIFY password1");
|
||
boss(&mut e, "BOT ADD Bendy bot serv.host Helper");
|
||
let joined = |out: &[NetAction], ch: &str| out.iter().any(|a| matches!(a, NetAction::ServiceJoin { channel, .. } if channel == ch));
|
||
let denied = |out: &[NetAction], word: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(word)));
|
||
|
||
// NOBOT on #c: the founder is refused, the admin isn't.
|
||
boss(&mut e, "SET #c NOBOT ON");
|
||
assert!(denied(&owner(&mut e, "ASSIGN #c Bendy"), "NOBOT"), "founder blocked by NOBOT");
|
||
assert!(joined(&boss(&mut e, "ASSIGN #c Bendy"), "#c"), "admin overrides NOBOT");
|
||
|
||
// Private bot on #d: the founder is refused, the admin isn't.
|
||
boss(&mut e, "SET Bendy PRIVATE ON");
|
||
assert!(denied(&owner(&mut e, "ASSIGN #d Bendy"), "private"), "founder blocked from private bot");
|
||
assert!(joined(&boss(&mut e, "ASSIGN #d Bendy"), "#d"), "admin assigns private bot");
|
||
}
|
||
|
||
// SAY/ACT speak through the channel's assigned bot, sourced from the bot's uid.
|
||
#[test]
|
||
fn botserv_say_speaks_through_bot() {
|
||
use echo_botserv::BotServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-bssay.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register("rando", "password1", None).unwrap();
|
||
db.register_channel("#c", "boss").unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
let bs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAR".into(), nick: "rando".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
ns(&mut e, "000AAAAAB", "IDENTIFY password1");
|
||
ns(&mut e, "000AAAAAR", "IDENTIFY password1");
|
||
bs(&mut e, "000AAAAAB", "BOT ADD Bendy bot serv.host Helper");
|
||
bs(&mut e, "000AAAAAB", "ASSIGN #c Bendy");
|
||
|
||
// Founder SAY: a PRIVMSG to the channel sourced from the bot's uid.
|
||
let out = bs(&mut e, "000AAAAAB", "SAY #c hello there");
|
||
assert!(
|
||
out.iter().any(|a| matches!(a, NetAction::Privmsg { from, to, text } if from.starts_with("42SB") && to == "#c" && text == "hello there")),
|
||
"bot says: {out:?}"
|
||
);
|
||
// ACT wraps the text in a CTCP ACTION.
|
||
let out = bs(&mut e, "000AAAAAB", "ACT #c waves");
|
||
assert!(
|
||
out.iter().any(|a| matches!(a, NetAction::Privmsg { text, .. } if text == "\x01ACTION waves\x01")),
|
||
"bot acts: {out:?}"
|
||
);
|
||
// A non-op can't drive the bot.
|
||
let out = bs(&mut e, "000AAAAAR", "SAY #c nope");
|
||
assert!(!out.iter().any(|a| matches!(a, NetAction::Privmsg { .. })), "non-op blocked: {out:?}");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "denied notice: {out:?}");
|
||
}
|
||
|
||
// The caps kicker makes the assigned bot kick a shouting message, exempts
|
||
// operators when DONTKICKOPS is on, and leaves ordinary lines alone.
|
||
#[test]
|
||
fn botserv_caps_kicker_kicks() {
|
||
use echo_botserv::BotServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-bskick.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register_channel("#c", "boss").unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
let bs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
let say = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "#c".into(), text: t.into() });
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAR".into(), nick: "rando".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
ns(&mut e, "000AAAAAB", "IDENTIFY password1");
|
||
bs(&mut e, "000AAAAAB", "BOT ADD Bendy bot serv.host Helper");
|
||
bs(&mut e, "000AAAAAB", "ASSIGN #c Bendy");
|
||
bs(&mut e, "000AAAAAB", "KICK #c CAPS ON");
|
||
e.handle(NetEvent::Join { uid: "000AAAAAR".into(), channel: "#c".into(), op: false });
|
||
|
||
// Shouting is kicked, sourced from the bot.
|
||
let out = say(&mut e, "000AAAAAR", "HELLO EVERYONE LISTEN UP");
|
||
assert!(
|
||
out.iter().any(|a| matches!(a, NetAction::Kick { from, channel, uid, .. } if from.starts_with("42SB") && channel == "#c" && uid == "000AAAAAR")),
|
||
"caps kicked: {out:?}"
|
||
);
|
||
// A normal line is fine.
|
||
assert!(!say(&mut e, "000AAAAAR", "hello everyone").iter().any(|a| matches!(a, NetAction::Kick { .. })), "quiet line ok");
|
||
// With DONTKICKOPS, an operator can shout.
|
||
bs(&mut e, "000AAAAAB", "KICK #c DONTKICKOPS ON");
|
||
e.network.set_op("#c", "000AAAAAR", true);
|
||
assert!(!say(&mut e, "000AAAAAR", "SHOUTING BUT I AM OP").iter().any(|a| matches!(a, NetAction::Kick { .. })), "op exempt");
|
||
}
|
||
|
||
// The repeat kicker counts consecutive identical lines (case-insensitively)
|
||
// and kicks once the threshold is reached; a different line resets it.
|
||
#[test]
|
||
fn botserv_repeat_kicker_kicks() {
|
||
let (mut e, _p) = kicker_fixture("bsrepeat");
|
||
let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
let say = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "#c".into(), text: t.into() });
|
||
bs(&mut e, "KICK #c REPEAT ON 2"); // kick on the third identical line
|
||
|
||
let kicked = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Kick { from, uid, .. } if from.starts_with("42SB") && uid == "000AAAAAS"));
|
||
assert!(!kicked(&say(&mut e, "spam")), "1st ok");
|
||
assert!(!kicked(&say(&mut e, "SPAM")), "2nd (case-fold) ok");
|
||
assert!(kicked(&say(&mut e, "Spam")), "3rd identical kicks");
|
||
// A different line clears the streak.
|
||
assert!(!kicked(&say(&mut e, "something else entirely")), "reset on new line");
|
||
}
|
||
|
||
// The flood kicker counts lines within a window; the window resets after it
|
||
// elapses. Uses the injectable clock so it is deterministic.
|
||
#[test]
|
||
fn botserv_flood_kicker_kicks() {
|
||
let (mut e, _p) = kicker_fixture("bsflood");
|
||
let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
let say = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "#c".into(), text: t.into() });
|
||
bs(&mut e, "KICK #c FLOOD ON 3 10"); // 3 lines in 10 seconds
|
||
let kicked = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Kick { uid, .. } if uid == "000AAAAAS"));
|
||
|
||
e.now_override = Some(1000);
|
||
assert!(!kicked(&say(&mut e, "a")), "line 1");
|
||
assert!(!kicked(&say(&mut e, "b")), "line 2");
|
||
// The window elapses before the 3rd line, so it resets instead of kicking.
|
||
e.now_override = Some(1011);
|
||
assert!(!kicked(&say(&mut e, "c")), "window reset");
|
||
// Three lines inside the window now trip it.
|
||
assert!(!kicked(&say(&mut e, "d")), "line 2 of new window");
|
||
assert!(kicked(&say(&mut e, "e")), "3rd in-window line kicks");
|
||
}
|
||
|
||
// The badwords kicker matches user-supplied regexes (case-insensitively),
|
||
// rebuilds its compiled set when the list changes, and rejects bad patterns.
|
||
#[test]
|
||
fn botserv_badwords_kicker_kicks() {
|
||
let (mut e, _p) = kicker_fixture("bsbadwords");
|
||
let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
let say = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "#c".into(), text: t.into() });
|
||
let kicked = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Kick { from, uid, .. } if from.starts_with("42SB") && uid == "000AAAAAS"));
|
||
|
||
bs(&mut e, "BADWORDS #c ADD fr[ao]g");
|
||
bs(&mut e, "KICK #c BADWORDS ON");
|
||
// Matching line (case-insensitive) is kicked; clean line is not.
|
||
assert!(kicked(&say(&mut e, "i love FROGS")), "matched badword kicked");
|
||
assert!(!kicked(&say(&mut e, "hello world")), "clean line ok");
|
||
|
||
// Adding a pattern bumps the revision, so the cached set rebuilds.
|
||
bs(&mut e, "BADWORDS #c ADD wibble");
|
||
assert!(kicked(&say(&mut e, "wibble wobble")), "new pattern matches after rebuild");
|
||
|
||
// An invalid regex is rejected and not stored.
|
||
let out = bs(&mut e, "BADWORDS #c ADD [unclosed");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("valid regular expression"))), "invalid rejected: {out:?}");
|
||
assert!(!say(&mut e, "[unclosed bracket text").iter().any(|a| matches!(a, NetAction::Kick { .. })), "bad pattern not stored");
|
||
}
|
||
|
||
// COPY clones one channel's kicker/badword config onto another.
|
||
#[test]
|
||
fn botserv_copy_bot_config() {
|
||
use echo_botserv::BotServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-bscopy.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register_channel("#c", "boss").unwrap();
|
||
db.register_channel("#d", "boss").unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
bs(&mut e, "KICK #c CAPS ON");
|
||
bs(&mut e, "BADWORDS #c ADD fr[ao]g");
|
||
bs(&mut e, "KICK #c BADWORDS ON");
|
||
let says = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
|
||
// #d starts blank, so nothing trips.
|
||
assert!(says(&bs(&mut e, "KICK #d TEST SHOUTING LOUD ALLCAPS"), "trips no content kicker"), "blank #d");
|
||
bs(&mut e, "COPY #c #d");
|
||
// Now #d has #c's caps and badword config.
|
||
assert!(says(&bs(&mut e, "KICK #d TEST SHOUTING LOUD ALLCAPS"), "would be kicked"), "caps copied");
|
||
assert!(says(&bs(&mut e, "KICK #d TEST i love frogs"), "would be kicked"), "badwords copied");
|
||
}
|
||
|
||
// TRIGGER makes the assigned bot auto-respond to a matching line, with $nick
|
||
// substituted for the speaker.
|
||
#[test]
|
||
fn botserv_trigger_auto_responds() {
|
||
let (mut e, _p) = kicker_fixture("bstrigger");
|
||
let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
let say = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "#c".into(), text: t.into() });
|
||
bs(&mut e, "TRIGGER #c ADD (?i)hello|Hi $nick, welcome!");
|
||
|
||
// A matching line gets a bot response addressed with the speaker's nick.
|
||
let out = say(&mut e, "hello everyone");
|
||
assert!(
|
||
out.iter().any(|a| matches!(a, NetAction::Privmsg { from, to, text } if from.starts_with("42SB") && to == "#c" && text == "Hi spammer, welcome!")),
|
||
"auto-response: {out:?}"
|
||
);
|
||
// A non-matching line is ignored.
|
||
assert!(!say(&mut e, "goodbye all").iter().any(|a| matches!(a, NetAction::Privmsg { .. })), "no response on miss");
|
||
}
|
||
|
||
// Community !votekick tallies distinct voters and kicks at the threshold.
|
||
#[test]
|
||
fn botserv_votekick_reaches_threshold() {
|
||
let (mut e, _p) = kicker_fixture("bsvote");
|
||
let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
let vote = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "#c".into(), text: t.into() });
|
||
bs(&mut e, "SET #c VOTEKICK 2");
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAV".into(), nick: "victim".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "carol".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Join { uid: "000AAAAAV".into(), channel: "#c".into(), op: false }); // the target must be in the channel
|
||
let kicked = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Kick { from, uid, .. } if from.starts_with("42SB") && uid == "000AAAAAV"));
|
||
|
||
// First voter, then the same voter again (deduped) — no kick yet.
|
||
assert!(!kicked(&vote(&mut e, "000AAAAAB", "!votekick victim")), "1 vote: no kick");
|
||
assert!(!kicked(&vote(&mut e, "000AAAAAB", "!votekick victim")), "same voter doesn't double-count");
|
||
// A second distinct voter reaches the threshold.
|
||
assert!(kicked(&vote(&mut e, "000AAAAAC", "!votekick victim")), "2 distinct votes: kicked");
|
||
}
|
||
|
||
// HostServ: a vhost is applied on identify and by SET, listed and removed by
|
||
// operators, and its administration is oper-gated with host validation.
|
||
#[test]
|
||
fn hostserv_assigns_and_applies_vhosts() {
|
||
use echo_hostserv::HostServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-hostserv.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register("alice", "password1", None).unwrap();
|
||
db.set_vhost("alice", "alice.vhost.example", "system", None).unwrap(); // pre-assigned
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(HostServ { uid: "42SAAAAAG".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
let hs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAG".into(), text: t.into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "realhost".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAV".into(), nick: "alice".into(), host: "realhost".into() , ip: "0.0.0.0".into() });
|
||
let sethost = |out: &[NetAction], uid: &str, host: &str| out.iter().any(|a| matches!(a, NetAction::SetHost { uid: u, host: h } if u == uid && h == host));
|
||
|
||
// Identifying applies the pre-assigned vhost.
|
||
assert!(sethost(&ns(&mut e, "000AAAAAV", "IDENTIFY password1"), "000AAAAAV", "alice.vhost.example"), "vhost applied on identify");
|
||
ns(&mut e, "000AAAAAB", "IDENTIFY password1");
|
||
|
||
// An operator SET applies at once to the online session.
|
||
assert!(sethost(&hs(&mut e, "000AAAAAB", "SET alice new.host.example"), "000AAAAAV", "new.host.example"), "SET applies online");
|
||
// ON re-activates the account's vhost.
|
||
assert!(sethost(&hs(&mut e, "000AAAAAV", "ON"), "000AAAAAV", "new.host.example"), "ON re-applies");
|
||
// LIST shows it.
|
||
assert!(hs(&mut e, "000AAAAAB", "LIST").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("alice") && text.contains("new.host.example"))), "listed");
|
||
// DEL restores the real host on the online session.
|
||
assert!(sethost(&hs(&mut e, "000AAAAAB", "DEL alice"), "000AAAAAV", "realhost"), "DEL restores real host");
|
||
|
||
// Non-operators can't SET; invalid hosts are rejected.
|
||
assert!(hs(&mut e, "000AAAAAV", "SET boss x.y").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "oper-gated");
|
||
assert!(hs(&mut e, "000AAAAAB", "SET alice not a host").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("isn't a valid host"))), "host validated");
|
||
}
|
||
|
||
// A vhost is normalised the way the ircd displays it (_ -> -) and can't be
|
||
// assigned to two accounts (uniqueness on the normalised form).
|
||
#[test]
|
||
fn hostserv_normalises_and_dedupes() {
|
||
use echo_hostserv::HostServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-hsnorm.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register("alice", "password1", None).unwrap();
|
||
db.register("bob", "password1", None).unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(HostServ { uid: "42SAAAAAG".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
let hs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAG".into(), text: t.into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "realhost".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAV".into(), nick: "alice".into(), host: "realhost".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAW".into(), nick: "bob".into(), host: "realhost".into() , ip: "0.0.0.0".into() });
|
||
ns(&mut e, "000AAAAAB", "IDENTIFY password1");
|
||
ns(&mut e, "000AAAAAV", "IDENTIFY password1");
|
||
ns(&mut e, "000AAAAAW", "IDENTIFY password1");
|
||
let says = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
|
||
// The underscore is normalised to a hyphen, so what's applied matches the
|
||
// ircd's display.
|
||
let out = hs(&mut e, "000AAAAAB", "SET alice cool_user.example");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::SetHost { host, .. } if host == "cool-user.example")), "normalised: {out:?}");
|
||
// bob can't take the same host (even spelled with the underscore).
|
||
assert!(says(&hs(&mut e, "000AAAAAB", "SET bob cool_user.example"), "already in use"), "uniqueness enforced");
|
||
// A distinct host is fine.
|
||
assert!(hs(&mut e, "000AAAAAB", "SET bob other.example").iter().any(|a| matches!(a, NetAction::SetHost { host, .. } if host == "other.example")), "distinct host ok");
|
||
}
|
||
|
||
// A temporary vhost applies while valid; an already-expired one is ignored.
|
||
#[test]
|
||
fn hostserv_vhost_expiry() {
|
||
use echo_hostserv::HostServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-hsexpiry.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register("alice", "password1", None).unwrap();
|
||
db.register("bob", "password1", None).unwrap();
|
||
db.set_vhost("bob", "old.host.example", "system", Some(0)).unwrap(); // already expired
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(HostServ { uid: "42SAAAAAG".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
let hs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAG".into(), text: t.into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "realhost".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAV".into(), nick: "alice".into(), host: "realhost".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAW".into(), nick: "bob".into(), host: "realhost".into() , ip: "0.0.0.0".into() });
|
||
ns(&mut e, "000AAAAAB", "IDENTIFY password1");
|
||
ns(&mut e, "000AAAAAV", "IDENTIFY password1");
|
||
|
||
// bob's expired vhost is not applied on identify.
|
||
assert!(!ns(&mut e, "000AAAAAW", "IDENTIFY password1").iter().any(|a| matches!(a, NetAction::SetHost { .. })), "expired vhost not applied");
|
||
// A temporary vhost with a duration applies now and lists as temporary.
|
||
let out = hs(&mut e, "000AAAAAB", "SET alice temp.host.example 1h");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::SetHost { uid, host } if uid == "000AAAAAV" && host == "temp.host.example")), "temporary vhost applied: {out:?}");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("expires in 1h"))), "expiry announced");
|
||
assert!(hs(&mut e, "000AAAAAB", "LIST").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("alice") && text.contains("temporary"))), "listed as temporary");
|
||
}
|
||
|
||
// Forbidden patterns block impersonating requests; the template + DEFAULT
|
||
// gives a user a $account-based vhost.
|
||
#[test]
|
||
fn hostserv_forbid_and_template() {
|
||
use echo_hostserv::HostServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-hsforbid.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register("alice", "password1", None).unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(HostServ { uid: "42SAAAAAG".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
let hs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAG".into(), text: t.into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "realhost".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAV".into(), nick: "alice".into(), host: "realhost".into() , ip: "0.0.0.0".into() });
|
||
ns(&mut e, "000AAAAAB", "IDENTIFY password1");
|
||
ns(&mut e, "000AAAAAV", "IDENTIFY password1");
|
||
let says = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
|
||
// Forbid impersonating hosts; a matching request is refused, others pass.
|
||
hs(&mut e, "000AAAAAB", "FORBID (?i)(admin|staff)");
|
||
assert!(says(&hs(&mut e, "000AAAAAV", "REQUEST admin.example"), "isn't allowed"), "impersonating request blocked");
|
||
assert!(says(&hs(&mut e, "000AAAAAV", "REQUEST cool.example"), "will review"), "clean request allowed");
|
||
// A second request right away is throttled.
|
||
assert!(says(&hs(&mut e, "000AAAAAV", "REQUEST another.example"), "Please wait"), "request throttled");
|
||
assert!(says(&hs(&mut e, "000AAAAAV", "FORBID x"), "Access denied"), "FORBID oper-gated");
|
||
|
||
// A template lets a user self-serve a $account vhost.
|
||
hs(&mut e, "000AAAAAB", "TEMPLATE $account.users.example");
|
||
let out = hs(&mut e, "000AAAAAV", "DEFAULT");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::SetHost { uid, host } if uid == "000AAAAAV" && host == "alice.users.example")), "template applied: {out:?}");
|
||
}
|
||
|
||
// The vhost OFFER menu: operators OFFER specs, users TAKE them self-serve.
|
||
#[test]
|
||
fn hostserv_offer_menu() {
|
||
use echo_hostserv::HostServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-hsoffer.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register("alice", "password1", None).unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(HostServ { uid: "42SAAAAAG".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
let hs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAG".into(), text: t.into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "realhost".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAV".into(), nick: "alice".into(), host: "realhost".into() , ip: "0.0.0.0".into() });
|
||
ns(&mut e, "000AAAAAB", "IDENTIFY password1");
|
||
ns(&mut e, "000AAAAAV", "IDENTIFY password1");
|
||
let says = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
|
||
// An operator offers a vhost; anyone can list it.
|
||
hs(&mut e, "000AAAAAB", "OFFER free.cloak.example");
|
||
assert!(says(&hs(&mut e, "000AAAAAV", "OFFERLIST"), "free.cloak.example"), "listed");
|
||
// A user takes it and it applies to them.
|
||
let out = hs(&mut e, "000AAAAAV", "TAKE 1");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::SetHost { uid, host } if uid == "000AAAAAV" && host == "free.cloak.example")), "TAKE applies: {out:?}");
|
||
// The operator removes it; a non-operator can't offer.
|
||
assert!(says(&hs(&mut e, "000AAAAAB", "OFFERDEL 1"), "Removed"), "removed");
|
||
assert!(says(&hs(&mut e, "000AAAAAV", "OFFER x.example"), "Access denied"), "oper-gated");
|
||
}
|
||
|
||
// An ident@host vhost applies both the ident (CHGIDENT) and the host.
|
||
#[test]
|
||
fn hostserv_ident_at_host() {
|
||
use echo_hostserv::HostServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-hsident.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register("alice", "password1", None).unwrap();
|
||
db.set_vhost("alice", "web@cloak.example", "system", None).unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(HostServ { uid: "42SAAAAAG".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAV".into(), nick: "alice".into(), host: "realhost".into() , ip: "0.0.0.0".into() });
|
||
|
||
let out = ns(&mut e, "000AAAAAV", "IDENTIFY password1");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::SetIdent { uid, ident } if uid == "000AAAAAV" && ident == "web")), "ident set: {out:?}");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::SetHost { uid, host } if uid == "000AAAAAV" && host == "cloak.example")), "host set");
|
||
}
|
||
|
||
// HostServ REQUEST -> WAITING -> ACTIVATE assigns and applies the vhost; a
|
||
// REJECT clears the request without assigning anything.
|
||
#[test]
|
||
fn hostserv_request_workflow() {
|
||
use echo_hostserv::HostServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-hsreq.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register("alice", "password1", None).unwrap();
|
||
db.register("bob", "password1", None).unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(HostServ { uid: "42SAAAAAG".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
let hs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAG".into(), text: t.into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "realhost".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAV".into(), nick: "alice".into(), host: "realhost".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAW".into(), nick: "bob".into(), host: "realhost".into() , ip: "0.0.0.0".into() });
|
||
ns(&mut e, "000AAAAAB", "IDENTIFY password1");
|
||
ns(&mut e, "000AAAAAV", "IDENTIFY password1");
|
||
ns(&mut e, "000AAAAAW", "IDENTIFY password1");
|
||
let says = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
|
||
// alice requests; the request shows up in WAITING.
|
||
hs(&mut e, "000AAAAAV", "REQUEST alice.cool.example");
|
||
assert!(says(&hs(&mut e, "000AAAAAB", "WAITING"), "alice.cool.example"), "request waiting");
|
||
// ACTIVATE assigns + applies it to alice's online session.
|
||
let out = hs(&mut e, "000AAAAAB", "ACTIVATE alice");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::SetHost { uid, host } if uid == "000AAAAAV" && host == "alice.cool.example")), "activated + applied: {out:?}");
|
||
// The request is consumed; WAITING is empty again.
|
||
assert!(says(&hs(&mut e, "000AAAAAB", "WAITING"), "No vhost requests"), "request consumed");
|
||
|
||
// A different user's request, rejected, assigns nothing.
|
||
hs(&mut e, "000AAAAAW", "REQUEST other.example");
|
||
assert!(says(&hs(&mut e, "000AAAAAB", "REJECT bob"), "Rejected"), "rejected");
|
||
assert!(says(&hs(&mut e, "000AAAAAB", "WAITING"), "No vhost requests"), "no request left after reject");
|
||
// A non-operator can't approve.
|
||
assert!(says(&hs(&mut e, "000AAAAAV", "WAITING"), "Access denied"), "oper-gated");
|
||
|
||
// A host forbidden AFTER its request is filed must not slip through ACTIVATE.
|
||
hs(&mut e, "000AAAAAB", "REQUEST evilcorp.example");
|
||
hs(&mut e, "000AAAAAB", "FORBID (?i)evilcorp");
|
||
let out = hs(&mut e, "000AAAAAB", "ACTIVATE boss");
|
||
assert!(says(&out, "forbidden list"), "activate refused for a now-forbidden host: {out:?}");
|
||
assert!(!out.iter().any(|a| matches!(a, NetAction::SetHost { .. })), "no vhost applied when forbidden: {out:?}");
|
||
}
|
||
|
||
// StatServ reports per-channel activity (#channel) and the global registry
|
||
// (SERVER, operators only).
|
||
#[test]
|
||
fn statserv_reports_channel_and_global() {
|
||
use echo_botserv::BotServ;
|
||
use echo_nickserv::NickServ;
|
||
use echo_statserv::StatServ;
|
||
let path = std::env::temp_dir().join("echo-statserv.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register_channel("#c", "boss").unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||
Box::new(StatServ { uid: "42SAAAAAF".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin).with(echo_api::Priv::Auspex));
|
||
e.set_opers(opers);
|
||
let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
let ss = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAF".into(), text: t.into() });
|
||
let say = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "#c".into(), text: t.into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "spammer".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
bs(&mut e, "BOT ADD Bendy bot serv.host Helper");
|
||
bs(&mut e, "ASSIGN #c Bendy");
|
||
say(&mut e, "one");
|
||
say(&mut e, "two");
|
||
say(&mut e, "three");
|
||
|
||
// Per-channel view.
|
||
let out = ss(&mut e, "#c");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("lines seen"))), "channel lines: {out:?}");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("spammer") && text.contains("3"))), "top talker: {out:?}");
|
||
// Global view (oper) shows the shared counters.
|
||
let out = ss(&mut e, "SERVER");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("botserv.messages"))), "global counters: {out:?}");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("channels.total"))), "gauge shown");
|
||
}
|
||
|
||
// The shared stats registry gathers per-service command counts, BotServ
|
||
// events, and live gauges — the same pipe every module reports through.
|
||
#[test]
|
||
fn stats_registry_collects_across_services() {
|
||
let (mut e, _p) = kicker_fixture("bsstats");
|
||
let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
let say = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "#c".into(), text: t.into() });
|
||
// The fixture already ran a NickServ IDENTIFY and BotServ BOT ADD/ASSIGN.
|
||
bs(&mut e, "KICK #c CAPS ON");
|
||
say(&mut e, "SHOUTING LOUDLY NOW"); // one kick
|
||
|
||
let s = e.stats_snapshot();
|
||
assert!(s.get("nickserv.command").copied().unwrap_or(0) >= 1, "nickserv counted: {s:?}");
|
||
assert!(s.get("botserv.command").copied().unwrap_or(0) >= 2, "botserv commands counted");
|
||
assert_eq!(s.get("botserv.kick").copied(), Some(1), "one kick counted");
|
||
// Live gauges derived from the store.
|
||
assert_eq!(s.get("channels.total").copied(), Some(1));
|
||
assert_eq!(s.get("bots.total").copied(), Some(1));
|
||
assert!(s.contains_key("accounts.total"));
|
||
}
|
||
|
||
// DONTKICKVOICES exempts voiced users; removing the voice makes them kickable.
|
||
#[test]
|
||
fn botserv_dontkickvoices_exempts() {
|
||
let (mut e, _p) = kicker_fixture("bsdkv");
|
||
let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
let say = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "#c".into(), text: t.into() });
|
||
let kicked = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Kick { uid, .. } if uid == "000AAAAAS"));
|
||
bs(&mut e, "KICK #c CAPS ON");
|
||
bs(&mut e, "KICK #c DONTKICKVOICES ON");
|
||
|
||
// Voiced: exempt.
|
||
e.handle(NetEvent::ChannelVoice { channel: "#c".into(), uid: "000AAAAAS".into(), voice: true });
|
||
assert!(!kicked(&say(&mut e, "SHOUTING WHILE VOICED")), "voiced user exempt");
|
||
// Devoiced: kickable again.
|
||
e.handle(NetEvent::ChannelVoice { channel: "#c".into(), uid: "000AAAAAS".into(), voice: false });
|
||
assert!(kicked(&say(&mut e, "SHOUTING WITHOUT VOICE")), "devoiced user kicked");
|
||
}
|
||
|
||
// Triggers substitute regex capture groups ($1) and honour a cooldown.
|
||
#[test]
|
||
fn botserv_trigger_captures_and_cooldown() {
|
||
let (mut e, _p) = kicker_fixture("bstrigcap");
|
||
let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
let say = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "#c".into(), text: t.into() });
|
||
bs(&mut e, "TRIGGER #c ADD (?i)hi (\\w+)|Hi $1!|30"); // capture group + 30s cooldown
|
||
let response = |out: &[NetAction]| out.iter().find_map(|a| match a {
|
||
NetAction::Privmsg { text, .. } => Some(text.clone()),
|
||
_ => None,
|
||
});
|
||
|
||
e.now_override = Some(1000);
|
||
assert_eq!(response(&say(&mut e, "hi alice")).as_deref(), Some("Hi alice!"), "capture substituted");
|
||
// Within the cooldown window: suppressed.
|
||
e.now_override = Some(1010);
|
||
assert_eq!(response(&say(&mut e, "hi bob")), None, "suppressed by cooldown");
|
||
// After the cooldown: fires again.
|
||
e.now_override = Some(1031);
|
||
assert_eq!(response(&say(&mut e, "hi carol")).as_deref(), Some("Hi carol!"), "fires after cooldown");
|
||
}
|
||
|
||
// WARN gives a one-time warning before the first real kick.
|
||
#[test]
|
||
fn botserv_warn_before_kick() {
|
||
let (mut e, _p) = kicker_fixture("bswarn");
|
||
let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
let say = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "#c".into(), text: t.into() });
|
||
bs(&mut e, "KICK #c CAPS ON");
|
||
bs(&mut e, "KICK #c WARN ON");
|
||
let kicked = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Kick { uid, .. } if uid == "000AAAAAS"));
|
||
|
||
// First offence: a warning notice from the bot, no kick.
|
||
let out = say(&mut e, "SHOUTING THE FIRST TIME");
|
||
assert!(!kicked(&out), "no kick on first offence");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { from, to, text } if from.starts_with("42SB") && to == "000AAAAAS" && text.contains("Next time"))), "warned: {out:?}");
|
||
// Second offence: kicked for real.
|
||
assert!(kicked(&say(&mut e, "SHOUTING THE SECOND TIME")), "kicked on second offence");
|
||
}
|
||
|
||
// KICK TEST dry-runs the content kickers against a line without kicking.
|
||
#[test]
|
||
fn botserv_kick_test_dry_run() {
|
||
let (mut e, _p) = kicker_fixture("bskicktest");
|
||
let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
bs(&mut e, "KICK #c CAPS ON");
|
||
bs(&mut e, "BADWORDS #c ADD fr[ao]g");
|
||
bs(&mut e, "KICK #c BADWORDS ON");
|
||
let says = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
|
||
assert!(says(&bs(&mut e, "KICK #c TEST HELLO EVERYONE LOUD"), "would be kicked"), "caps flagged");
|
||
assert!(says(&bs(&mut e, "KICK #c TEST i love frogs"), "would be kicked"), "badword flagged");
|
||
assert!(says(&bs(&mut e, "KICK #c TEST hello there"), "trips no content kicker"), "clean line ok");
|
||
}
|
||
|
||
// Times-to-ban: after TTB kicks the bot bans the user, and BANEXPIRE lifts
|
||
// the ban once it elapses (swept on the next event).
|
||
#[test]
|
||
fn botserv_ttb_bans_and_expires() {
|
||
let (mut e, _p) = kicker_fixture("bsttb");
|
||
let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
let say = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "#c".into(), text: t.into() });
|
||
bs(&mut e, "KICK #c CAPS ON");
|
||
bs(&mut e, "KICK #c TTB 2");
|
||
bs(&mut e, "SET #c BANEXPIRE 1m");
|
||
let banned = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::ChannelMode { from, channel, modes } if from.starts_with("42SB") && channel == "#c" && modes == "+b *!*@h"));
|
||
let kicked = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Kick { uid, .. } if uid == "000AAAAAS"));
|
||
|
||
e.now_override = Some(1000);
|
||
// First offence: kick only.
|
||
let out = say(&mut e, "SHOUTING LOUDLY ONE");
|
||
assert!(kicked(&out) && !banned(&out), "1st: kick only: {out:?}");
|
||
// Second offence reaches TTB: ban then kick.
|
||
let out = say(&mut e, "SHOUTING LOUDLY TWO");
|
||
assert!(banned(&out) && kicked(&out), "2nd: ban + kick: {out:?}");
|
||
|
||
// Before the ban expires, an event lifts nothing.
|
||
e.now_override = Some(1059);
|
||
let out = e.handle(NetEvent::Ping { token: "t".into(), from: None });
|
||
assert!(!out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes.starts_with("-b"))), "not yet expired: {out:?}");
|
||
// After it expires, the next event lifts it.
|
||
e.now_override = Some(1061);
|
||
let out = e.handle(NetEvent::Ping { token: "t".into(), from: None });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#c" && modes == "-b *!*@h")), "ban lifted: {out:?}");
|
||
}
|
||
|
||
// Registers #c with founder boss (admin), a live assigned bot Bendy, and
|
||
// returns the engine ready for KICK configuration + a spammer uid 000AAAAAS.
|
||
fn kicker_fixture(tag: &str) -> (Engine, std::path::PathBuf) {
|
||
use echo_botserv::BotServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join(format!("echo-{tag}.jsonl"));
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register_channel("#c", "boss").unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "spammer".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: "BOT ADD Bendy bot serv.host Helper".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: "ASSIGN #c Bendy".into() });
|
||
(e, path)
|
||
}
|
||
|
||
// A member's personal greet is shown by the assigned bot on join, once the
|
||
// channel enables greets and the member has access.
|
||
#[test]
|
||
fn botserv_greet_shown_on_join() {
|
||
use echo_botserv::BotServ;
|
||
use echo_chanserv::ChanServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-bsgreet.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register_channel("#c", "boss").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() }),
|
||
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
e.chan_service = Some("42SAAAAAB".into());
|
||
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
let bs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
ns(&mut e, "000AAAAAB", "IDENTIFY password1");
|
||
ns(&mut e, "000AAAAAB", "SET GREET hello all");
|
||
bs(&mut e, "000AAAAAB", "BOT ADD Bendy bot serv.host Helper");
|
||
bs(&mut e, "000AAAAAB", "ASSIGN #c Bendy");
|
||
|
||
// Greets off by default: joining is silent.
|
||
assert!(
|
||
!e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: true }).iter().any(|a| matches!(a, NetAction::Privmsg { .. })),
|
||
"no greet before it is enabled"
|
||
);
|
||
e.handle(NetEvent::Part { uid: "000AAAAAB".into(), channel: "#c".into(), reason: String::new() });
|
||
bs(&mut e, "000AAAAAB", "SET #c GREET ON");
|
||
// Now the founder's greet is shown by the bot.
|
||
let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: true });
|
||
assert!(
|
||
out.iter().any(|a| matches!(a, NetAction::Privmsg { from, to, text } if from.starts_with("42SB") && to == "#c" && text == "[boss] hello all")),
|
||
"greet shown: {out:?}"
|
||
);
|
||
}
|
||
|
||
// Fantasy commands typed in-channel route through ChanServ and are sourced
|
||
// from the assigned bot.
|
||
#[test]
|
||
fn fantasy_roll_speaks_dice_through_the_bot() {
|
||
use echo_botserv::BotServ;
|
||
use echo_chanserv::ChanServ;
|
||
use echo_diceserv::DiceServ;
|
||
let path = std::env::temp_dir().join("echo-fantasyroll.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register_channel("#c", "boss").unwrap();
|
||
db.register_channel("#d", "boss").unwrap(); // no bot
|
||
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() }),
|
||
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||
Box::new(DiceServ { uid: "42SAAAAAI".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let bs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
let chan = |e: &mut Engine, uid: &str, c: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: c.into(), text: t.into() });
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
bs(&mut e, "000AAAAAB", "BOT ADD Bendy bot serv.host Helper");
|
||
bs(&mut e, "000AAAAAB", "ASSIGN #c Bendy");
|
||
|
||
// `!roll` is answered by DiceServ but spoken into the channel by the bot.
|
||
let out = chan(&mut e, "000AAAAAB", "#c", "!roll 2d6+1");
|
||
assert!(
|
||
out.iter().any(|a| matches!(a, NetAction::Privmsg { from, to, text } if from.starts_with("42SB") && to == "#c" && text.contains("2d6+1") && text.contains('='))),
|
||
"fantasy roll spoken by the bot: {out:?}"
|
||
);
|
||
// No DiceServ (or no bot) → no dice fantasy.
|
||
assert!(chan(&mut e, "000AAAAAB", "#d", "!roll 2d6").is_empty(), "no bot, no roll");
|
||
}
|
||
|
||
#[test]
|
||
fn botserv_fantasy_routes_through_bot() {
|
||
use echo_botserv::BotServ;
|
||
use echo_chanserv::ChanServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-bsfantasy.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register_channel("#c", "boss").unwrap();
|
||
db.register_channel("#d", "boss").unwrap(); // registered, no bot
|
||
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() }),
|
||
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
let bs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
let chan = |e: &mut Engine, uid: &str, c: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: c.into(), text: t.into() });
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
ns(&mut e, "000AAAAAB", "IDENTIFY password1");
|
||
bs(&mut e, "000AAAAAB", "BOT ADD Bendy bot serv.host Helper");
|
||
bs(&mut e, "000AAAAAB", "ASSIGN #c Bendy");
|
||
|
||
// `!op` (founder, no target) ops the invoker, sourced from the bot uid.
|
||
let out = chan(&mut e, "000AAAAAB", "#c", "!op");
|
||
assert!(
|
||
out.iter().any(|a| matches!(a, NetAction::ChannelMode { from, channel, modes } if from.starts_with("42SB") && channel == "#c" && modes.contains("+o") && modes.contains("000AAAAAB"))),
|
||
"fantasy op via bot: {out:?}"
|
||
);
|
||
// Ordinary chatter is ignored.
|
||
assert!(chan(&mut e, "000AAAAAB", "#c", "hello everyone").is_empty(), "chatter ignored");
|
||
// Fantasy in a channel with no bot does nothing.
|
||
assert!(chan(&mut e, "000AAAAAB", "#d", "!op").is_empty(), "no bot, no fantasy");
|
||
|
||
// An unknown `!word` (another bot's command sharing the `!` prefix) draws
|
||
// no reply from the assigned bot — not even "I don't know that command".
|
||
let out = chan(&mut e, "000AAAAAB", "#c", "!cissue \"CERT LIST\" fix it");
|
||
assert!(out.is_empty(), "unknown fantasy command stays silent: {out:?}");
|
||
}
|
||
|
||
// MemoServ delivers a memo to an offline account and notifies them on login.
|
||
#[test]
|
||
fn memoserv_delivers_and_notifies_on_login() {
|
||
use echo_memoserv::MemoServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-memoserv.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "password1", None).unwrap();
|
||
db.register("bob", "password1", None).unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(MemoServ { uid: "42SAAAAAE".into() }),
|
||
],
|
||
db,
|
||
);
|
||
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
let ms = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAE".into(), text: t.into() });
|
||
let notice = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
|
||
// alice identifies and sends bob (offline) a memo.
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
ns(&mut e, "000AAAAAB", "IDENTIFY password1");
|
||
assert!(notice(&ms(&mut e, "000AAAAAB", "SEND bob Hello from alice"), "Memo sent"));
|
||
|
||
// bob logs in later and is told he has a new memo, then reads it.
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
assert!(notice(&ns(&mut e, "000AAAAAC", "IDENTIFY password1"), "new memo"));
|
||
assert!(notice(&ms(&mut e, "000AAAAAC", "READ NEW"), "Hello from alice"));
|
||
}
|
||
|
||
// Channel SUSPEND is oper-gated and freezes founder management until lifted.
|
||
#[test]
|
||
fn channel_suspend_is_oper_gated_and_freezes_management() {
|
||
use echo_chanserv::ChanServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-chansuspendcmd.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register("staff", "password1", None).unwrap();
|
||
db.register_channel("#c", "boss").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 mut opers = std::collections::HashMap::new();
|
||
opers.insert("staff".to_string(), Privs::default().with(echo_api::Priv::Suspend));
|
||
e.set_opers(opers);
|
||
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
let cs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAB".into(), text: t.into() });
|
||
let notice = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "staff".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
ns(&mut e, "000AAAAAB", "IDENTIFY password1");
|
||
ns(&mut e, "000AAAAAC", "IDENTIFY password1");
|
||
|
||
// A non-oper cannot suspend a channel.
|
||
assert!(notice(&cs(&mut e, "000AAAAAB", "SUSPEND #c"), "Access denied"));
|
||
// The oper suspends it.
|
||
assert!(notice(&cs(&mut e, "000AAAAAC", "SUSPEND #c raided"), "now suspended"));
|
||
// The founder can no longer manage it — including MODE/ACCESS/FLAGS, which
|
||
// used to bypass the suspension freeze.
|
||
assert!(notice(&cs(&mut e, "000AAAAAB", "SET #c SIGNKICK ON"), "suspended"));
|
||
assert!(notice(&cs(&mut e, "000AAAAAB", "MODE #c +m"), "suspended"), "MODE frozen while suspended");
|
||
assert!(notice(&cs(&mut e, "000AAAAAB", "ACCESS #c ADD someone sop"), "suspended"), "ACCESS frozen while suspended");
|
||
assert!(notice(&cs(&mut e, "000AAAAAB", "FLAGS #c someone +o"), "suspended"), "FLAGS frozen while suspended");
|
||
// UNSUSPEND restores management.
|
||
assert!(notice(&cs(&mut e, "000AAAAAC", "UNSUSPEND #c"), "no longer suspended"));
|
||
assert!(notice(&cs(&mut e, "000AAAAAB", "SET #c SIGNKICK ON"), "is now"));
|
||
}
|
||
|
||
// SUSPEND is oper-gated, blocks the victim's login, and UNSUSPEND restores it.
|
||
#[test]
|
||
fn suspend_blocks_login_and_needs_oper() {
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-suspendcmd.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("victim", "password1", None).unwrap();
|
||
db.register("staff", "password1", None).unwrap();
|
||
let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db);
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("staff".to_string(), Privs::default().with(echo_api::Priv::Suspend));
|
||
e.set_opers(opers);
|
||
let to_ns = |e: &mut Engine, uid: &str, text: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: text.into() });
|
||
let notice = |out: &[NetAction], needle: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle)));
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "victim".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "staff".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
|
||
// A non-oper cannot suspend.
|
||
assert!(notice(&to_ns(&mut e, "000AAAAAB", "SUSPEND victim"), "Access denied"));
|
||
// The oper identifies and suspends the victim.
|
||
to_ns(&mut e, "000AAAAAC", "IDENTIFY password1");
|
||
assert!(notice(&to_ns(&mut e, "000AAAAAC", "SUSPEND victim being a nuisance"), "now suspended"));
|
||
// The victim can no longer identify.
|
||
assert!(notice(&to_ns(&mut e, "000AAAAAB", "IDENTIFY password1"), "suspended"));
|
||
// UNSUSPEND lets them back in.
|
||
assert!(notice(&to_ns(&mut e, "000AAAAAC", "UNSUSPEND victim"), "no longer suspended"));
|
||
assert!(notice(&to_ns(&mut e, "000AAAAAB", "IDENTIFY password1"), "now identified"));
|
||
}
|
||
|
||
// An auspex oper sees another account's hidden INFO (email); a non-oper does not.
|
||
#[test]
|
||
fn auspex_oper_sees_hidden_info() {
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-auspex.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "password1", Some("alice@example.org".into())).unwrap();
|
||
db.register("operator", "password1", None).unwrap();
|
||
let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db);
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("operator".to_string(), Privs::default().with(echo_api::Priv::Auspex));
|
||
e.set_opers(opers);
|
||
let info_alice = |e: &mut Engine, uid: &str| {
|
||
e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: "INFO alice".into() })
|
||
};
|
||
let shows_email = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("alice@example.org")));
|
||
|
||
// The auspex oper identifies and sees alice's email.
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "operator".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
assert!(shows_email(&info_alice(&mut e, "000AAAAAB")), "auspex oper sees the email");
|
||
|
||
// An unidentified (non-oper) viewer does not.
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "guest".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
assert!(!shows_email(&info_alice(&mut e, "000AAAAAC")), "non-oper sees no email");
|
||
}
|
||
|
||
// TOPICLOCK reverts an unauthorised topic change; KEEPTOPIC restores the
|
||
// remembered topic when the channel is recreated.
|
||
#[test]
|
||
fn topiclock_reverts_and_keeptopic_restores() {
|
||
use echo_chanserv::ChanServ;
|
||
let path = std::env::temp_dir().join("echo-topic.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.register_channel("#c", "boss").unwrap();
|
||
db.set_channel_setting("#c", db::ChanSetting::KeepTopic, true).unwrap();
|
||
db.set_channel_setting("#c", db::ChanSetting::TopicLock, true).unwrap();
|
||
db.set_channel_topic("#c", "Official topic").unwrap();
|
||
let mut e = Engine::new(vec![Box::new(ChanServ { uid: "42SAAAAAB".into() })], db);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "rando".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
// A user without access changes the topic -> reverted to the stored one.
|
||
let out = e.handle(NetEvent::TopicChange { channel: "#c".into(), setter: "000AAAAAB".into(), topic: "hacked".into() });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Topic { topic, .. } if topic == "Official topic")), "topiclock reverts: {out:?}");
|
||
// KEEPTOPIC restores the remembered topic on channel (re)creation.
|
||
let out = e.handle(NetEvent::ChannelCreate { channel: "#c".into() });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Topic { topic, .. } if topic == "Official topic")), "keeptopic restores: {out:?}");
|
||
}
|
||
|
||
// ChanServ: registration needs identification, INFO shows the founder, and
|
||
// only the founder can drop.
|
||
#[test]
|
||
fn chanserv_register_info_drop() {
|
||
use echo_chanserv::ChanServ;
|
||
let path = std::env::temp_dir().join("echo-chanserv.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 };
|
||
let cs = ChanServ { uid: "42SAAAAAB".into() };
|
||
let mut e = Engine::new(vec![Box::new(ns), Box::new(cs)], db);
|
||
|
||
let to_cs = |e: &mut Engine, from: &str, text: &str| {
|
||
e.handle(NetEvent::Privmsg { from: from.into(), to: "42SAAAAAB".into(), text: text.into() })
|
||
};
|
||
let notice = |out: &[NetAction], needle: &str| {
|
||
out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle)))
|
||
};
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "host.example".into() , ip: "0.0.0.0".into() });
|
||
|
||
// Not identified yet: refused.
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "REGISTER #room"), "logged in"));
|
||
|
||
// Identify, then register. Registration needs operator status in the channel.
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "REGISTER #room"), "channel operator"), "op required to register");
|
||
e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#room".into(), op: true });
|
||
let out = to_cs(&mut e, "000AAAAAB", "REGISTER #room");
|
||
assert!(notice(&out, "now registered"));
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#room" && modes == "+r")), "register sets +r: {out:?}");
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "INFO #room"), "alice"));
|
||
|
||
// Founder sets a mode lock: it applies immediately and shows on view.
|
||
let out = to_cs(&mut e, "000AAAAAB", "MLOCK #room +nt-s");
|
||
assert!(notice(&out, "updated"));
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#room" && modes == "+rnt-s")), "mlock applied: {out:?}");
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "MLOCK #room"), "+nt-s"));
|
||
|
||
// Founder sets modes directly via ChanServ MODE.
|
||
let out = to_cs(&mut e, "000AAAAAB", "MODE #room +S");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#room" && modes == "+S")), "MODE applies: {out:?}");
|
||
|
||
// Founder manages the access list.
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "ACCESS #room ADD helper op"), "Added"));
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "ACCESS #room"), "helper"));
|
||
|
||
// A different, unidentified user cannot change modes, access, the lock, or drop it.
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "host.example".into() , ip: "0.0.0.0".into() });
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAC", "MODE #room +i"), "founder"));
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAC", "ACCESS #room ADD x op"), "founder"));
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAC", "MLOCK #room +i"), "founder"));
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAC", "DROP #room"), "founder"));
|
||
|
||
// The founder can, which also clears +r.
|
||
let out = to_cs(&mut e, "000AAAAAB", "DROP #room");
|
||
assert!(notice(&out, "dropped"));
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#room" && modes == "-r")), "drop clears +r: {out:?}");
|
||
}
|
||
|
||
// Notable service actions are announced to the staff audit channel, sourced
|
||
// from the services server; private/cosmetic events are not, and no channel
|
||
// means no feed.
|
||
#[test]
|
||
fn audit_feed_announces_notable_actions() {
|
||
use echo_chanserv::ChanServ;
|
||
let path = std::env::temp_dir().join("echo-audit.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).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,
|
||
);
|
||
e.set_sid("42S".into());
|
||
e.set_log_channel(Some("#services".into()));
|
||
let to_cs = |e: &mut Engine, from: &str, text: &str| {
|
||
e.handle(NetEvent::Privmsg { from: from.into(), to: "42SAAAAAB".into(), text: text.into() })
|
||
};
|
||
// The audit line is a Notice to the log channel, from the services SID.
|
||
let audit = |out: &[NetAction], needle: &str| {
|
||
out.iter().any(|a| matches!(a, NetAction::Notice { from, to, text }
|
||
if from == "42S" && to == "#services" && text.contains(needle)))
|
||
};
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "host.example".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#room".into(), op: true });
|
||
|
||
// Registering a channel is announced with the actor and the founder.
|
||
let out = to_cs(&mut e, "000AAAAAB", "REGISTER #room");
|
||
assert!(audit(&out, "registered channel \x02#room\x02"), "reg announced: {out:?}");
|
||
assert!(audit(&out, "alice"), "names the actor");
|
||
|
||
// A cosmetic mode lock is not surfaced (only its user-facing reply is).
|
||
let out = to_cs(&mut e, "000AAAAAB", "MLOCK #room +nt");
|
||
assert!(!out.iter().any(|a| matches!(a, NetAction::Notice { to, .. } if to == "#services")), "mlock not audited: {out:?}");
|
||
|
||
// Dropping the channel is announced.
|
||
assert!(audit(&to_cs(&mut e, "000AAAAAB", "DROP #room"), "dropped channel \x02#room\x02"), "drop announced");
|
||
|
||
// With no log channel configured, nothing is emitted to it.
|
||
e.set_log_channel(None);
|
||
e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#other".into(), op: true });
|
||
let out = to_cs(&mut e, "000AAAAAB", "REGISTER #other");
|
||
assert!(!out.iter().any(|a| matches!(a, NetAction::Notice { to, .. } if to == "#services")), "no feed when unset: {out:?}");
|
||
}
|
||
|
||
// Inactivity-expiry drops stale accounts and channels, but spares opers, live
|
||
// sessions, occupied channels, and anything pinned with NOEXPIRE. Each expiry
|
||
// is announced to the audit channel.
|
||
#[test]
|
||
fn expiry_sweep_respects_pins_sessions_and_opers() {
|
||
use echo_chanserv::ChanServ;
|
||
let path = std::env::temp_dir().join("echo-expiry.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
for a in ["victim", "staff", "active", "pinned"] {
|
||
db.register(a, "password1", None).unwrap();
|
||
}
|
||
db.register_channel("#dead", "staff").unwrap(); // founder is an oper, so only the channel expires
|
||
db.register_channel("#pinned", "staff").unwrap();
|
||
db.register_channel("#live", "staff").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,
|
||
);
|
||
e.set_sid("42S".into());
|
||
e.set_log_channel(Some("#services".into()));
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("staff".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||
e.set_irc_out(tx);
|
||
|
||
// staff (an oper) pins one account and one channel against expiry.
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "NOEXPIRE pinned ON".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAB".into(), text: "NOEXPIRE #pinned ON".into() });
|
||
// active keeps a live session; #live keeps a member sitting in it.
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "active".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAC".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#live".into(), op: false });
|
||
|
||
// Jump far past the threshold and sweep.
|
||
e.now_override = Some(10_000_000_000);
|
||
e.set_expiry(Some(100), Some(100), None);
|
||
e.expire_sweep();
|
||
|
||
// Only the plain, unused, unpinned, session-less records are gone.
|
||
assert!(!e.db.exists("victim"), "inactive account expired");
|
||
assert!(e.db.channel("#dead").is_none(), "unused channel expired");
|
||
assert!(e.db.exists("staff"), "oper account spared");
|
||
assert!(e.db.exists("active"), "logged-in account spared");
|
||
assert!(e.db.exists("pinned"), "NOEXPIRE account spared");
|
||
assert!(e.db.channel("#pinned").is_some(), "NOEXPIRE channel spared");
|
||
assert!(e.db.channel("#live").is_some(), "occupied channel spared");
|
||
|
||
// The expiries were announced and the dropped channel had +r cleared.
|
||
let (mut acct_note, mut chan_note, mut unreg) = (false, false, false);
|
||
while let Ok(a) = rx.try_recv() {
|
||
match a {
|
||
NetAction::Notice { to, text, .. } if to == "#services" && text.contains("victim") && text.contains("expired") => acct_note = true,
|
||
NetAction::Notice { to, text, .. } if to == "#services" && text.contains("#dead") && text.contains("expired") => chan_note = true,
|
||
NetAction::ChannelMode { channel, modes, .. } if channel == "#dead" && modes == "-r" => unreg = true,
|
||
_ => {}
|
||
}
|
||
}
|
||
assert!(acct_note, "account expiry announced to audit channel");
|
||
assert!(chan_note, "channel expiry announced to audit channel");
|
||
assert!(unreg, "expired channel had +r cleared");
|
||
}
|
||
|
||
// OperServ AKILL: an admin adds/lists/removes network bans, they drive the
|
||
// ircd's G-lines, persist for re-assertion at burst, and non-admins are shut
|
||
// out entirely.
|
||
#[test]
|
||
fn operserv_akill_add_list_del_and_burst() {
|
||
use echo_operserv::OperServ;
|
||
let path = std::env::temp_dir().join("echo-akill.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("nobody", "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());
|
||
e.set_log_channel(Some("#services".into()));
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("staff".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() });
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAN".into(), nick: "nobody".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAN".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
|
||
// A non-admin can't even see OperServ exists beyond the refusal.
|
||
let denied = os(&mut e, "000AAAAAN", "AKILL ADD *@evil.host being evil");
|
||
assert!(denied.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "non-admin refused: {denied:?}");
|
||
assert!(!denied.iter().any(|a| matches!(a, NetAction::AddLine { .. })), "no ban from a non-admin");
|
||
|
||
// Admin adds a temporary ban: it drives a G-line and is audited.
|
||
let out = os(&mut e, "000AAAAAS", "AKILL ADD +1h *@evil.host spamming");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::AddLine { kind, mask, duration, .. } if kind == "G" && mask == "*@evil.host" && *duration == 3600)), "G-line added: {out:?}");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { to, text, .. } if to == "#services" && text.contains("*@evil.host"))), "ban audited: {out:?}");
|
||
|
||
// A too-wide mask is refused outright.
|
||
assert!(os(&mut e, "000AAAAAS", "AKILL ADD *@* everything").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("too wide"))), "wildcard mask refused");
|
||
|
||
// LIST shows it, numbered.
|
||
assert!(os(&mut e, "000AAAAAS", "AKILL LIST").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("1.") && text.contains("*@evil.host"))), "listed");
|
||
|
||
// It survives to burst: startup re-asserts the G-line with a remaining
|
||
// duration, not the original 3600.
|
||
assert!(e.startup_actions().iter().any(|a| matches!(a, NetAction::AddLine { mask, duration, .. } if mask == "*@evil.host" && *duration > 0 && *duration <= 3600)), "re-asserted at burst");
|
||
|
||
// DEL by number removes it and lifts the G-line.
|
||
let out = os(&mut e, "000AAAAAS", "AKILL DEL 1");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::DelLine { kind, mask } if kind == "G" && mask == "*@evil.host")), "G-line lifted: {out:?}");
|
||
assert!(os(&mut e, "000AAAAAS", "AKILL LIST").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("No matching"))), "list now empty");
|
||
}
|
||
|
||
#[test]
|
||
fn ctcp_query_to_a_service_replies_or_stays_silent() {
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-ctcp.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let db = Db::open(&path, "42S");
|
||
let mut e = Engine::new(
|
||
vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
let ns = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
|
||
// VERSION → a CTCP NOTICE from the service, never an "unknown command".
|
||
let ver = ns(&mut e, "\x01VERSION\x01");
|
||
assert!(ver.iter().any(|a| matches!(a, NetAction::Notice { from, text, .. } if from == "42SAAAAAA" && text.starts_with("\x01VERSION echo") && text.ends_with('\x01'))), "VERSION reply: {ver:?}");
|
||
assert!(!ver.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("don't know"))), "no unknown-command bounce");
|
||
// PING echoes the token back verbatim.
|
||
assert!(ns(&mut e, "\x01PING tok123\x01").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text == "\x01PING tok123\x01")), "PING echo");
|
||
// ACTION and unrecognised CTCPs get no reply at all.
|
||
assert!(ns(&mut e, "\x01ACTION waves\x01").iter().all(|a| !matches!(a, NetAction::Notice { .. })), "ACTION silent");
|
||
assert!(ns(&mut e, "\x01FLOOBLE\x01").iter().all(|a| !matches!(a, NetAction::Notice { .. })), "unknown CTCP silent");
|
||
// A normal (non-CTCP) unknown command is still answered — the intercept
|
||
// didn't swallow ordinary routing.
|
||
assert!(ns(&mut e, "FLOOBLE").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("don't know"))), "normal unknown command still answered");
|
||
}
|
||
|
||
// An account approaching expiry with an email on file is warned once by
|
||
// email, isn't dropped while still in the window, and isn't warned twice.
|
||
#[test]
|
||
fn expiry_warns_by_email_before_dropping() {
|
||
let path = std::env::temp_dir().join("echo-expwarn.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.set_email_enabled(true);
|
||
db.register("dozer", "password1", Some("dozer@example.test".to_string())).unwrap();
|
||
db.register("nomail", "password1", None).unwrap();
|
||
let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db);
|
||
e.set_sid("42S".into());
|
||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||
e.set_irc_out(tx);
|
||
|
||
// A very wide warning window that brackets any freshly-registered account.
|
||
e.now_override = Some(10_000_000_000);
|
||
e.set_expiry(Some(9_900_000_000), None, Some(9_000_000_000));
|
||
e.expire_sweep();
|
||
|
||
// The account with an email got exactly one warning; neither was dropped.
|
||
let mut warns = 0;
|
||
while let Ok(a) = rx.try_recv() {
|
||
if let NetAction::SendEmail { to, subject, .. } = a {
|
||
assert_eq!(to, "dozer@example.test");
|
||
assert!(subject.contains("dozer") && subject.contains("expire"), "subject: {subject}");
|
||
warns += 1;
|
||
}
|
||
}
|
||
assert_eq!(warns, 1, "one warning email for the account with an address");
|
||
assert!(e.db.exists("dozer"), "still in the window, not dropped");
|
||
assert!(e.db.exists("nomail"), "no email, no warning, not dropped");
|
||
|
||
// A second sweep in the same window doesn't re-warn.
|
||
e.expire_sweep();
|
||
let mut again = 0;
|
||
while let Ok(a) = rx.try_recv() {
|
||
if matches!(a, NetAction::SendEmail { .. }) {
|
||
again += 1;
|
||
}
|
||
}
|
||
assert_eq!(again, 0, "warning isn't repeated while still idle");
|
||
}
|
||
|
||
// OperServ SQLINE (nick bans), GLOBAL (announce to all), and KILL (disconnect
|
||
// a user): the Q-line, the $* broadcast, and the KILL all reach the ircd, and
|
||
// each is admin-gated.
|
||
#[test]
|
||
fn operserv_sqline_global_and_kill() {
|
||
use echo_operserv::OperServ;
|
||
let path = std::env::temp_dir().join("echo-osmore.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("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(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() });
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAP".into(), nick: "plain".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAP".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAV".into(), nick: "spammer".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
|
||
// SQLINE drives a Q-line on the nick mask, tracked separately from AKILLs.
|
||
let out = os(&mut e, "000AAAAAS", "SQLINE ADD *bot* botnet");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::AddLine { kind, mask, .. } if kind == "Q" && mask == "*bot*")), "Q-line added: {out:?}");
|
||
// A rejected @-shaped mask (that belongs to AKILL, not SQLINE).
|
||
assert!(os(&mut e, "000AAAAAS", "SQLINE ADD a@b spam").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("valid"))), "nick mask rejects user@host");
|
||
// STATS reflects the live ban counts.
|
||
assert!(os(&mut e, "000AAAAAS", "STATS").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("1") && text.contains("SQLINE"))), "stats shows the sqline count");
|
||
// SNLINE drives a realname R-line.
|
||
assert!(os(&mut e, "000AAAAAS", "SNLINE ADD .*free.money.* spambot").iter().any(|a| matches!(a, NetAction::AddLine { kind, mask, .. } if kind == "R" && mask == ".*free.money.*")), "R-line added");
|
||
// SHUN drives a SHUN X-line on a user@host mask.
|
||
assert!(os(&mut e, "000AAAAAS", "SHUN ADD *@noisy.host quiet down").iter().any(|a| matches!(a, NetAction::AddLine { kind, mask, .. } if kind == "SHUN" && mask == "*@noisy.host")), "shun added");
|
||
// CBAN drives a channel-name ban; an all-wildcard channel mask is refused.
|
||
assert!(os(&mut e, "000AAAAAS", "CBAN ADD #warez* piracy").iter().any(|a| matches!(a, NetAction::AddLine { kind, mask, .. } if kind == "CBAN" && mask == "#warez*")), "cban added");
|
||
assert!(os(&mut e, "000AAAAAS", "CBAN ADD #* everything").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("too wide"))), "wildcard channel refused");
|
||
|
||
// GLOBAL fans out to every user via the $* server glob.
|
||
let out = os(&mut e, "000AAAAAS", "GLOBAL rebooting in 5");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { to, text, .. } if to == "$*" && text.contains("rebooting in 5"))), "global broadcast: {out:?}");
|
||
|
||
// KILL disconnects the named user, attributed to the oper.
|
||
let out = os(&mut e, "000AAAAAS", "KILL spammer flooding");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::KillUser { uid, reason, .. } if uid == "000AAAAAV" && reason.contains("flooding") && reason.contains("staff"))), "kill issued: {out:?}");
|
||
|
||
// None of it works for a non-admin: refused, and no network action leaks.
|
||
for cmd in ["SQLINE ADD *x* y", "GLOBAL hi", "KILL staff go"] {
|
||
let out = os(&mut e, "000AAAAAP", cmd);
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "non-admin refused {cmd}");
|
||
assert!(!out.iter().any(|a| matches!(a, NetAction::AddLine { .. } | NetAction::KillUser { .. })), "no ban/kill from non-admin {cmd}");
|
||
assert!(!out.iter().any(|a| matches!(a, NetAction::Notice { to, .. } if to == "$*")), "no broadcast from non-admin {cmd}");
|
||
}
|
||
}
|
||
|
||
// OperServ MODE (forced channel mode override, resolving a status-mode nick
|
||
// to its uid) and KICK (remove a user from a channel), both admin-gated.
|
||
#[test]
|
||
fn operserv_mode_and_kick() {
|
||
use echo_operserv::OperServ;
|
||
let path = std::env::temp_dir().join("echo-osmodekick.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("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(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() });
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAP".into(), nick: "plain".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAP".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAT".into(), nick: "target".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
|
||
// A status mode's nick target is resolved to its uid in the FMODE.
|
||
let out = os(&mut e, "000AAAAAS", "MODE #room +o target");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { from, channel, modes } if from == "42SAAAAAH" && channel == "#room" && modes == "+o 000AAAAAT")), "status mode resolved: {out:?}");
|
||
// A paramless mode passes straight through.
|
||
let out = os(&mut e, "000AAAAAS", "MODE #room +mn");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+mn")), "paramless mode: {out:?}");
|
||
|
||
// KICK removes the user, attributed to the operator.
|
||
let out = os(&mut e, "000AAAAAS", "KICK #room target trolling");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Kick { from, channel, uid, reason } if from == "42SAAAAAH" && channel == "#room" && uid == "000AAAAAT" && reason.contains("trolling") && reason.contains("staff"))), "kick issued: {out:?}");
|
||
|
||
// A non-admin gets nothing but a refusal.
|
||
for cmd in ["MODE #room +o target", "KICK #room target go"] {
|
||
let out = os(&mut e, "000AAAAAP", cmd);
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "non-admin refused {cmd}");
|
||
assert!(!out.iter().any(|a| matches!(a, NetAction::ChannelMode { .. } | NetAction::Kick { .. })), "no action from non-admin {cmd}");
|
||
}
|
||
}
|
||
|
||
// LOGSEARCH finds recorded actions — both a kick (stamped) and an akill (from
|
||
// the command audit trail); gated on auspex.
|
||
#[test]
|
||
fn operserv_logsearch_finds_actions() {
|
||
use echo_operserv::OperServ;
|
||
let path = std::env::temp_dir().join("echo-logsearch.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("mod", "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(echo_api::Priv::Root));
|
||
opers.insert("mod".to_string(), Privs::default().with(echo_api::Priv::Suspend));
|
||
e.set_opers(opers);
|
||
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(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAM".into(), nick: "mod".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAM".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAT".into(), nick: "troll".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
|
||
os(&mut e, "000AAAAAS", "AKILL ADD *@evil.host spamming");
|
||
os(&mut e, "000AAAAAS", "KICK #room troll flooding");
|
||
|
||
// The akill (from the command audit trail) is searchable.
|
||
assert!(has(&os(&mut e, "000AAAAAS", "LOGSEARCH evil.host"), "evil.host"), "akill searchable");
|
||
// The kick (stamped at the choke-point) is searchable.
|
||
assert!(has(&os(&mut e, "000AAAAAS", "LOGSEARCH troll"), "troll"), "kick searchable");
|
||
// A bare LOGSEARCH lists recent entries.
|
||
assert!(has(&os(&mut e, "000AAAAAS", "LOGSEARCH"), "End of results"), "lists recent");
|
||
// A nonsense pattern finds nothing.
|
||
assert!(has(&os(&mut e, "000AAAAAS", "LOGSEARCH zzzznope"), "No matching"), "empty result");
|
||
// An oper without auspex is refused.
|
||
assert!(has(&os(&mut e, "000AAAAAM", "LOGSEARCH troll"), "auspex"), "auspex-gated");
|
||
}
|
||
|
||
// Every user-removal gets a traceable incident id stamped into its reason and
|
||
// recorded in the searchable log.
|
||
#[test]
|
||
fn removals_get_incident_ids() {
|
||
use echo_operserv::OperServ;
|
||
let path = std::env::temp_dir().join("echo-incident.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("staff", "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(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() });
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAT".into(), nick: "troll".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
|
||
// The kick reason carries a bracketed id, and the incident is searchable.
|
||
let out = os(&mut e, "000AAAAAS", "KICK #room troll flooding");
|
||
let reason = out.iter().find_map(|a| match a {
|
||
NetAction::Kick { reason, .. } => Some(reason.clone()),
|
||
_ => None,
|
||
}).expect("a kick was issued");
|
||
assert!(reason.contains("flooding") && reason.contains("[#"), "id stamped into reason: {reason}");
|
||
// The id in the reason and the incident log agree, and search finds it.
|
||
let id = reason.split("[#").nth(1).and_then(|s| s.split(']').next()).unwrap().to_string();
|
||
let hits = e.network.search_incidents(&id, 10);
|
||
assert_eq!(hits.len(), 1, "searchable by id");
|
||
assert!(hits[0].summary.contains("troll"), "summary names the target: {}", hits[0].summary);
|
||
assert!(e.network.search_incidents("kicked", 10).iter().any(|i| i.summary.contains("troll")), "searchable by keyword");
|
||
}
|
||
|
||
// OperServ CHANKILL: AKILL every host in a channel at once, deduped, never
|
||
// banning the operator who ran it.
|
||
#[test]
|
||
fn operserv_chankill_clears_a_channel() {
|
||
use echo_operserv::OperServ;
|
||
let path = std::env::temp_dir().join("echo-oschankill.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("staff", "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(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() });
|
||
let conn = |e: &mut Engine, uid: &str, host: &str| e.handle(NetEvent::UserConnect { uid: uid.into(), nick: uid.into(), host: host.into(), ip: "0.0.0.0".into() });
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "staffhost".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
conn(&mut e, "000AAAAA1", "bad1");
|
||
conn(&mut e, "000AAAAA2", "bad2");
|
||
conn(&mut e, "000AAAAA3", "bad1"); // same host as A1 → deduped
|
||
for u in ["000AAAAAS", "000AAAAA1", "000AAAAA2", "000AAAAA3"] {
|
||
e.handle(NetEvent::Join { uid: u.into(), channel: "#spam".into(), op: false });
|
||
}
|
||
|
||
let out = os(&mut e, "000AAAAAS", "CHANKILL #spam flooding");
|
||
// Both distinct spammer hosts are G-lined, once each.
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::AddLine { mask, .. } if mask == "*@bad1")), "bad1 banned: {out:?}");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::AddLine { mask, .. } if mask == "*@bad2")), "bad2 banned");
|
||
assert_eq!(out.iter().filter(|a| matches!(a, NetAction::AddLine { mask, .. } if mask == "*@bad1")).count(), 1, "deduped");
|
||
// The operator's own host is spared.
|
||
assert!(!out.iter().any(|a| matches!(a, NetAction::AddLine { mask, .. } if mask == "*@staffhost")), "operator not self-banned");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("2") && text.contains("AKILL"))), "reports 2 hosts");
|
||
}
|
||
|
||
// OperServ JUPE: hold a server name with a fake server, re-assert it at burst,
|
||
// and lift it with a squit. Admin-only.
|
||
#[test]
|
||
fn operserv_jupe_holds_and_lifts() {
|
||
use echo_operserv::OperServ;
|
||
let path = std::env::temp_dir().join("echo-osjupe.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("staff", "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(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() });
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into(), ip: "9.9.9.9".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
|
||
// Juping introduces a fake server holding the name.
|
||
let out = os(&mut e, "000AAAAAS", "JUPE rogue.example evil twin");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::JupeServer { name, .. } if name == "rogue.example")), "jupe introduced: {out:?}");
|
||
// A bare name (no dot) isn't a server — syntax refused.
|
||
assert!(os(&mut e, "000AAAAAS", "JUPE notaserver").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Syntax"))), "needs a server name");
|
||
|
||
// It's re-introduced at burst.
|
||
assert!(e.startup_actions().iter().any(|a| matches!(a, NetAction::JupeServer { name, .. } if name == "rogue.example")), "re-asserted at burst");
|
||
// LIST shows it.
|
||
assert!(os(&mut e, "000AAAAAS", "JUPE LIST").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("rogue.example"))), "listed");
|
||
|
||
// DEL squits the fake server.
|
||
assert!(os(&mut e, "000AAAAAS", "JUPE DEL rogue.example").iter().any(|a| matches!(a, NetAction::Squit { .. })), "squit on lift");
|
||
assert!(!e.startup_actions().iter().any(|a| matches!(a, NetAction::JupeServer { .. })), "gone after DEL");
|
||
}
|
||
|
||
// External-account mode: IRC can log in against pushed-in accounts but can't
|
||
// register or change identity; channels (echo's own domain) still work.
|
||
#[test]
|
||
fn external_accounts_delegate_identity() {
|
||
use echo_chanserv::ChanServ;
|
||
let path = std::env::temp_dir().join("echo-external.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
// Simulates an account pushed in by the external authority (the website).
|
||
db.register("alice", "password1", None).unwrap();
|
||
db.set_external_accounts(true);
|
||
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,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
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)));
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
|
||
// Login against the pushed-in account still works.
|
||
let out = ns(&mut e, "000AAAAAB", "IDENTIFY password1");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value == "alice")), "identify works: {out:?}");
|
||
|
||
// But creating or changing identity from IRC is refused.
|
||
assert!(has(&ns(&mut e, "000AAAAAB", "REGISTER newpass a@b.c"), "website"), "register refused");
|
||
assert!(has(&ns(&mut e, "000AAAAAB", "SET PASSWORD hunter2"), "website"), "set password refused");
|
||
assert!(has(&ns(&mut e, "000AAAAAB", "DROP password1"), "website"), "drop refused");
|
||
// The IRCv3 registration relay is refused too.
|
||
let reply = crate::proto::RegReply::NickServ { agent: "42SAAAAAA".into(), uid: "000AAAAAB".into(), nick: "bob".into() };
|
||
assert!(e.pre_register_check("bob", &reply).is_some_and(|r| r.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("website")))), "relay refused");
|
||
|
||
// Channels are echo's own domain — still registerable.
|
||
e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#room".into(), op: true });
|
||
assert!(has(&cs(&mut e, "000AAAAAB", "REGISTER #room"), "now registered"), "channels still work");
|
||
}
|
||
|
||
// OperServ DEFCON: each level tightens the network — announce on change, freeze
|
||
// channel then all registrations, cap sessions, and lock out new connections.
|
||
#[test]
|
||
fn operserv_defcon_levels() {
|
||
use echo_chanserv::ChanServ;
|
||
use echo_operserv::OperServ;
|
||
let path = std::env::temp_dir().join("echo-defcon.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("staff", "password1", None).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() }),
|
||
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(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() });
|
||
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)));
|
||
let killed = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::KillUser { .. }));
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into(), ip: "1.1.1.1".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
e.handle(NetEvent::Join { uid: "000AAAAAS".into(), channel: "#room".into(), op: true });
|
||
|
||
// Setting a level announces it network-wide.
|
||
let out = os(&mut e, "000AAAAAS", "DEFCON 4");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { to, text, .. } if to == "$*" && text.contains("DEFCON 4"))), "announced: {out:?}");
|
||
// DEFCON 4 freezes channel registrations but not account ones.
|
||
assert!(has(&cs(&mut e, "000AAAAAS", "REGISTER #room"), "frozen"), "chan reg frozen at 4");
|
||
let reply = crate::proto::RegReply::NickServ { agent: "42SAAAAAA".into(), uid: "000AAAAAX".into(), nick: "newbie".into() };
|
||
assert!(e.pre_register_check("newbie", &reply).is_none(), "account reg still ok at 4");
|
||
|
||
// DEFCON 3 freezes account registrations too.
|
||
os(&mut e, "000AAAAAS", "DEFCON 3");
|
||
assert!(e.pre_register_check("newbie", &reply).is_some_and(|r| r.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("frozen")))), "account reg frozen at 3");
|
||
|
||
// DEFCON 2 caps everyone to one session per host.
|
||
os(&mut e, "000AAAAAS", "DEFCON 2");
|
||
assert!(!killed(&e.handle(NetEvent::UserConnect { uid: "000AAAAA1".into(), nick: "a".into(), host: "h".into(), ip: "2.2.2.2".into() })), "first from an IP ok");
|
||
assert!(killed(&e.handle(NetEvent::UserConnect { uid: "000AAAAA2".into(), nick: "b".into(), host: "h".into(), ip: "2.2.2.2".into() })), "second from the same IP capped");
|
||
|
||
// DEFCON 1 turns away every new connection.
|
||
os(&mut e, "000AAAAAS", "DEFCON 1");
|
||
assert!(killed(&e.handle(NetEvent::UserConnect { uid: "000AAAAA3".into(), nick: "c".into(), host: "h".into(), ip: "3.3.3.3".into() })), "lockdown rejects new connections");
|
||
|
||
// Back to 5: normal again.
|
||
os(&mut e, "000AAAAAS", "DEFCON 5");
|
||
assert!(!killed(&e.handle(NetEvent::UserConnect { uid: "000AAAAA4".into(), nick: "d".into(), host: "h".into(), ip: "4.4.4.4".into() })), "connections fine at 5");
|
||
assert!(has(&cs(&mut e, "000AAAAAS", "REGISTER #room"), "now registered"), "chan reg works at 5");
|
||
}
|
||
|
||
// OperServ session limiting: the connection that puts an IP over its limit is
|
||
// killed; SESSION inspects counts; EXCEPTION raises the allowance per IP-mask.
|
||
#[test]
|
||
fn operserv_session_limit_and_exceptions() {
|
||
use echo_operserv::OperServ;
|
||
let path = std::env::temp_dir().join("echo-ossession.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("staff", "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());
|
||
e.set_session_limit(Some(2));
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("staff".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() });
|
||
let conn = |e: &mut Engine, uid: &str, ip: &str| e.handle(NetEvent::UserConnect { uid: uid.into(), nick: uid.into(), host: "h".into(), ip: ip.into() });
|
||
let killed = |out: &[NetAction], who: &str| out.iter().any(|a| matches!(a, NetAction::KillUser { uid, .. } if uid == who));
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into(), ip: "9.9.9.9".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
|
||
// Two sessions from one IP are fine; the third is killed.
|
||
assert!(!killed(&conn(&mut e, "000AAAAA1", "5.5.5.5"), "000AAAAA1"), "1st ok");
|
||
assert!(!killed(&conn(&mut e, "000AAAAA2", "5.5.5.5"), "000AAAAA2"), "2nd ok");
|
||
assert!(killed(&conn(&mut e, "000AAAAA3", "5.5.5.5"), "000AAAAA3"), "3rd over the limit is killed");
|
||
// A different IP is unaffected.
|
||
assert!(!killed(&conn(&mut e, "000AAAAA4", "6.6.6.6"), "000AAAAA4"), "other IP fine");
|
||
|
||
// SESSION VIEW reports the live count.
|
||
assert!(os(&mut e, "000AAAAAS", "SESSION VIEW 5.5.5.5").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("3"))), "view count");
|
||
|
||
// An unlimited exception lets more sessions through from that IP.
|
||
os(&mut e, "000AAAAAS", "EXCEPTION ADD 5.5.5.5 0 nat gateway");
|
||
assert!(!killed(&conn(&mut e, "000AAAAA5", "5.5.5.5"), "000AAAAA5"), "exception lifts the limit");
|
||
assert!(os(&mut e, "000AAAAAS", "EXCEPTION LIST").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("5.5.5.5") && text.contains("unlimited"))), "listed");
|
||
|
||
// A quit frees a slot.
|
||
e.handle(NetEvent::Quit { uid: "000AAAAA4".into(), reason: String::new() });
|
||
assert!(os(&mut e, "000AAAAAS", "SESSION VIEW 6.6.6.6").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("0"))), "slot freed on quit");
|
||
}
|
||
|
||
// OperServ OPER: a runtime grant actually confers privileges (merged with the
|
||
// config opers), and revoking removes them. Admin-only.
|
||
#[test]
|
||
fn operserv_oper_grants_runtime_privileges() {
|
||
use echo_operserv::OperServ;
|
||
let path = std::env::temp_dir().join("echo-osoper.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("alice", "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(echo_api::Priv::Root));
|
||
e.set_opers(opers);
|
||
let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() });
|
||
let denied = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied")));
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAL".into(), nick: "alice".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAL".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
|
||
// Before the grant, alice is a plain user — OperServ shuts her out.
|
||
assert!(denied(&os(&mut e, "000AAAAAL", "STATS")), "alice not an oper yet");
|
||
|
||
// Grant alice admin at runtime; now the same command works.
|
||
os(&mut e, "000AAAAAS", "OPER ADD alice admin");
|
||
assert!(!denied(&os(&mut e, "000AAAAAL", "STATS")), "alice is an oper after the grant");
|
||
assert!(os(&mut e, "000AAAAAS", "OPER LIST").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("alice"))), "listed");
|
||
|
||
// Revoke; alice loses access again.
|
||
os(&mut e, "000AAAAAS", "OPER DEL alice");
|
||
assert!(denied(&os(&mut e, "000AAAAAL", "STATS")), "alice lost access after revoke");
|
||
|
||
// A config oper is not a runtime oper, so it can't be DEL'd here.
|
||
assert!(os(&mut e, "000AAAAAS", "OPER DEL staff").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("isn't a runtime operator"))), "config oper untouched");
|
||
}
|
||
|
||
// A temporary OPER grant confers privileges until it lazily expires.
|
||
#[test]
|
||
fn operserv_oper_grant_can_expire() {
|
||
use echo_operserv::OperServ;
|
||
let path = std::env::temp_dir().join("echo-opertmp.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("temp", "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(echo_api::Priv::Root));
|
||
e.set_opers(opers);
|
||
let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() });
|
||
let denied = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied")));
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAP".into(), nick: "temp".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAP".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
|
||
// A one-hour admin grant is active now...
|
||
os(&mut e, "000AAAAAS", "OPER ADD temp admin +1h");
|
||
assert!(!denied(&os(&mut e, "000AAAAAP", "STATS")), "active while granted");
|
||
assert!(os(&mut e, "000AAAAAS", "OPER LIST").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("temp") && text.contains("temporary"))), "listed as temporary");
|
||
|
||
// ...but not after its window passes.
|
||
e.now_override = Some(10_000_000_000);
|
||
assert!(denied(&os(&mut e, "000AAAAAP", "STATS")), "expired grant confers nothing");
|
||
}
|
||
|
||
// HelpServ: a user opens a ticket; it queues, flows into the audit trail
|
||
// (LOGSEARCH), and operators TAKE / CLOSE it. Reviewing is oper-only.
|
||
#[test]
|
||
fn helpserv_ticket_flow() {
|
||
use echo_helpserv::HelpServ;
|
||
use echo_operserv::OperServ;
|
||
let path = std::env::temp_dir().join("echo-helpserv.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("staff", "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() }),
|
||
Box::new(HelpServ { uid: "42SAAAAAN".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("staff".to_string(), Privs::default().with(echo_api::Priv::Admin).with(echo_api::Priv::Auspex));
|
||
e.set_opers(opers);
|
||
let hs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAN".into(), text: t.into() });
|
||
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], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAU".into(), nick: "newbie".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
|
||
// A user asks for help; it queues and is searchable via the audit trail.
|
||
assert!(has(&hs(&mut e, "000AAAAAU", "REQUEST how do I register a channel"), "in the queue"), "request accepted");
|
||
assert!(has(&os(&mut e, "000AAAAAS", "LOGSEARCH register a channel"), "register a channel"), "searchable via LOGSEARCH");
|
||
|
||
// Operator works the queue: NEXT claims the oldest, then CLOSE.
|
||
assert!(has(&hs(&mut e, "000AAAAAS", "LIST"), "newbie"), "oper sees the queue");
|
||
assert!(has(&hs(&mut e, "000AAAAAS", "NEXT"), "You took ticket \x02#0\x02"), "next claims oldest");
|
||
assert!(has(&hs(&mut e, "000AAAAAS", "VIEW 0"), "register a channel"), "view detail");
|
||
assert!(has(&hs(&mut e, "000AAAAAS", "CLOSE 0"), "closed"), "close");
|
||
assert!(!has(&hs(&mut e, "000AAAAAS", "LIST"), "newbie"), "closed drops off the open list");
|
||
|
||
// Non-opers can't work the queue; and filing is rate-limited.
|
||
assert!(has(&hs(&mut e, "000AAAAAU", "LIST"), "Access denied"), "queue is oper-only");
|
||
assert!(has(&hs(&mut e, "000AAAAAU", "REQUEST again please"), "wait a moment"), "rate-limited");
|
||
}
|
||
|
||
// ReportServ: any user files an abuse report; it lands in the queue AND flows
|
||
// through the audit trail so OperServ LOGSEARCH finds it. Reviewing is oper-only.
|
||
#[test]
|
||
fn reportserv_files_and_is_searchable() {
|
||
use echo_operserv::OperServ;
|
||
use echo_reportserv::ReportServ;
|
||
let path = std::env::temp_dir().join("echo-reportserv.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("staff", "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() }),
|
||
Box::new(ReportServ { uid: "42SAAAAAK".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("staff".to_string(), Privs::default().with(echo_api::Priv::Admin).with(echo_api::Priv::Auspex));
|
||
e.set_opers(opers);
|
||
let rs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAK".into(), text: t.into() });
|
||
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], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAU".into(), nick: "victim".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
|
||
// An ordinary (unidentified) user files a report.
|
||
assert!(has(&rs(&mut e, "000AAAAAU", "REPORT spammer flooding the lobby"), "has been sent"), "report accepted");
|
||
// The report is in the queue and — via the audit trail — searchable.
|
||
assert!(has(&rs(&mut e, "000AAAAAS", "LIST"), "spammer"), "oper sees the queue");
|
||
assert!(has(&os(&mut e, "000AAAAAS", "LOGSEARCH flooding"), "flooding"), "report searchable via LOGSEARCH");
|
||
|
||
// Detail, then close.
|
||
assert!(has(&rs(&mut e, "000AAAAAS", "VIEW 0"), "flooding the lobby"), "view detail");
|
||
assert!(has(&rs(&mut e, "000AAAAAS", "CLOSE 0"), "closed"), "close");
|
||
assert!(!has(&rs(&mut e, "000AAAAAS", "LIST"), "spammer"), "closed reports drop off the open list");
|
||
|
||
// Non-opers can't review; and filing is rate-limited.
|
||
assert!(has(&rs(&mut e, "000AAAAAU", "LIST"), "Access denied"), "review is oper-only");
|
||
assert!(has(&rs(&mut e, "000AAAAAU", "REPORT someone again"), "wait a moment"), "rate-limited");
|
||
}
|
||
|
||
// InfoServ bulletins over the shared news store: public bulletins greet
|
||
// everyone on connect, oper bulletins greet an operator on login.
|
||
#[test]
|
||
fn infoserv_bulletins_public_and_oper() {
|
||
use echo_infoserv::InfoServ;
|
||
let path = std::env::temp_dir().join("echo-infoserv.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(InfoServ { uid: "42SAAAAAJ".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("staff".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||
e.set_irc_out(tx);
|
||
let is = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAJ".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() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
|
||
is(&mut e, "000AAAAAS", "POST Welcome to the network");
|
||
is(&mut e, "000AAAAAS", "OPOST Staff meeting at 5");
|
||
|
||
// A new user is greeted with the public bulletin on connect, not the oper one.
|
||
let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAP".into(), nick: "plain".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { to, text, .. } if to == "000AAAAAP" && text.contains("Welcome to the network"))), "public bulletin on connect: {out:?}");
|
||
assert!(!has(&out, "Staff meeting"), "a plain user doesn't get oper bulletins");
|
||
|
||
// An operator logging in is shown the oper bulletin (over the outbound path).
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
while rx.try_recv().is_ok() {} // drain the connect's public bulletin
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
let mut oper_bulletin = 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_bulletin = true;
|
||
}
|
||
}
|
||
}
|
||
assert!(oper_bulletin, "oper bulletin shown to an operator on login");
|
||
|
||
// LIST shows the public one (to anyone); DEL removes it so a later connect is quiet.
|
||
assert!(has(&is(&mut e, "000AAAAAP", "LIST"), "Welcome to the network"), "anyone can list public bulletins");
|
||
is(&mut e, "000AAAAAS", "DEL 1");
|
||
let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAQ".into(), nick: "late".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
assert!(!has(&out, "Welcome to the network"), "bulletin gone after DEL");
|
||
|
||
// A non-admin can't post.
|
||
assert!(has(&is(&mut e, "000AAAAAP", "POST 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]
|
||
fn operserv_info_staff_notes() {
|
||
use echo_chanserv::ChanServ;
|
||
use echo_operserv::OperServ;
|
||
let path = std::env::temp_dir().join("echo-osinfo.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("alice", "password1", None).unwrap();
|
||
db.register_channel("#room", "staff").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() }),
|
||
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(echo_api::Priv::Admin).with(echo_api::Priv::Auspex));
|
||
e.set_opers(opers);
|
||
let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() });
|
||
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
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], 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() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAL".into(), nick: "alice".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAL".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
|
||
// Admin attaches a note; it shows in NickServ INFO to the oper.
|
||
os(&mut e, "000AAAAAS", "INFO ADD alice known troublemaker");
|
||
assert!(has(&ns(&mut e, "000AAAAAS", "INFO alice"), "known troublemaker"), "note shown to oper in NS INFO");
|
||
assert!(has(&os(&mut e, "000AAAAAS", "INFO alice"), "known troublemaker"), "note shown via OperServ INFO");
|
||
// The account's own owner never sees the staff note.
|
||
assert!(!has(&ns(&mut e, "000AAAAAL", "INFO alice"), "Staff note"), "owner doesn't see the note");
|
||
|
||
// Channel notes show in ChanServ INFO to the oper.
|
||
os(&mut e, "000AAAAAS", "INFO ADD #room watch for drama");
|
||
assert!(has(&cs(&mut e, "000AAAAAS", "INFO #room"), "watch for drama"), "channel note in CS INFO");
|
||
|
||
// DEL clears it.
|
||
os(&mut e, "000AAAAAS", "INFO DEL alice");
|
||
assert!(!has(&ns(&mut e, "000AAAAAS", "INFO alice"), "Staff note"), "note cleared");
|
||
|
||
// A non-admin can't set notes.
|
||
assert!(has(&os(&mut e, "000AAAAAL", "INFO ADD staff x"), "Access denied"), "non-admin refused");
|
||
}
|
||
|
||
// OperServ SVSNICK / SVSJOIN: force a user's nick or channel join, admin-gated.
|
||
#[test]
|
||
fn operserv_svs_forces_nick_and_join() {
|
||
use echo_operserv::OperServ;
|
||
let path = std::env::temp_dir().join("echo-ossvs.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("staff", "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(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() });
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAT".into(), nick: "target".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
|
||
// SVSNICK forces the rename.
|
||
let out = os(&mut e, "000AAAAAS", "SVSNICK target Renamed");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ForceNick { uid, nick } if uid == "000AAAAAT" && nick == "Renamed")), "svsnick issued: {out:?}");
|
||
// The ircd echoes the rename; once tracked, SVSJOIN resolves the new nick.
|
||
e.handle(NetEvent::NickChange { uid: "000AAAAAT".into(), nick: "Renamed".into() });
|
||
let out = os(&mut e, "000AAAAAS", "SVSJOIN Renamed #room");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ForceJoin { uid, channel, .. } if uid == "000AAAAAT" && channel == "#room")), "svsjoin issued: {out:?}");
|
||
|
||
// An unknown target is reported, not forced.
|
||
assert!(os(&mut e, "000AAAAAS", "SVSNICK ghost x").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("no \x02ghost\x02"))), "unknown target");
|
||
}
|
||
|
||
// OperServ IGNORE: an admin silences a user, whose service commands are then
|
||
// dropped; DEL restores them; opers are never silenced.
|
||
#[test]
|
||
fn operserv_ignore_silences_and_restores() {
|
||
use echo_operserv::OperServ;
|
||
let path = std::env::temp_dir().join("echo-osignore.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("staff", "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(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() });
|
||
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAM".into(), nick: "menace".into(), host: "bad.host".into() , ip: "0.0.0.0".into() });
|
||
|
||
// Before the ignore, a NickServ command gets a reply.
|
||
assert!(!ns(&mut e, "000AAAAAM", "INFO").is_empty(), "responds before ignore");
|
||
|
||
// Ignore by host mask, then the same command yields nothing at all.
|
||
os(&mut e, "000AAAAAS", "IGNORE ADD *!*@bad.host flooding");
|
||
assert!(ns(&mut e, "000AAAAAM", "INFO").is_empty(), "ignored user's command is dropped");
|
||
assert!(os(&mut e, "000AAAAAM", "HELP").is_empty(), "ignored across every service");
|
||
|
||
// The operator is never silenced by an ignore against them.
|
||
os(&mut e, "000AAAAAS", "IGNORE ADD *!*@h staffignore");
|
||
assert!(!ns(&mut e, "000AAAAAS", "INFO").is_empty(), "an oper can't be ignored");
|
||
|
||
// DEL restores the menace.
|
||
os(&mut e, "000AAAAAS", "IGNORE DEL *!*@bad.host");
|
||
assert!(!ns(&mut e, "000AAAAAM", "INFO").is_empty(), "responds again after DEL");
|
||
|
||
// A non-admin (no longer ignored) can't manage the list — it's refused.
|
||
assert!(os(&mut e, "000AAAAAM", "IGNORE LIST").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "non-admin refused");
|
||
}
|
||
|
||
// NOEXPIRE is oper-only.
|
||
#[test]
|
||
fn noexpire_command_is_oper_gated() {
|
||
let mut e = engine_with("noexp", "alice", "sesame");
|
||
e.db.register("mallory", "password1", None).unwrap();
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
// alice is not an oper: refused, and the flag is untouched.
|
||
let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "NOEXPIRE mallory ON".into() });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "non-oper refused: {out:?}");
|
||
}
|
||
|
||
// ChanFix: op-time is scored from live state, and CHANFIX reops the trusted
|
||
// regulars of an opless, unregistered channel; it defers to ChanServ.
|
||
#[test]
|
||
fn chanfix_scores_and_fixes_opless_channels() {
|
||
use echo_chanfix::ChanFix;
|
||
let path = std::env::temp_dir().join("echo-chanfix.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
for a in ["alice", "bob", "staff"] {
|
||
db.register(a, "password1", None).unwrap();
|
||
}
|
||
db.register_channel("#owned", "staff").unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(ChanFix { uid: "42SAAAAAM".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("staff".to_string(), Privs::default().with(echo_api::Priv::Auspex));
|
||
e.set_opers(opers);
|
||
let cf = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAM".into(), text: t.into() });
|
||
let has = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
let opped = |out: &[NetAction], who: &str| out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == &format!("+o {who}")));
|
||
|
||
for (uid, nick) in [("000AAAAAA", "alice"), ("000AAAAAB", "bob"), ("000AAAAAS", "staff")] {
|
||
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() });
|
||
}
|
||
// alice and bob hold ops in an unregistered #lobby; score their op-time.
|
||
e.handle(NetEvent::Join { uid: "000AAAAAA".into(), channel: "#lobby".into(), op: true });
|
||
e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#lobby".into(), op: true });
|
||
for _ in 0..15 {
|
||
e.chanfix_tick();
|
||
}
|
||
|
||
// SCORES shows the accumulated standings.
|
||
assert!(has(&cf(&mut e, "000AAAAAS", "SCORES #lobby"), "alice"), "scores recorded");
|
||
// A non-oper can't use ChanFix at all.
|
||
assert!(has(&cf(&mut e, "000AAAAAA", "SCORES #lobby"), "Access denied"), "oper-only");
|
||
|
||
// The channel goes opless; CHANFIX reops the trusted regulars still present.
|
||
e.handle(NetEvent::ChannelOp { channel: "#lobby".into(), uid: "000AAAAAA".into(), op: false });
|
||
e.handle(NetEvent::ChannelOp { channel: "#lobby".into(), uid: "000AAAAAB".into(), op: false });
|
||
let out = cf(&mut e, "000AAAAAS", "CHANFIX #lobby");
|
||
assert!(opped(&out, "000AAAAAA") && opped(&out, "000AAAAAB"), "reopped the regulars: {out:?}");
|
||
|
||
// It won't touch a ChanServ-registered channel.
|
||
assert!(has(&cf(&mut e, "000AAAAAS", "CHANFIX #owned"), "registered"), "defers to ChanServ");
|
||
}
|
||
|
||
// GroupServ interconnection: a channel grants access to a !group, and every
|
||
// group member holding the c (channel-access) flag inherits it (auto-op on join).
|
||
#[test]
|
||
fn groupserv_grants_channel_access() {
|
||
use echo_chanserv::ChanServ;
|
||
use echo_groupserv::GroupServ;
|
||
let path = std::env::temp_dir().join("echo-groupserv.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() }),
|
||
Box::new(GroupServ { uid: "42SAAAAAL".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 gs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAL".into(), text: t.into() });
|
||
let has = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||
let opped = |out: &[NetAction], who: &str| out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == &format!("+o {who}")));
|
||
|
||
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() });
|
||
}
|
||
|
||
// alice makes a group, adds bob, and grants the group op access on #room.
|
||
assert!(has(&gs(&mut e, "000AAAAAA", "REGISTER !team"), "registered"), "group registered");
|
||
assert!(has(&gs(&mut e, "000AAAAAA", "ADD !team bob"), "Added"), "bob added");
|
||
assert!(has(&cs(&mut e, "000AAAAAA", "FLAGS #room !team +o"), "now holds"), "group granted channel op");
|
||
|
||
// A member without the c (channel-access) flag does NOT inherit; +c makes
|
||
// bob auto-opped on join. carol (not in the group) never is.
|
||
assert!(!opped(&e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#room".into(), op: false }), "000AAAAAB"), "no c flag, no access");
|
||
gs(&mut e, "000AAAAAA", "FLAGS !team bob +c");
|
||
assert!(opped(&e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#room".into(), op: false }), "000AAAAAB"), "c flag -> auto-opped");
|
||
assert!(!opped(&e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#room".into(), op: false }), "000AAAAAC"), "non-member not opped");
|
||
|
||
// Removing bob from the group revokes his inherited channel access.
|
||
gs(&mut e, "000AAAAAA", "DEL !team bob");
|
||
assert_eq!(e.db.channel_join_mode("#room", "bob"), None, "access gone once out of the group");
|
||
|
||
// Managing the group needs the founder or the f flag.
|
||
assert!(has(&gs(&mut e, "000AAAAAC", "ADD !team carol"), "founder or the \x02f\x02 flag"), "outsider can't manage");
|
||
gs(&mut e, "000AAAAAA", "FLAGS !team carol +f");
|
||
assert!(has(&gs(&mut e, "000AAAAAC", "ADD !team bob"), "Added"), "carol with f can now manage");
|
||
}
|
||
|
||
// 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 echo_chanserv::ChanServ;
|
||
let path = std::env::temp_dir().join("echo-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");
|
||
// ...but a non-founder delegate may NOT grant the founder flag (escalation).
|
||
assert!(has(&cs(&mut e, "000AAAAAC", "FLAGS #room bob +f"), "Only the founder"), "carol can't grant +f");
|
||
assert!(!e.db.channel("#room").unwrap().access.iter().any(|a| a.account.eq_ignore_ascii_case("bob") && a.level.contains('f')), "bob did not get founder");
|
||
// The founder still can.
|
||
assert!(has(&cs(&mut e, "000AAAAAA", "FLAGS #room bob +f"), "now holds"), "founder can grant +f");
|
||
|
||
// 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.
|
||
#[test]
|
||
fn chanserv_moderation() {
|
||
use echo_chanserv::ChanServ;
|
||
let path = std::env::temp_dir().join("echo-cs-mod.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
db.register_channel("#m", "alice").unwrap();
|
||
let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 };
|
||
let cs = ChanServ { uid: "42SAAAAAB".into() };
|
||
let mut e = Engine::new(vec![Box::new(ns), Box::new(cs)], db);
|
||
|
||
let to_cs = |e: &mut Engine, from: &str, text: &str| {
|
||
e.handle(NetEvent::Privmsg { from: from.into(), to: "42SAAAAAB".into(), text: text.into() })
|
||
};
|
||
// Founder alice identifies; target bob connects with a known host.
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h1".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "1.2.3.4".into() , ip: "0.0.0.0".into() });
|
||
|
||
// OP a user by nick.
|
||
let out = to_cs(&mut e, "000AAAAAB", "OP #m bob");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#m" && modes == "+o 000AAAAAC")), "{out:?}");
|
||
|
||
// KICK a user by nick, with a reason.
|
||
let out = to_cs(&mut e, "000AAAAAB", "KICK #m bob rude");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Kick { channel, uid, reason, .. } if channel == "#m" && uid == "000AAAAAC" && reason.starts_with("rude"))), "{out:?}");
|
||
|
||
// BAN sets +b *!*@host and kicks.
|
||
let out = to_cs(&mut e, "000AAAAAB", "BAN #m bob spam");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#m" && modes == "+b *!*@1.2.3.4")), "{out:?}");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Kick { uid, .. } if uid == "000AAAAAC")), "{out:?}");
|
||
|
||
// UNBAN clears it.
|
||
let out = to_cs(&mut e, "000AAAAAB", "UNBAN #m bob");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#m" && modes == "-b *!*@1.2.3.4")), "{out:?}");
|
||
|
||
// A user with no access is refused.
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAD".into(), nick: "carol".into(), host: "h2".into() , ip: "0.0.0.0".into() });
|
||
assert!(to_cs(&mut e, "000AAAAAD", "KICK #m alice").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("operator access"))));
|
||
}
|
||
|
||
// ChanServ topic, invite, auto-kick (with enforcement on join), list and status.
|
||
#[test]
|
||
fn chanserv_topic_invite_akick() {
|
||
use echo_chanserv::ChanServ;
|
||
let path = std::env::temp_dir().join("echo-cs-tia.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
db.register_channel("#c", "alice").unwrap();
|
||
let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 };
|
||
let cs = ChanServ { uid: "42SAAAAAB".into() };
|
||
let mut e = Engine::new(vec![Box::new(ns), Box::new(cs)], db);
|
||
let to_cs = |e: &mut Engine, from: &str, text: &str| {
|
||
e.handle(NetEvent::Privmsg { from: from.into(), to: "42SAAAAAB".into(), text: text.into() })
|
||
};
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h1".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "bad.host".into() , ip: "0.0.0.0".into() });
|
||
|
||
// TOPIC and INVITE are sourced from ChanServ.
|
||
let out = to_cs(&mut e, "000AAAAAB", "TOPIC #c hello there");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Topic { channel, topic, .. } if channel == "#c" && topic == "hello there")), "{out:?}");
|
||
let out = to_cs(&mut e, "000AAAAAB", "INVITE #c bob");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Invite { uid, channel, .. } if uid == "000AAAAAC" && channel == "#c")), "{out:?}");
|
||
|
||
// LIST and STATUS.
|
||
assert!(to_cs(&mut e, "000AAAAAB", "LIST").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("#c"))));
|
||
assert!(to_cs(&mut e, "000AAAAAB", "STATUS #c").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("founder"))));
|
||
|
||
// AKICK a mask, then a matching join is banned and kicked.
|
||
assert!(to_cs(&mut e, "000AAAAAB", "AKICK #c ADD *!*@bad.host begone").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Added"))));
|
||
let out = e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#c".into(), op: false });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#c" && modes == "+b *!*@bad.host")), "{out:?}");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Kick { channel, uid, reason, .. } if channel == "#c" && uid == "000AAAAAC" && reason.starts_with("begone"))), "{out:?}");
|
||
|
||
// The founder joining is never auto-kicked (they get +qo instead).
|
||
let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes.contains('o'))), "{out:?}");
|
||
}
|
||
|
||
// A user who joins clean then switches to a nick matching the akick list is
|
||
// banned and kicked, just as if they'd joined under that nick.
|
||
#[test]
|
||
fn akick_enforced_on_nick_change() {
|
||
use echo_chanserv::ChanServ;
|
||
let path = std::env::temp_dir().join("echo-cs-akick-nick.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
db.register_channel("#c", "alice").unwrap();
|
||
let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 };
|
||
let cs = ChanServ { uid: "42SAAAAAB".into() };
|
||
let mut e = Engine::new(vec![Box::new(ns), Box::new(cs)], db);
|
||
let to_cs = |e: &mut Engine, from: &str, text: &str| {
|
||
e.handle(NetEvent::Privmsg { from: from.into(), to: "42SAAAAAB".into(), text: text.into() })
|
||
};
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h1".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
to_cs(&mut e, "000AAAAAB", "AKICK #c ADD evil!*@* begone");
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "clean.host".into(), ip: "0.0.0.0".into() });
|
||
let out = e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#c".into(), op: false });
|
||
assert!(!out.iter().any(|a| matches!(a, NetAction::Kick { .. })), "clean nick not kicked: {out:?}");
|
||
|
||
let out = e.handle(NetEvent::NickChange { uid: "000AAAAAC".into(), nick: "evil".into() });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#c" && modes == "+b evil!*@*")), "{out:?}");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Kick { channel, uid, reason, .. } if channel == "#c" && uid == "000AAAAAC" && reason.starts_with("begone"))), "{out:?}");
|
||
}
|
||
|
||
// ChanServ SET: description and founder transfer, founder-gated.
|
||
#[test]
|
||
fn chanserv_set() {
|
||
use echo_chanserv::ChanServ;
|
||
let path = std::env::temp_dir().join("echo-cs-set.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
db.register("bob", "hunter2", None).unwrap();
|
||
db.register_channel("#c", "alice").unwrap();
|
||
let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 };
|
||
let cs = ChanServ { uid: "42SAAAAAB".into() };
|
||
let mut e = Engine::new(vec![Box::new(ns), Box::new(cs)], db);
|
||
let to_cs = |e: &mut Engine, from: &str, text: &str| {
|
||
e.handle(NetEvent::Privmsg { from: from.into(), to: "42SAAAAAB".into(), text: text.into() })
|
||
};
|
||
let notice = |out: &[NetAction], needle: &str| {
|
||
out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle)))
|
||
};
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h1".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
|
||
// Description is stored and shows in INFO.
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "SET #c DESC a friendly place"), "updated"));
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "INFO #c"), "a friendly place"));
|
||
|
||
// URL is stored, shows in INFO, and clears when set empty.
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "SET #c URL https://example.org/rules"), "updated"));
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "INFO #c"), "https://example.org/rules"));
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "SET #c URL"), "cleared"));
|
||
assert!(!notice(&to_cs(&mut e, "000AAAAAB", "INFO #c"), "example.org"), "cleared URL no longer shows");
|
||
|
||
// Contact email is stored, shows in INFO, and clears when set empty.
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "SET #c EMAIL staff@example.org"), "updated"));
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "INFO #c"), "staff@example.org"));
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "SET #c EMAIL"), "cleared"));
|
||
assert!(!notice(&to_cs(&mut e, "000AAAAAB", "INFO #c"), "staff@example.org"), "cleared email no longer shows");
|
||
|
||
// Transfer to a non-account is refused.
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "SET #c FOUNDER nobody"), "isn't a registered account"));
|
||
|
||
// Transfer to bob works; alice is then no longer the founder.
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "SET #c FOUNDER bob"), "transferred"));
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "SET #c DESC nope"), "founder can change"));
|
||
}
|
||
|
||
// ChanServ entrymsg, enforce, getkey, seen, clone and the xop lists.
|
||
#[test]
|
||
fn chanserv_extended() {
|
||
use echo_chanserv::ChanServ;
|
||
let path = std::env::temp_dir().join("echo-cs-ext.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
db.register("carol", "pw", None).unwrap();
|
||
db.register_channel("#c", "alice").unwrap();
|
||
let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 };
|
||
let cs = ChanServ { uid: "42SAAAAAB".into() };
|
||
let mut e = Engine::new(vec![Box::new(ns), Box::new(cs)], db);
|
||
let to_cs = |e: &mut Engine, from: &str, text: &str| {
|
||
e.handle(NetEvent::Privmsg { from: from.into(), to: "42SAAAAAB".into(), text: text.into() })
|
||
};
|
||
let notice = |out: &[NetAction], needle: &str| {
|
||
out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle)))
|
||
};
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h1".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
|
||
// ENTRYMSG: set it, then a joining user is noticed it.
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "ENTRYMSG #c welcome aboard"), "updated"));
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "guest".into(), host: "h2".into() , ip: "0.0.0.0".into() });
|
||
let out = e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#c".into(), op: false });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { to, text, .. } if to == "000AAAAAC" && text == "welcome aboard")), "{out:?}");
|
||
|
||
// XOP: AOP add shows in the AOP list and grants op access.
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "AOP #c ADD carol"), "Added"));
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "AOP #c LIST"), "carol"));
|
||
|
||
// ENFORCE: alice (founder) present gets +qo, the akick-matching guest is kicked.
|
||
e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false });
|
||
to_cs(&mut e, "000AAAAAB", "AKICK #c ADD *!*@h2 out");
|
||
let out = to_cs(&mut e, "000AAAAAB", "ENFORCE #c");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+qo 000AAAAAB 000AAAAAB")), "{out:?}");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Kick { uid, .. } if uid == "000AAAAAC")), "{out:?}");
|
||
// SYNC is an alias for ENFORCE — same re-apply of access modes.
|
||
let out = to_cs(&mut e, "000AAAAAB", "SYNC #c");
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+qo 000AAAAAB 000AAAAAB")), "sync alias: {out:?}");
|
||
|
||
// GETKEY: reflects a tracked key change.
|
||
e.handle(NetEvent::ChannelKey { channel: "#c".into(), key: Some("s3cret".into()) });
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "GETKEY #c"), "s3cret"));
|
||
|
||
// SEEN: an online user vs one who quit.
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "SEEN alice"), "currently online"));
|
||
e.handle(NetEvent::Quit { uid: "000AAAAAC".into(), reason: String::new() });
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "SEEN guest"), "last seen"));
|
||
|
||
// CLONE: copy #c's settings (incl. the AOP) into a second owned channel.
|
||
e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#d".into(), op: true });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: "REGISTER #d".into() });
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "CLONE #c #d"), "Copied"));
|
||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "AOP #d LIST"), "carol"));
|
||
}
|
||
|
||
// A registered channel (re)appearing re-asserts +r; an unregistered one does not.
|
||
#[test]
|
||
fn channel_create_reasserts_registered_mode() {
|
||
let path = std::env::temp_dir().join("echo-chancreate.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.register_channel("#reg", "alice").unwrap();
|
||
let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 };
|
||
let mut e = Engine::new(vec![Box::new(ns)], db);
|
||
|
||
let out = e.handle(NetEvent::ChannelCreate { channel: "#reg".into() });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#reg" && modes == "+r")), "registered channel regains +r: {out:?}");
|
||
|
||
let out = e.handle(NetEvent::ChannelCreate { channel: "#other".into() });
|
||
assert!(out.is_empty(), "unregistered channel is left alone: {out:?}");
|
||
}
|
||
|
||
// A channel's mode lock is applied on creation and enforced on violation.
|
||
#[test]
|
||
fn mode_lock_is_applied_and_enforced() {
|
||
let path = std::env::temp_dir().join("echo-mlock-engine.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.register_channel("#lk", "alice").unwrap();
|
||
db.set_mlock("#lk", "nt", "s").unwrap();
|
||
let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 };
|
||
let mut e = Engine::new(vec![Box::new(ns)], db);
|
||
|
||
// Creation applies the full lock.
|
||
let out = e.handle(NetEvent::ChannelCreate { channel: "#lk".into() });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#lk" && modes == "+rnt-s")), "{out:?}");
|
||
|
||
// Setting a locked-off mode is reverted.
|
||
let out = e.handle(NetEvent::ChannelModeChange { channel: "#lk".into(), modes: "+s".into(), setter: String::new() });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#lk" && modes == "-s")), "{out:?}");
|
||
|
||
// An unrelated mode change is left alone.
|
||
assert!(e.handle(NetEvent::ChannelModeChange { channel: "#lk".into(), modes: "+m".into(), setter: String::new() }).is_empty());
|
||
}
|
||
|
||
// The founder is opped when they join their channel; others are not.
|
||
#[test]
|
||
fn founder_is_opped_on_join() {
|
||
let path = std::env::temp_dir().join("echo-autoop.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("alice", "sesame", None).unwrap();
|
||
db.register_channel("#x", "alice").unwrap();
|
||
let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 };
|
||
let mut e = Engine::new(vec![Box::new(ns)], db);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "host.example".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
|
||
let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#x".into(), op: false });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#x" && modes == "+qo 000AAAAAB 000AAAAAB")), "founder opped: {out:?}");
|
||
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "host.example".into() , ip: "0.0.0.0".into() });
|
||
assert!(e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#x".into(), op: false }).is_empty(), "non-founder not opped");
|
||
}
|
||
|
||
// An access-list voice gets +v on join.
|
||
#[test]
|
||
fn access_voice_on_join() {
|
||
let path = std::env::temp_dir().join("echo-access-join.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("carol", "sesame", None).unwrap();
|
||
db.register_channel("#y", "boss").unwrap();
|
||
db.access_add("#y", "carol", "voice").unwrap();
|
||
let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 };
|
||
let mut e = Engine::new(vec![Box::new(ns)], db);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "carol".into(), host: "host.example".into() , ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
|
||
let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#y".into(), op: false });
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#y" && modes == "+v 000AAAAAB")), "{out:?}");
|
||
}
|
||
|
||
// HelpServ fronts the whole network's help: it serves any service's per-command
|
||
// help, lists the services, and still answers for its own commands.
|
||
#[test]
|
||
fn helpserv_fronts_the_network_help_index() {
|
||
let path = std::env::temp_dir().join("echo-helpserv-index.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let db = Db::open(&path, "test");
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(echo_helpserv::HelpServ { uid: "42SAAAAAN".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
|
||
let ask = |e: &mut Engine, text: &str| {
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAN".into(), text: text.into() })
|
||
.into_iter()
|
||
.filter_map(|a| match a {
|
||
NetAction::Notice { text, .. } => Some(text),
|
||
_ => None,
|
||
})
|
||
.collect::<Vec<_>>()
|
||
};
|
||
|
||
// HELP <service> <command> fronts that service's per-command help.
|
||
let reg = ask(&mut e, "HELP NickServ REGISTER");
|
||
assert!(reg.iter().any(|l| l.contains("Syntax:") && l.contains("REGISTER")), "{reg:?}");
|
||
|
||
// Bare HELP lists the services on the network.
|
||
let idx = ask(&mut e, "HELP");
|
||
assert!(idx.iter().any(|l| l.contains("Services:") && l.contains("NickServ")), "{idx:?}");
|
||
|
||
// A bare service name works too.
|
||
let ns = ask(&mut e, "NickServ");
|
||
assert!(ns.iter().any(|l| l.contains("IDENTIFY")), "{ns:?}");
|
||
|
||
// HelpServ's own commands still resolve.
|
||
let own = ask(&mut e, "HELP REQUEST");
|
||
assert!(own.iter().any(|l| l.contains("Syntax:") && l.contains("REQUEST")), "{own:?}");
|
||
}
|
||
|
||
// ---- Per-service integration coverage --------------------------------------
|
||
// The service crates carry almost no unit tests; exercise each one's core path
|
||
// through a real Engine so a regression in a command handler is caught.
|
||
|
||
// Connect uid 000AAAAAB as `acct` and identify (password "sesame"). The account
|
||
// must already be registered in the engine's db.
|
||
fn svc_login(e: &mut Engine, acct: &str) {
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: acct.into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
}
|
||
|
||
// Grant the account full operator privileges.
|
||
fn svc_oper(e: &mut Engine, acct: &str) {
|
||
// A full (root-tier) operator: `root` implies every lower privilege.
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert(acct.to_string(), echo_api::Privs::from_names(&["root"]));
|
||
e.set_opers(opers);
|
||
}
|
||
|
||
// Send a command to a service uid; return the NOTICE texts it produced.
|
||
fn svc_ask(e: &mut Engine, uid: &str, text: &str) -> Vec<String> {
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: uid.into(), text: text.into() })
|
||
.into_iter()
|
||
.filter_map(|a| match a { NetAction::Notice { text, .. } => Some(text), _ => None })
|
||
.collect()
|
||
}
|
||
|
||
fn svc_db(tag: &str) -> Db {
|
||
let path = std::env::temp_dir().join(format!("echo-svc-{tag}.jsonl"));
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "test");
|
||
db.scram_iterations = 4096;
|
||
db
|
||
}
|
||
|
||
fn ns() -> Box<NickServ> {
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })
|
||
}
|
||
|
||
#[test]
|
||
fn memoserv_delivers_a_memo_to_an_offline_account() {
|
||
let mut db = svc_db("memo");
|
||
db.register("alice", "sesame", None).unwrap();
|
||
db.register("bob", "sesame", None).unwrap();
|
||
let mut e = Engine::new(vec![ns(), Box::new(echo_memoserv::MemoServ { uid: "42SAAAAAE".into() })], db);
|
||
svc_login(&mut e, "alice");
|
||
let out = svc_ask(&mut e, "42SAAAAAE", "SEND bob hi there");
|
||
assert!(out.iter().any(|l| l.contains("Memo sent")), "{out:?}");
|
||
assert_eq!(e.db.unread_memos("bob"), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn groupserv_registers_a_group_and_adds_a_member() {
|
||
let mut db = svc_db("group");
|
||
db.register("alice", "sesame", None).unwrap();
|
||
db.register("bob", "sesame", None).unwrap();
|
||
let mut e = Engine::new(vec![ns(), Box::new(echo_groupserv::GroupServ { uid: "42SAAAAAL".into() })], db);
|
||
svc_login(&mut e, "alice");
|
||
assert!(svc_ask(&mut e, "42SAAAAAL", "REGISTER !team").iter().any(|l| l.contains("registered")));
|
||
svc_ask(&mut e, "42SAAAAAL", "ADD !team bob");
|
||
let g = e.db.group("!team").expect("group exists");
|
||
assert!(g.founder.eq_ignore_ascii_case("alice"));
|
||
assert_eq!(g.members.len(), 1, "bob is a member");
|
||
}
|
||
|
||
#[test]
|
||
fn reportserv_files_then_an_oper_closes() {
|
||
let mut db = svc_db("report");
|
||
db.register("alice", "sesame", None).unwrap();
|
||
let mut e = Engine::new(vec![ns(), Box::new(echo_reportserv::ReportServ { uid: "42SAAAAAK".into() })], db);
|
||
svc_login(&mut e, "alice");
|
||
svc_ask(&mut e, "42SAAAAAK", "REPORT bob being a nuisance");
|
||
assert_eq!(e.db.reports(true).len(), 1, "one open report");
|
||
svc_oper(&mut e, "alice");
|
||
svc_ask(&mut e, "42SAAAAAK", "CLOSE 0"); // report ids start at 0
|
||
assert_eq!(e.db.reports(true).len(), 0, "closed");
|
||
}
|
||
|
||
#[test]
|
||
fn infoserv_posts_a_bulletin_admin_only() {
|
||
let mut db = svc_db("info");
|
||
db.register("alice", "sesame", None).unwrap();
|
||
let mut e = Engine::new(vec![ns(), Box::new(echo_infoserv::InfoServ { uid: "42SAAAAAJ".into() })], db);
|
||
svc_login(&mut e, "alice");
|
||
// Not an oper yet: refused.
|
||
assert!(svc_ask(&mut e, "42SAAAAAJ", "POST scheduled maintenance tonight").iter().any(|l| l.contains("Access denied")));
|
||
assert_eq!(e.db.news(NewsKind::Logon).len(), 0);
|
||
svc_oper(&mut e, "alice");
|
||
svc_ask(&mut e, "42SAAAAAJ", "POST scheduled maintenance tonight");
|
||
assert_eq!(e.db.news(NewsKind::Logon).len(), 1, "bulletin posted");
|
||
}
|
||
|
||
#[test]
|
||
fn hostserv_request_then_operator_activate() {
|
||
let mut db = svc_db("host");
|
||
db.register("alice", "sesame", None).unwrap();
|
||
let mut e = Engine::new(vec![ns(), Box::new(echo_hostserv::HostServ { uid: "42SAAAAAG".into() })], db);
|
||
svc_login(&mut e, "alice");
|
||
svc_ask(&mut e, "42SAAAAAG", "REQUEST cool.vhost");
|
||
assert_eq!(e.db.vhost_requests().len(), 1, "request pending");
|
||
svc_oper(&mut e, "alice");
|
||
svc_ask(&mut e, "42SAAAAAG", "ACTIVATE alice");
|
||
assert!(e.db.vhosts().iter().any(|(acct, host, _, _)| acct.eq_ignore_ascii_case("alice") && host == "cool.vhost"), "vhost assigned: {:?}", e.db.vhosts());
|
||
}
|
||
|
||
#[test]
|
||
fn operserv_akill_add_and_remove() {
|
||
let mut db = svc_db("oper");
|
||
db.register("alice", "sesame", None).unwrap();
|
||
let mut e = Engine::new(vec![ns(), Box::new(echo_operserv::OperServ { uid: "42SAAAAAH".into() })], db);
|
||
svc_login(&mut e, "alice");
|
||
// Not an oper: refused.
|
||
svc_ask(&mut e, "42SAAAAAH", "AKILL ADD *@bad.host spamming");
|
||
assert_eq!(e.db.akills().len(), 0);
|
||
svc_oper(&mut e, "alice");
|
||
svc_ask(&mut e, "42SAAAAAH", "AKILL ADD *@bad.host spamming");
|
||
assert_eq!(e.db.akills().len(), 1, "akill added");
|
||
svc_ask(&mut e, "42SAAAAAH", "AKILL DEL *@bad.host");
|
||
assert_eq!(e.db.akills().len(), 0, "akill removed");
|
||
}
|
||
|
||
#[test]
|
||
fn botserv_bot_add_is_operator_gated() {
|
||
let mut db = svc_db("bot");
|
||
db.register("alice", "sesame", None).unwrap();
|
||
let mut e = Engine::new(vec![ns(), Box::new(echo_botserv::BotServ { uid: "42SAAAAAD".into() })], db);
|
||
svc_login(&mut e, "alice");
|
||
svc_ask(&mut e, "42SAAAAAD", "BOT ADD Helper helper services.local A bot");
|
||
assert_eq!(e.db.bots().count(), 0, "not an oper");
|
||
svc_oper(&mut e, "alice");
|
||
svc_ask(&mut e, "42SAAAAAD", "BOT ADD Helper helper services.local A bot");
|
||
assert_eq!(e.db.bots().count(), 1, "bot created by oper");
|
||
}
|
||
|
||
#[test]
|
||
fn statserv_server_needs_an_operator() {
|
||
let mut db = svc_db("stat");
|
||
db.register("alice", "sesame", None).unwrap();
|
||
let mut e = Engine::new(vec![ns(), Box::new(echo_statserv::StatServ { uid: "42SAAAAAF".into() })], db);
|
||
svc_login(&mut e, "alice");
|
||
assert!(svc_ask(&mut e, "42SAAAAAF", "SERVER").iter().any(|l| l.to_lowercase().contains("denied") || l.contains("operator")), "non-oper refused");
|
||
svc_oper(&mut e, "alice");
|
||
assert!(!svc_ask(&mut e, "42SAAAAAF", "SERVER").is_empty(), "oper gets stats");
|
||
}
|
||
|
||
#[test]
|
||
fn chanfix_and_diceserv_basic_paths() {
|
||
let mut db = svc_db("misc");
|
||
db.register("alice", "sesame", None).unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
ns(),
|
||
Box::new(echo_chanfix::ChanFix { uid: "42SAAAAAM".into() }),
|
||
Box::new(echo_diceserv::DiceServ { uid: "42SAAAAAI".into() }),
|
||
],
|
||
db,
|
||
);
|
||
svc_login(&mut e, "alice");
|
||
// ChanFix is oper-only; SCORES on an unknown channel reports no history.
|
||
svc_oper(&mut e, "alice");
|
||
assert!(svc_ask(&mut e, "42SAAAAAM", "SCORES #ghost").iter().any(|l| l.contains("No op-time")), "chanfix empty case");
|
||
// DiceServ evaluates an expression.
|
||
assert!(svc_ask(&mut e, "42SAAAAAI", "ROLL 2d6").iter().any(|l| l.contains('=')), "dice result");
|
||
}
|
||
|
||
#[test]
|
||
fn notify_flags_match_user_nick_channel_and_expiry() {
|
||
fn bt(nick: &'static str, host: &'static str) -> echo_api::BanTarget<'static> {
|
||
echo_api::BanTarget { nick, ident: "u", host, realhost: host, ip: "1.2.3.4", gecos: "g", account: None, server: "s.net", fingerprint: None, channels: vec![] }
|
||
}
|
||
let mut db = svc_db("notifymatch");
|
||
db.notify_add("baddie*!*@*", "cdn", "nick watch", "admin", None).unwrap();
|
||
db.notify_add("#warez*", "j", "chan watch", "admin", None).unwrap();
|
||
db.notify_add("*@spam.net", "c", "host watch", "admin", Some(1)).unwrap(); // expires at epoch 1 = long gone
|
||
|
||
// A user mask hits exactly its own flags, not another entry's.
|
||
let baddie = bt("baddie99", "h.example");
|
||
assert!(db.notify_flags(Some(&baddie), None).contains('c'), "connect flag");
|
||
assert!(db.notify_flags(Some(&baddie), None).contains('n'), "nick flag");
|
||
assert!(!db.notify_flags(Some(&baddie), None).contains('j'), "j belongs to the channel mask only");
|
||
// An unrelated user matches nothing.
|
||
assert!(db.notify_flags(Some(&bt("nice", "h.example")), None).is_empty(), "no match for a clean user");
|
||
// A channel mask matches on the joined channel, not on a different one.
|
||
assert!(db.notify_flags(Some(&bt("nice", "h.example")), Some("#warez7")).contains('j'), "channel watch hit");
|
||
assert!(!db.notify_flags(Some(&bt("nice", "h.example")), Some("#chat")).contains('j'), "channel watch miss");
|
||
// The expired host watch never matches and is hidden from the list.
|
||
assert!(db.notify_flags(Some(&bt("x", "spam.net")), None).is_empty(), "expired entry never matches");
|
||
assert_eq!(db.notifies().len(), 2, "expired entry hidden from the list");
|
||
// A PyLink relay client (nick carries a /network suffix) is excluded wholesale,
|
||
// even against a watch it would otherwise match — and a normal nick still hits.
|
||
db.set_notify_exclude(vec!["*/*".to_string()]);
|
||
assert!(db.notify_flags(Some(&bt("baddie7/fun", "h.example")), None).is_empty(), "relay client excluded from every watch");
|
||
assert!(db.notify_flags(Some(&bt("baddie7", "h.example")), None).contains('c'), "a normal nick still matches after exclude is set");
|
||
// A #channel in the exclude list mutes that channel while others still fire —
|
||
// e.g. watch every channel's joins but skip #staff.
|
||
db.set_notify_exclude(vec!["#staff".to_string()]);
|
||
assert!(db.notify_flags(Some(&bt("nice", "h.example")), Some("#staff")).is_empty(), "excluded channel is muted");
|
||
assert!(db.notify_flags(Some(&bt("nice", "h.example")), Some("#warez7")).contains('j'), "other channels still watched");
|
||
// `server:<glob>` mutes everyone on a matching server — a relay whose users have
|
||
// clean nicks. bt() puts users on server "s.net".
|
||
db.set_notify_exclude(vec!["server:s.net".to_string()]);
|
||
assert!(db.notify_flags(Some(&bt("relayed", "h.example")), Some("#warez7")).is_empty(), "user on the excluded server is muted");
|
||
db.set_notify_exclude(vec!["via:other.net".to_string()]);
|
||
assert!(db.notify_flags(Some(&bt("relayed", "h.example")), Some("#warez7")).contains('j'), "user on a different server still watched");
|
||
db.set_notify_exclude(vec![]);
|
||
// DEL by mask removes just that one.
|
||
assert!(db.notify_del("baddie*!*@*").unwrap());
|
||
assert_eq!(db.notifies().len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn notify_hook_emits_feed_for_watched_user() {
|
||
let mut e = engine_with("nfyhook", "foo", "sesame");
|
||
e.set_sid("42S".into());
|
||
e.set_log_channel(Some("#services".into()));
|
||
e.set_debugserv_uid("42SAAAAAO");
|
||
e.db.notify_add("baddie*!*@*", "cd", "watch", "admin", None).unwrap();
|
||
|
||
// A matching user's connect (identity complete at UserAttrs) feeds a line.
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "baddie1".into(), host: "h".into(), ip: "1.2.3.4".into() });
|
||
let out = e.handle(NetEvent::UserAttrs { uid: "000AAAAAC".into(), ident: "x".into(), realhost: "h".into(), gecos: "g".into() });
|
||
assert!(
|
||
out.iter().any(|a| matches!(a, NetAction::Privmsg { from, to, text }
|
||
if from == "42SAAAAAO" && to == "#services" && text.contains("[NOTIFY]") && text.contains("connected")
|
||
&& text.contains("IP: ") && text.contains("1.2.3.4"))), // real IP carried under a label, not just the cloak
|
||
"expected a NOTIFY connect line with a labelled real IP, got {out:?}"
|
||
);
|
||
// A clean user draws nothing.
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAD".into(), nick: "nice".into(), host: "h".into(), ip: "9.9.9.9".into() });
|
||
let quiet = e.handle(NetEvent::UserAttrs { uid: "000AAAAAD".into(), ident: "y".into(), realhost: "h".into(), gecos: "g".into() });
|
||
assert!(!quiet.iter().any(|a| matches!(a, NetAction::Privmsg { to, .. } if to == "#services")), "no line for a clean user: {quiet:?}");
|
||
// Their disconnect is matched before we forget them.
|
||
let dc = e.handle(NetEvent::Quit { uid: "000AAAAAC".into(), reason: String::new() });
|
||
assert!(
|
||
dc.iter().any(|a| matches!(a, NetAction::Privmsg { text, .. } if text.contains("[NOTIFY]") && text.contains("disconnected"))),
|
||
"expected a NOTIFY disconnect line, got {dc:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn notify_hook_covers_kick_reason_oper_and_usermode() {
|
||
let mut e = engine_with("nfyext", "foo", "sesame");
|
||
e.set_sid("42S".into());
|
||
e.set_log_channel(Some("#services".into()));
|
||
e.set_debugserv_uid("42SAAAAAO");
|
||
e.db.notify_add("baddie*!*@*", "kdou", "watch", "admin", None).unwrap();
|
||
// The watched victim and a (non-watched) kicker.
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "baddie1".into(), host: "h".into(), ip: "1.2.3.4".into() });
|
||
e.handle(NetEvent::UserAttrs { uid: "000AAAAAC".into(), ident: "x".into(), realhost: "h".into(), gecos: "g".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAD".into(), nick: "moddy".into(), host: "h".into(), ip: "9.9.9.9".into() });
|
||
|
||
let feed = |acts: &[NetAction], needle: &str| acts.iter().any(|a| matches!(a, NetAction::Privmsg { to, text, .. } if to == "#services" && text.contains("[NOTIFY]") && text.contains(needle)));
|
||
|
||
// Kick: the victim's line names the kicker and carries the reason.
|
||
let k = e.handle(NetEvent::Kicked { channel: "#devs".into(), uid: "000AAAAAC".into(), by: "000AAAAAD".into(), reason: "spam".into() });
|
||
assert!(feed(&k, "was kicked from #devs by moddy") && feed(&k, "(spam)"), "kick line: {k:?}");
|
||
// User mode change.
|
||
let u = e.handle(NetEvent::UserMode { uid: "000AAAAAC".into(), modes: "+i".into() });
|
||
assert!(feed(&u, "set user mode +i"), "usermode line: {u:?}");
|
||
// Oper-up carries the oper class.
|
||
let o = e.handle(NetEvent::OperUp { uid: "000AAAAAC".into(), oper_type: "NetAdmin".into() });
|
||
assert!(feed(&o, "opered up") && feed(&o, "NetAdmin"), "operup line: {o:?}");
|
||
// Disconnect now carries the quit reason.
|
||
let d = e.handle(NetEvent::Quit { uid: "000AAAAAC".into(), reason: "Ping timeout: 240 seconds".into() });
|
||
assert!(feed(&d, "disconnected (Ping timeout: 240 seconds)"), "quit reason line: {d:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn cmd_limiter_bursts_then_throttles_per_host() {
|
||
let mut lim = CmdLimiter::default();
|
||
let t0 = Instant::now();
|
||
// A full bucket lets a whole burst through back-to-back.
|
||
for _ in 0..CMD_BURST as usize {
|
||
assert!(matches!(lim.check_at("1.2.3.4", t0), CmdVerdict::Allow), "burst within budget");
|
||
}
|
||
// Going over budget warns once, then drops silently — never a fresh warning per
|
||
// line, which would just backscatter the flood.
|
||
assert!(matches!(lim.check_at("1.2.3.4", t0), CmdVerdict::Warn), "first over-budget warns");
|
||
assert!(matches!(lim.check_at("1.2.3.4", t0), CmdVerdict::Drop), "no repeat warning");
|
||
assert!(matches!(lim.check_at("1.2.3.4", t0), CmdVerdict::Drop), "stays dropped");
|
||
// A different host keeps its own independent budget.
|
||
assert!(matches!(lim.check_at("9.9.9.9", t0), CmdVerdict::Allow), "other host independent");
|
||
// A trickle refill lets a couple through, but an abuser the ircd is already
|
||
// pacing must not be re-warned each time the bucket briefly recovers.
|
||
let paced = t0 + Duration::from_secs(4); // +2 tokens
|
||
assert!(matches!(lim.check_at("1.2.3.4", paced), CmdVerdict::Allow), "refilled token 1");
|
||
assert!(matches!(lim.check_at("1.2.3.4", paced), CmdVerdict::Allow), "refilled token 2");
|
||
assert!(matches!(lim.check_at("1.2.3.4", paced), CmdVerdict::Drop), "dropped, not re-warned mid-window");
|
||
// Only once the warn window has elapsed does one fresh warning surface again.
|
||
let next_window = t0 + Duration::from_secs(CMD_WARN_EVERY.as_secs() + 5);
|
||
for _ in 0..CMD_BURST as usize {
|
||
assert!(matches!(lim.check_at("1.2.3.4", next_window), CmdVerdict::Allow), "bucket refilled after idle");
|
||
}
|
||
assert!(matches!(lim.check_at("1.2.3.4", next_window), CmdVerdict::Warn), "one fresh warning in a new window");
|
||
}
|
||
|
||
// A dict fantasy (!dict) in a bot channel emits a DictLookup carried by the
|
||
// assigned bot — the actual dict.org round trip happens off-reactor in the link
|
||
// layer, so this just checks the routing.
|
||
#[test]
|
||
fn fantasy_dict_emits_a_lookup_via_the_bot() {
|
||
use echo_botserv::BotServ;
|
||
use echo_dictserv::DictServ;
|
||
use echo_nickserv::NickServ;
|
||
let path = std::env::temp_dir().join("echo-fdict.jsonl");
|
||
let _ = std::fs::remove_file(&path);
|
||
let mut db = Db::open(&path, "42S");
|
||
db.scram_iterations = 4096;
|
||
db.register("boss", "password1", None).unwrap();
|
||
db.register_channel("#c", "boss").unwrap();
|
||
let mut e = Engine::new(
|
||
vec![
|
||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||
Box::new(DictServ { uid: "42SAAAAAQ".into() }),
|
||
],
|
||
db,
|
||
);
|
||
e.set_sid("42S".into());
|
||
let mut opers = std::collections::HashMap::new();
|
||
opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin));
|
||
e.set_opers(opers);
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAR".into(), nick: "rando".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: "BOT ADD Bendy bot serv.host Helper".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: "ASSIGN #c Bendy".into() });
|
||
e.handle(NetEvent::Join { uid: "000AAAAAR".into(), channel: "#c".into(), op: false });
|
||
|
||
let out = e.handle(NetEvent::Privmsg { from: "000AAAAAR".into(), to: "#c".into(), text: "!dict cat".into() });
|
||
assert!(
|
||
out.iter().any(|a| matches!(a, NetAction::DictLookup { target, database, query, speak_as, .. }
|
||
if target == "#c" && database == "wn" && query == "cat" && speak_as.starts_with("42SB"))),
|
||
"expected a bot-carried DictLookup, got {out:?}"
|
||
);
|
||
}
|
||
|
||
// A blocked look-alike channel registration refuses the user AND tells the staff
|
||
// feed who tried it.
|
||
#[test]
|
||
fn confusable_register_alerts_the_staff_feed() {
|
||
use echo_chanserv::ChanServ;
|
||
let mut db = svc_db("confalert");
|
||
db.register("boss", "sesame", None).unwrap();
|
||
let mut e = Engine::new(vec![ns(), Box::new(ChanServ { uid: "42SAAAAAB".into() })], db);
|
||
e.set_sid("42S".into());
|
||
e.set_log_channel(Some("#services".into()));
|
||
e.set_debugserv_uid("42SAAAAAO");
|
||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "host.fr".into(), ip: "1.2.3.4".into() });
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||
let chan = "#teѕt"; // Latin te + Cyrillic ѕ + Latin t — a look-alike
|
||
e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: chan.into(), op: true });
|
||
let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: format!("REGISTER {chan}") });
|
||
// The user is refused,
|
||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { to, text, .. } if to == "000AAAAAB" && text.contains("alphabets"))), "user refused: {out:?}");
|
||
// and #services is told who tried it.
|
||
assert!(
|
||
out.iter().any(|a| matches!(a, NetAction::Privmsg { from, to, text }
|
||
if from == "42SAAAAAO" && to == "#services" && text.contains("[REGISTER]") && text.contains("boss") && text.contains("look-alike"))),
|
||
"staff feed alert: {out:?}"
|
||
);
|
||
// ...and it's recorded to the searchable incident log (OperServ LOGSEARCH).
|
||
assert!(!e.network.search_incidents("look-alike", 10).is_empty(), "recorded for LOGSEARCH");
|
||
}
|
||
|
||
// Perf harness — not part of the normal suite. Run:
|
||
// cargo test --release -p echo -- --ignored --nocapture bench_engine
|
||
// Measures the two costs that actually bound the single-threaded engine: the
|
||
// BotServ per-message hot path (kicker scans + record_line + fantasy), and the
|
||
// log append+fsync write path.
|
||
#[test]
|
||
#[ignore]
|
||
fn bench_engine_throughput() {
|
||
use std::time::Instant;
|
||
|
||
// Write path: one serialize + append + fsync per state-mutating event.
|
||
{
|
||
let mut db = svc_db("bench_write");
|
||
let n = 3000usize;
|
||
let t = Instant::now();
|
||
for i in 0..n {
|
||
db.notify_add(&format!("m{i}!*@*"), "c", "bench", "admin", None).unwrap();
|
||
}
|
||
let el = t.elapsed();
|
||
println!(
|
||
"\n[write] append+fsync: {n} events in {:.3}s = {:.0} events/s, {:.3} ms/event\n (temp-dir fs; a real data disk may fsync slower)",
|
||
el.as_secs_f64(),
|
||
n as f64 / el.as_secs_f64(),
|
||
el.as_secs_f64() * 1000.0 / n as f64,
|
||
);
|
||
}
|
||
|
||
// Hot path: BotServ chatter in a busy channel. Every kicker is on but set high
|
||
// enough that ordinary lines never trip it, so we time the per-message CHECK
|
||
// cost (the realistic case), not the kick path.
|
||
let (mut e, _p) = kicker_fixture("bench_hot");
|
||
let bs = |e: &mut Engine, t: &str| {
|
||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
|
||
};
|
||
bs(&mut e, "KICK #c CAPS ON");
|
||
bs(&mut e, "KICK #c REPEAT ON 100000");
|
||
bs(&mut e, "KICK #c FLOOD ON 100000 60");
|
||
|
||
let members = 1000usize;
|
||
let muid = |i: usize| format!("00M{i:06}");
|
||
for i in 0..members {
|
||
e.handle(NetEvent::UserConnect { uid: muid(i), nick: format!("user{i}"), host: "h".into(), ip: "0.0.0.0".into() });
|
||
e.handle(NetEvent::Join { uid: muid(i), channel: "#c".into(), op: false });
|
||
}
|
||
|
||
let msgs = 200_000usize;
|
||
let t = Instant::now();
|
||
for i in 0..msgs {
|
||
e.handle(NetEvent::Privmsg { from: muid(i % members), to: "#c".into(), text: format!("just chatting line {i} nothing special here") });
|
||
}
|
||
let el = t.elapsed();
|
||
println!(
|
||
"[hot] botserv chatter: {msgs} msgs in a {members}-user channel in {:.3}s = {:.0} msgs/s, {:.3} us/msg\n",
|
||
el.as_secs_f64(),
|
||
msgs as f64 / el.as_secs_f64(),
|
||
el.as_micros() as f64 / msgs as f64,
|
||
);
|
||
}
|