1059 lines
34 KiB
Rust
1059 lines
34 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
*
|
|
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
*/
|
|
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::net::{TcpListener, TcpStream};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Mutex;
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
use std::thread;
|
|
use std::time::Duration;
|
|
|
|
use rusqlite::Connection;
|
|
use vandelay::logging::Logger;
|
|
use vandelay::sync::CommonConfig;
|
|
use vandelay::sync::import_managesieve::{ManageSieveAuth, ManageSieveImportConfig, run};
|
|
|
|
type Script = Box<dyn FnOnce(&mut MockConn) -> std::io::Result<()> + Send + 'static>;
|
|
|
|
struct MockSieveServer {
|
|
addr: String,
|
|
_thread: thread::JoinHandle<()>,
|
|
}
|
|
|
|
struct MockConn {
|
|
reader: BufReader<TcpStream>,
|
|
writer: TcpStream,
|
|
}
|
|
|
|
impl MockConn {
|
|
fn write_line(&mut self, s: &str) -> std::io::Result<()> {
|
|
self.writer.write_all(s.as_bytes())?;
|
|
self.writer.write_all(b"\r\n")?;
|
|
self.writer.flush()
|
|
}
|
|
|
|
fn write_raw(&mut self, bytes: &[u8]) -> std::io::Result<()> {
|
|
self.writer.write_all(bytes)?;
|
|
self.writer.flush()
|
|
}
|
|
|
|
fn read_line(&mut self) -> std::io::Result<String> {
|
|
let mut line = String::new();
|
|
let n = self.reader.read_line(&mut line)?;
|
|
if n == 0 {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::UnexpectedEof,
|
|
"client closed",
|
|
));
|
|
}
|
|
Ok(line.trim_end_matches(['\r', '\n']).to_owned())
|
|
}
|
|
|
|
fn write_capability(&mut self, sasl: &str, starttls: bool) -> std::io::Result<()> {
|
|
self.write_line("\"IMPLEMENTATION\" \"Mock ManageSieve\"")?;
|
|
self.write_line("\"VERSION\" \"1.0\"")?;
|
|
if starttls {
|
|
self.write_line("\"STARTTLS\"")?;
|
|
}
|
|
self.write_line(&format!("\"SASL\" \"{sasl}\""))?;
|
|
self.write_line("\"SIEVE\" \"fileinto vacation\"")?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl MockSieveServer {
|
|
fn start<H>(handler: H) -> MockSieveServer
|
|
where
|
|
H: FnOnce(&mut MockConn) -> std::io::Result<()> + Send + 'static,
|
|
{
|
|
Self::start_scripts(vec![Box::new(handler)])
|
|
}
|
|
|
|
fn start_scripts(scripts: Vec<Script>) -> MockSieveServer {
|
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
|
|
let port = listener.local_addr().expect("local_addr").port();
|
|
let addr = format!("127.0.0.1:{port}");
|
|
let queue: Mutex<Vec<Script>> = Mutex::new(scripts.into_iter().rev().collect());
|
|
let queue = std::sync::Arc::new(queue);
|
|
let thread = thread::spawn(move || {
|
|
for stream in listener.incoming() {
|
|
let stream = match stream {
|
|
Ok(s) => s,
|
|
Err(_) => return,
|
|
};
|
|
let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));
|
|
let _ = stream.set_write_timeout(Some(Duration::from_secs(5)));
|
|
let writer = stream.try_clone().expect("clone");
|
|
let reader = BufReader::new(stream);
|
|
let mut conn = MockConn { reader, writer };
|
|
let script = queue.lock().expect("queue").pop();
|
|
if let Some(script) = script {
|
|
thread::spawn(move || {
|
|
let _ = script(&mut conn);
|
|
});
|
|
} else {
|
|
let _ = conn.write_line("BYE \"no script left\"");
|
|
}
|
|
}
|
|
});
|
|
MockSieveServer {
|
|
addr,
|
|
_thread: thread,
|
|
}
|
|
}
|
|
|
|
fn url(&self) -> String {
|
|
format!("sieve://{}", self.addr)
|
|
}
|
|
}
|
|
|
|
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
|
|
|
fn tempfile(label: &str) -> PathBuf {
|
|
let counter = COUNTER.fetch_add(1, Ordering::SeqCst);
|
|
let mut p = std::env::temp_dir();
|
|
p.push(format!(
|
|
"vandelay_mock_managesieve_{label}_{counter}.sqlite"
|
|
));
|
|
if p.exists() {
|
|
let _ = std::fs::remove_file(&p);
|
|
}
|
|
p
|
|
}
|
|
|
|
fn count(conn: &Connection, table: &str) -> i64 {
|
|
conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
fn run_basic(
|
|
server: &MockSieveServer,
|
|
archive: &Path,
|
|
) -> Result<vandelay::sync::Summary, vandelay::error::Error> {
|
|
let common = CommonConfig {
|
|
archive: archive.to_path_buf(),
|
|
threads: 1,
|
|
dry_run: false,
|
|
max_retries: 1,
|
|
allow_invalid_certs: false,
|
|
logger: Logger::from_flags(true, 0),
|
|
};
|
|
let config = ManageSieveImportConfig {
|
|
url: server.url(),
|
|
auth: ManageSieveAuth::Basic {
|
|
user: "alice".to_owned(),
|
|
password: "p@ss".to_owned(),
|
|
},
|
|
allow_cleartext: true,
|
|
allow_source_change: false,
|
|
};
|
|
run(common, config)
|
|
}
|
|
|
|
#[test]
|
|
fn capability_after_auth_is_consumed_when_present() {
|
|
let server = MockSieveServer::start(|conn| {
|
|
conn.write_capability("PLAIN", false)?;
|
|
conn.write_line("OK \"hi\"")?;
|
|
let cmd = conn.read_line()?;
|
|
assert!(cmd.starts_with("AUTHENTICATE \"PLAIN\""), "got {cmd}");
|
|
|
|
conn.write_line("OK")?;
|
|
|
|
let cmd = conn.read_line()?;
|
|
assert!(cmd.starts_with("CAPABILITY"), "got {cmd}");
|
|
conn.write_capability("PLAIN", false)?;
|
|
conn.write_line("OK")?;
|
|
|
|
let cmd = conn.read_line()?;
|
|
assert!(cmd.starts_with("LISTSCRIPTS"), "got {cmd}");
|
|
conn.write_line("OK")?;
|
|
|
|
let _ = conn.read_line();
|
|
conn.write_line("OK \"bye\"")?;
|
|
Ok(())
|
|
});
|
|
let archive = tempfile("caps_after_auth");
|
|
let summary = run_basic(&server, &archive).expect("import");
|
|
assert!(!summary.any_failed());
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn sasl_plain_succeeds_when_advertised() {
|
|
let server = MockSieveServer::start(|conn| {
|
|
conn.write_capability("PLAIN", false)?;
|
|
conn.write_line("OK")?;
|
|
let cmd = conn.read_line()?;
|
|
assert!(cmd.starts_with("AUTHENTICATE \"PLAIN\""), "got {cmd}");
|
|
conn.write_line("OK \"auth ok\"")?;
|
|
|
|
let _ = conn.read_line();
|
|
conn.write_capability("PLAIN", false)?;
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line();
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line();
|
|
conn.write_line("OK \"bye\"")?;
|
|
Ok(())
|
|
});
|
|
let archive = tempfile("plain");
|
|
let summary = run_basic(&server, &archive).expect("import");
|
|
assert!(!summary.any_failed());
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn sasl_login_fallback_when_plain_rejected() {
|
|
let server = MockSieveServer::start(|conn| {
|
|
conn.write_capability("PLAIN LOGIN", false)?;
|
|
conn.write_line("OK")?;
|
|
let cmd = conn.read_line()?;
|
|
assert!(cmd.starts_with("AUTHENTICATE \"PLAIN\""), "got {cmd}");
|
|
|
|
conn.write_line("NO \"PLAIN not allowed here\"")?;
|
|
|
|
let cmd = conn.read_line()?;
|
|
assert!(
|
|
cmd.starts_with("AUTHENTICATE \"LOGIN\""),
|
|
"expected LOGIN fallback, got {cmd}"
|
|
);
|
|
|
|
conn.write_line("\"VXNlcm5hbWU6\"")?;
|
|
let _user = conn.read_line()?;
|
|
|
|
conn.write_line("\"UGFzc3dvcmQ6\"")?;
|
|
let _pass = conn.read_line()?;
|
|
conn.write_line("OK \"welcome\"")?;
|
|
let _ = conn.read_line();
|
|
conn.write_capability("PLAIN LOGIN", false)?;
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line();
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line();
|
|
conn.write_line("OK \"bye\"")?;
|
|
Ok(())
|
|
});
|
|
let archive = tempfile("login_fallback");
|
|
let summary = run_basic(&server, &archive).expect("import");
|
|
assert!(!summary.any_failed());
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_no_translates_to_connection_error() {
|
|
let server = MockSieveServer::start(|conn| {
|
|
conn.write_capability("PLAIN", false)?;
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line()?;
|
|
conn.write_line("NO \"bad creds\"")?;
|
|
Ok(())
|
|
});
|
|
let archive = tempfile("auth_fail");
|
|
let err = run_basic(&server, &archive).unwrap_err();
|
|
match err {
|
|
vandelay::error::Error::Connection(msg) => assert!(
|
|
msg.contains("auth failed") || msg.contains("LOGIN"),
|
|
"got {msg}"
|
|
),
|
|
other => panic!("expected Connection, got {other:?}"),
|
|
}
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn server_lacking_plain_login_for_basic_is_refused() {
|
|
let server = MockSieveServer::start(|conn| {
|
|
conn.write_capability("OAUTHBEARER", false)?;
|
|
conn.write_line("OK")?;
|
|
Ok(())
|
|
});
|
|
let archive = tempfile("no_basic_mech");
|
|
let err = run_basic(&server, &archive).unwrap_err();
|
|
assert!(matches!(err, vandelay::error::Error::Connection(_)));
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
fn auth_then<F>(conn: &mut MockConn, after_auth: F) -> std::io::Result<()>
|
|
where
|
|
F: FnOnce(&mut MockConn) -> std::io::Result<()>,
|
|
{
|
|
conn.write_capability("PLAIN", false)?;
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line()?;
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line()?;
|
|
conn.write_capability("PLAIN", false)?;
|
|
conn.write_line("OK")?;
|
|
after_auth(conn)?;
|
|
let _ = conn.read_line();
|
|
conn.write_line("OK \"bye\"")?;
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn empty_listscripts_imports_zero_rows() {
|
|
let server = MockSieveServer::start(|conn| {
|
|
auth_then(conn, |c| {
|
|
let cmd = c.read_line()?;
|
|
assert!(cmd.starts_with("LISTSCRIPTS"));
|
|
c.write_line("OK")?;
|
|
Ok(())
|
|
})
|
|
});
|
|
let archive = tempfile("empty_list");
|
|
let summary = run_basic(&server, &archive).expect("import");
|
|
assert!(!summary.any_failed());
|
|
let conn = Connection::open(&archive).unwrap();
|
|
assert_eq!(count(&conn, "sieve_scripts"), 0);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn listscripts_with_one_active_imports_with_active_flag() {
|
|
let script = b"require [\"fileinto\"];\nfileinto \"INBOX\";\n";
|
|
let script_owned = script.to_vec();
|
|
let server = MockSieveServer::start(move |conn| {
|
|
auth_then(conn, |c| {
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"vacation\" ACTIVE")?;
|
|
c.write_line("\"other\"")?;
|
|
c.write_line("OK")?;
|
|
|
|
let _ = c.read_line()?;
|
|
c.write_line(&format!("{{{}}}", script_owned.len()))?;
|
|
c.write_raw(&script_owned)?;
|
|
c.write_raw(b"\r\n")?;
|
|
c.write_line("OK")?;
|
|
|
|
let _ = c.read_line()?;
|
|
c.write_line(&format!("{{{}}}", script_owned.len()))?;
|
|
c.write_raw(&script_owned)?;
|
|
c.write_raw(b"\r\n")?;
|
|
c.write_line("OK")?;
|
|
Ok(())
|
|
})
|
|
});
|
|
let archive = tempfile("active");
|
|
let summary = run_basic(&server, &archive).expect("import");
|
|
assert!(!summary.any_failed(), "{summary:?}");
|
|
let conn = Connection::open(&archive).unwrap();
|
|
assert_eq!(count(&conn, "sieve_scripts"), 2);
|
|
let active_name: String = conn
|
|
.query_row(
|
|
"SELECT name FROM sieve_scripts WHERE is_active = 1",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(active_name, "vacation");
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn getscript_quoted_body_is_imported_verbatim() {
|
|
let server = MockSieveServer::start(|conn| {
|
|
auth_then(conn, |c| {
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"oneliner\"")?;
|
|
c.write_line("OK")?;
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"stop;\"")?;
|
|
c.write_line("OK")?;
|
|
Ok(())
|
|
})
|
|
});
|
|
let archive = tempfile("quoted_body");
|
|
let summary = run_basic(&server, &archive).expect("import");
|
|
assert!(!summary.any_failed());
|
|
let conn = Connection::open(&archive).unwrap();
|
|
let bytes: Vec<u8> = conn
|
|
.query_row(
|
|
"SELECT b.data FROM blobs b JOIN sieve_scripts s ON s.blob_id=b.id LIMIT 1",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(bytes, b"stop;");
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn getscript_nonexistent_is_skipped_and_counted() {
|
|
let server = MockSieveServer::start(|conn| {
|
|
auth_then(conn, |c| {
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"ok-one\"")?;
|
|
c.write_line("\"missing\"")?;
|
|
c.write_line("OK")?;
|
|
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"keep;\"")?;
|
|
c.write_line("OK")?;
|
|
|
|
let _ = c.read_line()?;
|
|
c.write_line("NO (NONEXISTENT) \"vanished\"")?;
|
|
Ok(())
|
|
})
|
|
});
|
|
let archive = tempfile("nonexistent");
|
|
let summary = run_basic(&server, &archive).expect("import");
|
|
assert!(summary.any_failed(), "expected at least one failure");
|
|
let conn = Connection::open(&archive).unwrap();
|
|
assert_eq!(count(&conn, "sieve_scripts"), 1);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn referral_aborts_run_with_connection_error() {
|
|
let server = MockSieveServer::start(|conn| {
|
|
auth_then(conn, |c| {
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"x\"")?;
|
|
c.write_line("OK")?;
|
|
let _ = c.read_line()?;
|
|
c.write_line("NO (REFERRAL \"sieve://other.example/\") \"go\"")?;
|
|
Ok(())
|
|
})
|
|
});
|
|
let archive = tempfile("referral");
|
|
let err = run_basic(&server, &archive).unwrap_err();
|
|
match err {
|
|
vandelay::error::Error::Connection(msg) => assert!(msg.contains("referral"), "{msg}"),
|
|
other => panic!("expected Connection, got {other:?}"),
|
|
}
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
fn seed_then_run(seed: Script, second: Script) -> (MockSieveServer, PathBuf) {
|
|
let server = MockSieveServer::start_scripts(vec![seed, second]);
|
|
let archive = tempfile("multi");
|
|
(server, archive)
|
|
}
|
|
|
|
fn run_against(
|
|
server: &MockSieveServer,
|
|
archive: &Path,
|
|
) -> Result<vandelay::sync::Summary, vandelay::error::Error> {
|
|
run_basic(server, archive)
|
|
}
|
|
|
|
fn seed_one_then(seed_name: &str, seed_bytes: &str, second: Script) -> (MockSieveServer, PathBuf) {
|
|
let seed_name_owned = seed_name.to_owned();
|
|
let seed_bytes_owned = seed_bytes.to_owned();
|
|
let seed: Script = Box::new(move |conn| {
|
|
auth_then(conn, |c| {
|
|
let _ = c.read_line()?;
|
|
c.write_line(&format!("\"{seed_name_owned}\""))?;
|
|
c.write_line("OK")?;
|
|
let _ = c.read_line()?;
|
|
c.write_line(&format!("\"{seed_bytes_owned}\""))?;
|
|
c.write_line("OK")?;
|
|
Ok(())
|
|
})
|
|
});
|
|
seed_then_run(seed, second)
|
|
}
|
|
|
|
#[test]
|
|
fn present_unchanged_script_does_not_rewrite_blob() {
|
|
let second: Script = Box::new(|conn| {
|
|
auth_then(conn, |c| {
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"a\"")?;
|
|
c.write_line("OK")?;
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"stop;\"")?;
|
|
c.write_line("OK")?;
|
|
Ok(())
|
|
})
|
|
});
|
|
let (server, archive) = seed_one_then("a", "stop;", second);
|
|
let _ = run_against(&server, &archive).expect("seed");
|
|
let summary = run_against(&server, &archive).expect("reconcile");
|
|
let (_, c) = &summary.per_type[0];
|
|
assert_eq!(c.created, 0);
|
|
assert_eq!(c.deleted, 0);
|
|
assert_eq!(c.skipped, 1, "present unchanged should count as skipped");
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn present_content_updated_swaps_blob_pointer() {
|
|
let second: Script = Box::new(|conn| {
|
|
auth_then(conn, |c| {
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"a\"")?;
|
|
c.write_line("OK")?;
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"v2\"")?;
|
|
c.write_line("OK")?;
|
|
Ok(())
|
|
})
|
|
});
|
|
let (server, archive) = seed_one_then("a", "v1", second);
|
|
let _ = run_against(&server, &archive).expect("seed");
|
|
let summary = run_against(&server, &archive).expect("reconcile");
|
|
let (_, c) = &summary.per_type[0];
|
|
assert_eq!(c.fetched, 1, "content update should count as fetched");
|
|
let conn = Connection::open(&archive).unwrap();
|
|
let bytes: Vec<u8> = conn
|
|
.query_row(
|
|
"SELECT b.data FROM blobs b JOIN sieve_scripts s ON s.blob_id=b.id WHERE s.name='a'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(bytes, b"v2");
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn vanished_script_is_deleted_locally() {
|
|
let second: Script = Box::new(|conn| {
|
|
auth_then(conn, |c| {
|
|
let _ = c.read_line()?;
|
|
c.write_line("OK")?;
|
|
Ok(())
|
|
})
|
|
});
|
|
let (server, archive) = seed_one_then("a", "x", second);
|
|
let _ = run_against(&server, &archive).expect("seed");
|
|
let summary = run_against(&server, &archive).expect("reconcile");
|
|
let (_, c) = &summary.per_type[0];
|
|
assert_eq!(c.deleted, 1, "vanished script should be counted deleted");
|
|
let conn = Connection::open(&archive).unwrap();
|
|
assert_eq!(count(&conn, "sieve_scripts"), 0);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn active_flag_flip_does_not_violate_partial_unique_index() {
|
|
let seed: Script = Box::new(|conn| {
|
|
auth_then(conn, |c| {
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"a\" ACTIVE")?;
|
|
c.write_line("OK")?;
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"x\"")?;
|
|
c.write_line("OK")?;
|
|
Ok(())
|
|
})
|
|
});
|
|
let second: Script = Box::new(|conn| {
|
|
auth_then(conn, |c| {
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"a\"")?;
|
|
c.write_line("\"b\" ACTIVE")?;
|
|
c.write_line("OK")?;
|
|
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"x\"")?;
|
|
c.write_line("OK")?;
|
|
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"y\"")?;
|
|
c.write_line("OK")?;
|
|
Ok(())
|
|
})
|
|
});
|
|
let (server, archive) = seed_then_run(seed, second);
|
|
let _ = run_against(&server, &archive).expect("seed");
|
|
let summary = run_against(&server, &archive).expect("reconcile");
|
|
assert!(!summary.any_failed(), "{summary:?}");
|
|
let conn = Connection::open(&archive).unwrap();
|
|
let active: String = conn
|
|
.query_row(
|
|
"SELECT name FROM sieve_scripts WHERE is_active = 1",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(active, "b");
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn resume_after_partial_run_completes_remainder() {
|
|
let seed: Script = Box::new(|conn| {
|
|
auth_then(conn, |c| {
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"a\"")?;
|
|
c.write_line("OK")?;
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"first\"")?;
|
|
c.write_line("OK")?;
|
|
Ok(())
|
|
})
|
|
});
|
|
let second: Script = Box::new(|conn| {
|
|
auth_then(conn, |c| {
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"a\"")?;
|
|
c.write_line("\"b\"")?;
|
|
c.write_line("OK")?;
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"first\"")?;
|
|
c.write_line("OK")?;
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"second\"")?;
|
|
c.write_line("OK")?;
|
|
Ok(())
|
|
})
|
|
});
|
|
let (server, archive) = seed_then_run(seed, second);
|
|
let _ = run_against(&server, &archive).expect("seed");
|
|
let summary = run_against(&server, &archive).expect("second");
|
|
let (_, c) = &summary.per_type[0];
|
|
assert_eq!(c.created, 1);
|
|
assert_eq!(c.skipped, 1);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn second_run_is_convergent() {
|
|
let s1: Script = Box::new(|conn| {
|
|
auth_then(conn, |c| {
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"only\" ACTIVE")?;
|
|
c.write_line("OK")?;
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"keep;\"")?;
|
|
c.write_line("OK")?;
|
|
Ok(())
|
|
})
|
|
});
|
|
let s2: Script = Box::new(|conn| {
|
|
auth_then(conn, |c| {
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"only\" ACTIVE")?;
|
|
c.write_line("OK")?;
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"keep;\"")?;
|
|
c.write_line("OK")?;
|
|
Ok(())
|
|
})
|
|
});
|
|
let (server, archive) = seed_then_run(s1, s2);
|
|
let _ = run_against(&server, &archive).expect("first");
|
|
let summary = run_against(&server, &archive).expect("second");
|
|
let (_, c) = &summary.per_type[0];
|
|
assert_eq!(c.created, 0);
|
|
assert_eq!(c.fetched, 0);
|
|
assert_eq!(c.deleted, 0);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn transient_no_on_getscript_retries_then_succeeds() {
|
|
let archive = tempfile("transient");
|
|
let server = MockSieveServer::start(|conn| {
|
|
auth_then(conn, |c| {
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"a\"")?;
|
|
c.write_line("OK")?;
|
|
|
|
let _ = c.read_line()?;
|
|
c.write_line("NO \"try again later\"")?;
|
|
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"keep;\"")?;
|
|
c.write_line("OK")?;
|
|
Ok(())
|
|
})
|
|
});
|
|
let common = CommonConfig {
|
|
archive: archive.to_path_buf(),
|
|
threads: 1,
|
|
dry_run: false,
|
|
max_retries: 3,
|
|
allow_invalid_certs: false,
|
|
logger: Logger::from_flags(true, 0),
|
|
};
|
|
let config = ManageSieveImportConfig {
|
|
url: server.url(),
|
|
auth: ManageSieveAuth::Basic {
|
|
user: "alice".to_owned(),
|
|
password: "p@ss".to_owned(),
|
|
},
|
|
allow_cleartext: true,
|
|
allow_source_change: false,
|
|
};
|
|
let summary = run(common, config).expect("import");
|
|
assert!(!summary.any_failed());
|
|
let conn = Connection::open(&archive).unwrap();
|
|
assert_eq!(count(&conn, "sieve_scripts"), 1);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn bye_mid_listscripts_reconnects_and_succeeds() {
|
|
let first: Script = Box::new(|conn| {
|
|
conn.write_capability("PLAIN", false)?;
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line()?;
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line()?;
|
|
conn.write_capability("PLAIN", false)?;
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line()?;
|
|
conn.write_line("BYE \"server restarting\"")?;
|
|
Ok(())
|
|
});
|
|
let second: Script = Box::new(|conn| {
|
|
conn.write_capability("PLAIN", false)?;
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line()?;
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line()?;
|
|
conn.write_capability("PLAIN", false)?;
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line()?;
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line();
|
|
conn.write_line("OK \"bye\"")?;
|
|
Ok(())
|
|
});
|
|
let server = MockSieveServer::start_scripts(vec![first, second]);
|
|
let archive = tempfile("bye_listscripts");
|
|
let common = CommonConfig {
|
|
archive: archive.clone(),
|
|
threads: 1,
|
|
dry_run: false,
|
|
max_retries: 3,
|
|
allow_invalid_certs: false,
|
|
logger: Logger::from_flags(true, 0),
|
|
};
|
|
let config = ManageSieveImportConfig {
|
|
url: server.url(),
|
|
auth: ManageSieveAuth::Basic {
|
|
user: "alice".to_owned(),
|
|
password: "p@ss".to_owned(),
|
|
},
|
|
allow_cleartext: true,
|
|
allow_source_change: false,
|
|
};
|
|
let summary = run(common, config).expect("import");
|
|
assert!(!summary.any_failed(), "{summary:?}");
|
|
let conn = Connection::open(&archive).unwrap();
|
|
assert_eq!(count(&conn, "sieve_scripts"), 0);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn bye_mid_getscript_reconnects_and_completes_remaining_scripts() {
|
|
let first: Script = Box::new(|conn| {
|
|
conn.write_capability("PLAIN", false)?;
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line()?;
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line()?;
|
|
conn.write_capability("PLAIN", false)?;
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line()?;
|
|
conn.write_line("\"a\"")?;
|
|
conn.write_line("\"b\"")?;
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line()?;
|
|
conn.write_line("\"keep;\"")?;
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line()?;
|
|
conn.write_line("BYE \"server restarting\"")?;
|
|
Ok(())
|
|
});
|
|
let second: Script = Box::new(|conn| {
|
|
conn.write_capability("PLAIN", false)?;
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line()?;
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line()?;
|
|
conn.write_capability("PLAIN", false)?;
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line()?;
|
|
conn.write_line("\"keep;\"")?;
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line();
|
|
conn.write_line("OK \"bye\"")?;
|
|
Ok(())
|
|
});
|
|
let server = MockSieveServer::start_scripts(vec![first, second]);
|
|
let archive = tempfile("bye_getscript");
|
|
let common = CommonConfig {
|
|
archive: archive.clone(),
|
|
threads: 1,
|
|
dry_run: false,
|
|
max_retries: 3,
|
|
allow_invalid_certs: false,
|
|
logger: Logger::from_flags(true, 0),
|
|
};
|
|
let config = ManageSieveImportConfig {
|
|
url: server.url(),
|
|
auth: ManageSieveAuth::Basic {
|
|
user: "alice".to_owned(),
|
|
password: "p@ss".to_owned(),
|
|
},
|
|
allow_cleartext: true,
|
|
allow_source_change: false,
|
|
};
|
|
let summary = run(common, config).expect("import");
|
|
assert!(!summary.any_failed(), "{summary:?}");
|
|
let conn = Connection::open(&archive).unwrap();
|
|
assert_eq!(count(&conn, "sieve_scripts"), 2);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn post_auth_unsolicited_capability_is_consumed() {
|
|
let server = MockSieveServer::start(|conn| {
|
|
conn.write_capability("PLAIN", false)?;
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line()?;
|
|
conn.write_capability("PLAIN", false)?;
|
|
conn.write_line("OK")?;
|
|
let cmd = conn.read_line()?;
|
|
assert!(
|
|
cmd.starts_with("LISTSCRIPTS"),
|
|
"expected LISTSCRIPTS directly (no CAPABILITY refresh), got {cmd}"
|
|
);
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line();
|
|
conn.write_line("OK \"bye\"")?;
|
|
Ok(())
|
|
});
|
|
let archive = tempfile("post_auth_caps");
|
|
let summary = run_basic(&server, &archive).expect("import");
|
|
assert!(!summary.any_failed());
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn dry_run_does_not_mutate_archive() {
|
|
let server = MockSieveServer::start(|conn| {
|
|
auth_then(conn, |c| {
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"a\"")?;
|
|
c.write_line("OK")?;
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"keep;\"")?;
|
|
c.write_line("OK")?;
|
|
Ok(())
|
|
})
|
|
});
|
|
let archive = tempfile("dryrun");
|
|
let common = CommonConfig {
|
|
archive: archive.to_path_buf(),
|
|
threads: 1,
|
|
dry_run: true,
|
|
max_retries: 1,
|
|
allow_invalid_certs: false,
|
|
logger: Logger::from_flags(true, 0),
|
|
};
|
|
let config = ManageSieveImportConfig {
|
|
url: server.url(),
|
|
auth: ManageSieveAuth::Basic {
|
|
user: "alice".to_owned(),
|
|
password: "p@ss".to_owned(),
|
|
},
|
|
allow_cleartext: true,
|
|
allow_source_change: false,
|
|
};
|
|
let summary = run(common, config).expect("dryrun");
|
|
let (_, c) = &summary.per_type[0];
|
|
assert_eq!(c.created, 1);
|
|
let conn = Connection::open(&archive).unwrap();
|
|
assert_eq!(count(&conn, "sieve_scripts"), 0, "dry-run must not write");
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn oauthbearer_authenticates_end_to_end_when_advertised() {
|
|
let server = MockSieveServer::start(|conn| {
|
|
conn.write_capability("OAUTHBEARER", false)?;
|
|
conn.write_line("OK")?;
|
|
let cmd = conn.read_line()?;
|
|
assert!(
|
|
cmd.starts_with("AUTHENTICATE \"OAUTHBEARER\""),
|
|
"expected OAUTHBEARER, got {cmd}"
|
|
);
|
|
conn.write_line("OK \"hi\"")?;
|
|
let _ = conn.read_line();
|
|
conn.write_capability("OAUTHBEARER", false)?;
|
|
conn.write_line("OK")?;
|
|
let cmd = conn.read_line()?;
|
|
assert!(cmd.starts_with("LISTSCRIPTS"), "got {cmd}");
|
|
conn.write_line("OK")?;
|
|
let _ = conn.read_line();
|
|
conn.write_line("OK \"bye\"")?;
|
|
Ok(())
|
|
});
|
|
let archive = tempfile("oauthbearer");
|
|
let common = CommonConfig {
|
|
archive: archive.to_path_buf(),
|
|
threads: 1,
|
|
dry_run: false,
|
|
max_retries: 1,
|
|
allow_invalid_certs: false,
|
|
logger: Logger::from_flags(true, 0),
|
|
};
|
|
let config = ManageSieveImportConfig {
|
|
url: server.url(),
|
|
auth: ManageSieveAuth::Bearer {
|
|
user: "[email protected]".to_owned(),
|
|
token: "tok-xyz".to_owned(),
|
|
},
|
|
allow_cleartext: true,
|
|
allow_source_change: false,
|
|
};
|
|
let summary = run(common, config).expect("import");
|
|
assert!(!summary.any_failed(), "{summary:?}");
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn plain_then_login_both_refused_aborts_run_with_combined_message() {
|
|
let server = MockSieveServer::start(|conn| {
|
|
conn.write_capability("PLAIN LOGIN", false)?;
|
|
conn.write_line("OK")?;
|
|
let cmd = conn.read_line()?;
|
|
assert!(cmd.starts_with("AUTHENTICATE \"PLAIN\""), "{cmd}");
|
|
conn.write_line("NO \"plain refused\"")?;
|
|
let cmd = conn.read_line()?;
|
|
assert!(cmd.starts_with("AUTHENTICATE \"LOGIN\""), "{cmd}");
|
|
|
|
conn.write_line("\"VXNlcm5hbWU6\"")?;
|
|
let _ = conn.read_line()?;
|
|
|
|
conn.write_line("\"UGFzc3dvcmQ6\"")?;
|
|
let _ = conn.read_line()?;
|
|
conn.write_line("NO \"login refused too\"")?;
|
|
Ok(())
|
|
});
|
|
let archive = tempfile("plain_and_login_refused");
|
|
let err = run_basic(&server, &archive).unwrap_err();
|
|
match err {
|
|
vandelay::error::Error::Connection(msg) => {
|
|
assert!(
|
|
msg.contains("LOGIN") && msg.contains("PLAIN"),
|
|
"expected combined LOGIN/PLAIN error text, got {msg}"
|
|
);
|
|
}
|
|
other => panic!("expected Connection, got {other:?}"),
|
|
}
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn three_scripts_with_middle_active_lands_the_middle_active() {
|
|
let server = MockSieveServer::start(|conn| {
|
|
auth_then(conn, |c| {
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"first\"")?;
|
|
c.write_line("\"middle\" ACTIVE")?;
|
|
c.write_line("\"third\"")?;
|
|
c.write_line("OK")?;
|
|
for body in &["one", "two", "three"] {
|
|
let _ = c.read_line()?;
|
|
c.write_line(&format!("\"{body}\""))?;
|
|
c.write_line("OK")?;
|
|
}
|
|
Ok(())
|
|
})
|
|
});
|
|
let archive = tempfile("middle_active");
|
|
let summary = run_basic(&server, &archive).expect("import");
|
|
assert!(!summary.any_failed(), "{summary:?}");
|
|
let conn = Connection::open(&archive).unwrap();
|
|
assert_eq!(count(&conn, "sieve_scripts"), 3);
|
|
let active: String = conn
|
|
.query_row(
|
|
"SELECT name FROM sieve_scripts WHERE is_active = 1",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(active, "middle");
|
|
let total_active: i64 = conn
|
|
.query_row(
|
|
"SELECT count(*) FROM sieve_scripts WHERE is_active = 1",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(total_active, 1);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn transient_no_exhausting_max_retries_skips_script_and_followup_picks_it_up() {
|
|
let archive = tempfile("retry_exhaustion");
|
|
let first = MockSieveServer::start(|conn| {
|
|
auth_then(conn, |c| {
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"flaky\"")?;
|
|
c.write_line("OK")?;
|
|
|
|
for _ in 0..3 {
|
|
let _ = c.read_line()?;
|
|
c.write_line("NO \"try again later\"")?;
|
|
}
|
|
Ok(())
|
|
})
|
|
});
|
|
let common = |archive: &Path| CommonConfig {
|
|
archive: archive.to_path_buf(),
|
|
threads: 1,
|
|
dry_run: false,
|
|
max_retries: 2,
|
|
allow_invalid_certs: false,
|
|
logger: Logger::from_flags(true, 0),
|
|
};
|
|
let cfg = |url: String| ManageSieveImportConfig {
|
|
url,
|
|
auth: ManageSieveAuth::Basic {
|
|
user: "alice".to_owned(),
|
|
password: "p@ss".to_owned(),
|
|
},
|
|
allow_cleartext: true,
|
|
allow_source_change: false,
|
|
};
|
|
let summary = run(common(&archive), cfg(first.url())).expect("first run");
|
|
assert!(
|
|
summary.any_failed(),
|
|
"expected per-script failure to count toward exit 5: {summary:?}"
|
|
);
|
|
let (_, c) = &summary.per_type[0];
|
|
assert_eq!(c.failed, 1, "exactly one failure recorded");
|
|
let conn = Connection::open(&archive).unwrap();
|
|
assert_eq!(
|
|
count(&conn, "sieve_scripts"),
|
|
0,
|
|
"the script must NOT have landed locally"
|
|
);
|
|
drop(conn);
|
|
|
|
let second = MockSieveServer::start(|conn| {
|
|
auth_then(conn, |c| {
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"flaky\"")?;
|
|
c.write_line("OK")?;
|
|
let _ = c.read_line()?;
|
|
c.write_line("\"keep;\"")?;
|
|
c.write_line("OK")?;
|
|
Ok(())
|
|
})
|
|
});
|
|
let mut cfg2 = cfg(second.url());
|
|
cfg2.allow_source_change = true;
|
|
let summary2 = run(common(&archive), cfg2).expect("second run");
|
|
assert!(!summary2.any_failed(), "{summary2:?}");
|
|
let (_, c2) = &summary2.per_type[0];
|
|
assert_eq!(c2.created, 1);
|
|
let conn = Connection::open(&archive).unwrap();
|
|
assert_eq!(count(&conn, "sieve_scripts"), 1);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|