use std::time::Duration;
use ircbot::{Server, State};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
type Script = Vec<(&'static str, Vec<&'static str>)>;
const IDLE: Duration = Duration::from_millis(300);
async fn scripted_server(script: Script) -> (String, oneshot::Receiver<Vec<String>>) {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind failed");
let addr = listener
.local_addr()
.expect("local_addr failed")
.to_string();
let (tx, rx) = oneshot::channel();
tokio::spawn(async move {
let (sock, _) = listener.accept().await.expect("accept failed");
let (read, mut write) = sock.into_split();
let mut lines = BufReader::new(read).lines();
let mut received: Vec<String> = Vec::new();
loop {
let line = match tokio::time::timeout(IDLE, lines.next_line()).await {
Ok(Ok(Some(line))) => line,
_ => break,
};
if let Some((_, replies)) = script.iter().find(|(p, _)| line.starts_with(p)) {
for reply in replies {
write
.write_all(format!("{reply}\r\n").as_bytes())
.await
.expect("write failed");
}
}
received.push(line);
}
let _ = tx.send(received);
tokio::time::sleep(Duration::from_secs(120)).await;
});
(addr, rx)
}
fn accepting_sasl_script(advertisement: &'static str) -> Script {
vec![
("CAP LS", vec![advertisement]),
("CAP REQ", vec![":srv CAP * ACK :sasl"]),
("AUTHENTICATE PLAIN", vec!["AUTHENTICATE +"]),
("AUTHENTICATE EXTERNAL", vec!["AUTHENTICATE +"]),
(
"AUTHENTICATE",
vec![
":srv 900 bot bot!bot@host bot :You are now logged in as bot",
":srv 903 bot :SASL authentication successful",
],
),
]
}
async fn connect_error(server: Server) -> String {
match State::connect("bot", server, vec![]).await {
Ok(_) => panic!("expected the connection to be rejected"),
Err(e) => e.to_string(),
}
}
#[tokio::test]
async fn without_credentials_the_handshake_skips_cap_entirely() {
let (addr, rx) = scripted_server(vec![]).await;
let _state = State::connect("bot", Server::plain(&addr), vec![])
.await
.expect("connect failed");
assert_eq!(
rx.await.expect("server reported nothing"),
vec!["NICK bot", "USER bot 0 * :bot"]
);
}
#[tokio::test]
async fn a_server_password_is_sent_before_the_nick() {
let (addr, rx) = scripted_server(vec![]).await;
let _state = State::connect("bot", Server::plain(&addr).with_password("s3cret"), vec![])
.await
.expect("connect failed");
assert_eq!(
rx.await.expect("server reported nothing"),
vec!["PASS :s3cret", "NICK bot", "USER bot 0 * :bot"]
);
}
#[tokio::test]
async fn a_server_password_cannot_inject_a_second_command() {
let (addr, rx) = scripted_server(vec![]).await;
let _state = State::connect(
"bot",
Server::plain(&addr).with_password("s3cret\r\nJOIN #evil"),
vec![],
)
.await
.expect("connect failed");
assert_eq!(
rx.await.expect("server reported nothing"),
vec!["PASS :s3cretJOIN #evil", "NICK bot", "USER bot 0 * :bot"]
);
}
#[tokio::test]
async fn sasl_plain_authenticates_and_ends_the_capability_exchange() {
let (addr, rx) = scripted_server(accepting_sasl_script(
":srv CAP * LS :sasl=PLAIN,EXTERNAL multi-prefix",
))
.await;
let _state = State::connect(
"bot",
Server::plain(&addr).with_sasl_plain("bot", "hunter2"),
vec![],
)
.await
.expect("connect failed");
assert_eq!(
rx.await.expect("server reported nothing"),
vec![
"CAP LS 302",
"NICK bot",
"USER bot 0 * :bot",
"CAP REQ :sasl",
"AUTHENTICATE PLAIN",
"AUTHENTICATE AGJvdABodW50ZXIy",
"CAP END",
]
);
}
#[tokio::test]
async fn a_multi_line_capability_advertisement_is_reassembled() {
let mut script = accepting_sasl_script(":srv CAP * LS :sasl=PLAIN multi-prefix");
script[0] = (
"CAP LS",
vec![
":srv CAP * LS * :multi-prefix away-notify",
":srv CAP * LS :sasl=PLAIN",
],
);
let (addr, rx) = scripted_server(script).await;
let _state = State::connect(
"bot",
Server::plain(&addr).with_sasl_plain("bot", "hunter2"),
vec![],
)
.await
.expect("connect failed");
let sent = rx.await.expect("server reported nothing");
assert!(
sent.contains(&"CAP REQ :sasl".to_string()),
"sasl was not requested: {sent:?}"
);
}
#[tokio::test]
async fn sasl_external_sends_an_empty_response() {
let (addr, rx) = scripted_server(accepting_sasl_script(
":srv CAP * LS :sasl=PLAIN,EXTERNAL multi-prefix",
))
.await;
let _state = State::connect("bot", Server::plain(&addr).with_sasl_external(), vec![])
.await
.expect("connect failed");
assert_eq!(
rx.await.expect("server reported nothing"),
vec![
"CAP LS 302",
"NICK bot",
"USER bot 0 * :bot",
"CAP REQ :sasl",
"AUTHENTICATE EXTERNAL",
"AUTHENTICATE +",
"CAP END",
]
);
}
#[tokio::test]
async fn unadvertised_capabilities_are_not_requested() {
let (addr, rx) = scripted_server(vec![
("CAP LS", vec![":srv CAP * LS :server-time multi-prefix"]),
("CAP REQ", vec![":srv CAP * ACK :server-time"]),
])
.await;
let _state = State::connect(
"bot",
Server::plain(&addr).with_capabilities(["server-time", "away-notify"]),
vec![],
)
.await
.expect("connect failed");
assert_eq!(
rx.await.expect("server reported nothing"),
vec![
"CAP LS 302",
"NICK bot",
"USER bot 0 * :bot",
"CAP REQ :server-time",
"CAP END",
]
);
}
#[tokio::test]
async fn a_capability_exchange_with_nothing_to_request_still_ends() {
let (addr, rx) = scripted_server(vec![("CAP LS", vec![":srv CAP * LS :multi-prefix"])]).await;
let _state = State::connect(
"bot",
Server::plain(&addr).with_capabilities(["server-time"]),
vec![],
)
.await
.expect("connect failed");
assert_eq!(
rx.await.expect("server reported nothing"),
vec!["CAP LS 302", "NICK bot", "USER bot 0 * :bot", "CAP END"]
);
}
#[tokio::test]
async fn a_server_without_sasl_fails_the_connection() {
let (addr, _rx) = scripted_server(vec![("CAP LS", vec![":srv CAP * LS :multi-prefix"])]).await;
let err = connect_error(Server::plain(&addr).with_sasl_plain("bot", "hunter2")).await;
assert!(
err.contains("does not offer the sasl capability"),
"unhelpful error: {err}"
);
}
#[tokio::test]
async fn a_server_lacking_our_mechanism_fails_the_connection() {
let (addr, _rx) = scripted_server(vec![(
"CAP LS",
vec![":srv CAP * LS :sasl=EXTERNAL multi-prefix"],
)])
.await;
let err = connect_error(Server::plain(&addr).with_sasl_plain("bot", "hunter2")).await;
assert!(
err.contains("does not support SASL PLAIN"),
"unhelpful error: {err}"
);
assert!(
err.contains("EXTERNAL"),
"error omits what is on offer: {err}"
);
}
#[tokio::test]
async fn rejected_credentials_fail_the_connection() {
let (addr, _rx) = scripted_server(vec![
("CAP LS", vec![":srv CAP * LS :sasl=PLAIN"]),
("CAP REQ", vec![":srv CAP * ACK :sasl"]),
("AUTHENTICATE PLAIN", vec!["AUTHENTICATE +"]),
(
"AUTHENTICATE",
vec![":srv 904 bot :SASL authentication failed"],
),
])
.await;
let err = connect_error(Server::plain(&addr).with_sasl_plain("bot", "wrong")).await;
assert!(
err.contains("SASL authentication failed"),
"unhelpful error: {err}"
);
assert!(
err.contains("with_sasl_plain"),
"error does not say what to fix: {err}"
);
}
#[tokio::test]
async fn a_refused_sasl_capability_fails_the_connection() {
let (addr, _rx) = scripted_server(vec![
("CAP LS", vec![":srv CAP * LS :sasl=PLAIN"]),
("CAP REQ", vec![":srv CAP * NAK :sasl"]),
])
.await;
let err = connect_error(Server::plain(&addr).with_sasl_plain("bot", "hunter2")).await;
assert!(err.contains("refused the sasl capability"), "{err}");
}
#[tokio::test]
async fn a_server_without_cap_support_still_registers() {
let (addr, rx) =
scripted_server(vec![("CAP LS", vec![":srv 421 bot CAP :Unknown command"])]).await;
let _state = State::connect(
"bot",
Server::plain(&addr).with_capabilities(["server-time"]),
vec![],
)
.await
.expect("connect failed");
assert_eq!(
rx.await.expect("server reported nothing"),
vec!["CAP LS 302", "NICK bot", "USER bot 0 * :bot"]
);
}
#[tokio::test]
async fn a_server_without_cap_support_fails_a_sasl_connection() {
let (addr, _rx) =
scripted_server(vec![("CAP LS", vec![":srv 421 bot CAP :Unknown command"])]).await;
let err = connect_error(Server::plain(&addr).with_sasl_plain("bot", "hunter2")).await;
assert!(err.contains("does not implement CAP"), "{err}");
}
#[tokio::test(start_paused = true)]
async fn a_silent_server_times_the_exchange_out() {
let (addr, _rx) = scripted_server(vec![]).await;
let err = connect_error(Server::plain(&addr).with_sasl_plain("bot", "hunter2")).await;
assert!(err.contains("stopped responding"), "unhelpful error: {err}");
}
#[tokio::test]
async fn a_ping_during_negotiation_is_answered() {
let mut script = accepting_sasl_script(":srv CAP * LS :sasl=PLAIN multi-prefix");
script[0] = (
"CAP LS",
vec!["PING :handshake", ":srv CAP * LS :sasl=PLAIN"],
);
let (addr, rx) = scripted_server(script).await;
let _state = State::connect(
"bot",
Server::plain(&addr).with_sasl_plain("bot", "hunter2"),
vec![],
)
.await
.expect("connect failed");
let sent = rx.await.expect("server reported nothing");
assert!(
sent.contains(&"PONG :handshake".to_string()),
"the ping went unanswered: {sent:?}"
);
}
#[tokio::test]
async fn a_nick_collision_during_negotiation_still_reaches_the_read_loop() {
let (addr, rx) = scripted_server(vec![
(
"CAP LS",
vec![
":srv CAP * LS :sasl=PLAIN",
":srv 433 * bot :Nickname is already in use",
],
),
("CAP REQ", vec![":srv CAP * ACK :sasl"]),
("AUTHENTICATE PLAIN", vec!["AUTHENTICATE +"]),
(
"AUTHENTICATE",
vec![":srv 903 bot :SASL authentication successful"],
),
])
.await;
let state = State::connect(
"bot",
Server::plain(&addr).with_sasl_plain("bot", "hunter2"),
vec![],
)
.await
.expect("connect failed");
let _bot = tokio::spawn(ircbot::internal::run_bot(
std::sync::Arc::new(()),
state,
vec![],
));
let sent = rx.await.expect("server reported nothing");
assert!(
sent.contains(&"NICK bot_".to_string()),
"the nick collision was dropped: {sent:?}"
);
}
#[tokio::test]
async fn an_unprompted_success_numeric_does_not_end_the_exchange() {
let (addr, rx) = scripted_server(vec![
(
"CAP LS",
vec![
":srv CAP * LS :sasl=PLAIN",
":srv 903 bot :SASL authentication successful",
],
),
("CAP REQ", vec![":srv CAP * ACK :sasl"]),
("AUTHENTICATE PLAIN", vec!["AUTHENTICATE +"]),
(
"AUTHENTICATE",
vec![":srv 903 bot :SASL authentication successful"],
),
])
.await;
let _state = State::connect(
"bot",
Server::plain(&addr).with_sasl_plain("bot", "hunter2"),
vec![],
)
.await
.expect("connect failed");
let sent = rx.await.expect("server reported nothing");
assert!(
sent.contains(&"AUTHENTICATE AGJvdABodW50ZXIy".to_string()),
"the exchange ended before authenticating: {sent:?}"
);
}