Fix sync issues

This commit is contained in:
Maurus Decimus
2026-05-29 19:49:37 +02:00
parent 576073f8c9
commit 0dfbaf8096
9 changed files with 614 additions and 49 deletions
+187
View File
@@ -66,6 +66,66 @@ fn discovery_uses_url_as_homeset_when_collection_present() {
assert!(disc.collections[0].props.is_calendar);
}
#[test]
fn discovery_resolves_per_user_principal_even_when_url_lists_collections() {
let mut server = mockito::Server::new();
let url = server.url();
let collections_body = format!(
r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>{url}/dav/cal/[email protected]/default/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
<d:displayname>Default</d:displayname>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#
);
let _listing = server
.mock("PROPFIND", "/dav/cal/")
.match_header("depth", "1")
.with_status(207)
.with_header("content-type", "application/xml; charset=utf-8")
.with_body(&collections_body)
.create();
let principal_body = format!(
r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>{url}/dav/cal/</d:href>
<d:propstat>
<d:prop>
<d:current-user-principal><d:href>{url}/dav/principals/[email protected]/</d:href></d:current-user-principal>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#
);
let _principal = server
.mock("PROPFIND", "/dav/cal/")
.match_header("depth", "0")
.with_status(207)
.with_header("content-type", "application/xml; charset=utf-8")
.with_body(&principal_body)
.create();
let c = client(0);
let disc = discover(&c, DavKind::Caldav, &format!("{url}/dav/cal/")).expect("discover");
assert_eq!(disc.collections.len(), 1);
assert_eq!(
disc.principal_url.as_deref(),
Some(format!("{url}/dav/principals/[email protected]/").as_str()),
"account identity must be the per-user principal, not the shared base DAV root"
);
}
#[test]
fn discovery_falls_through_principal_to_home_set() {
let mut server = mockito::Server::new();
@@ -1129,6 +1189,133 @@ fn dry_run_writes_nothing_but_emits_per_collection_counts() {
assert_eq!(event_counts.1.created, 2);
}
#[test]
fn dav_source_change_protection_fires_across_users_on_same_root() {
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use std::path::PathBuf;
use vandelay::logging::Logger;
use vandelay::sync::CommonConfig;
use vandelay::sync::import_dav::{DavAuth, DavImportConfig, DavKindArg, run};
let mut server = mockito::Server::new();
let url = server.url();
let listing = format!(
r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>{url}/dav/cal/shared/default/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
<d:displayname>Default</d:displayname>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#
);
let _listing = server
.mock("PROPFIND", "/dav/cal/")
.match_header("depth", "1")
.with_status(207)
.with_header("content-type", "application/xml; charset=utf-8")
.with_body(&listing)
.create();
let empty_items = format!(
r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>{url}/dav/cal/shared/default/</d:href>
<d:propstat>
<d:prop><d:resourcetype><d:collection/><c:calendar/></d:resourcetype></d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#
);
let _items = server
.mock("PROPFIND", "/dav/cal/shared/default/")
.with_status(207)
.with_header("content-type", "application/xml; charset=utf-8")
.with_body(&empty_items)
.create();
let auth_a = format!("Basic {}", STANDARD.encode("[email protected]:passA"));
let auth_b = format!("Basic {}", STANDARD.encode("[email protected]:passB"));
let principal = |who: &str| {
format!(
r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>{url}/dav/cal/</d:href>
<d:propstat>
<d:prop><d:current-user-principal><d:href>{url}/dav/principals/{who}/</d:href></d:current-user-principal></d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#
)
};
let _pa = server
.mock("PROPFIND", "/dav/cal/")
.match_header("depth", "0")
.match_header("authorization", auth_a.as_str())
.with_status(207)
.with_header("content-type", "application/xml; charset=utf-8")
.with_body(principal("[email protected]"))
.create();
let _pb = server
.mock("PROPFIND", "/dav/cal/")
.match_header("depth", "0")
.match_header("authorization", auth_b.as_str())
.with_status(207)
.with_header("content-type", "application/xml; charset=utf-8")
.with_body(principal("[email protected]"))
.create();
let archive: PathBuf = std::env::temp_dir().join(format!(
"vandelay-dav-srcchange-{}-{}.sqlite",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_file(&archive);
let common = |archive: &PathBuf| CommonConfig {
archive: archive.clone(),
threads: 1,
dry_run: false,
max_retries: 0,
allow_invalid_certs: true,
logger: Logger::from_flags(false, 0),
};
let config = |user: &str, pass: &str| DavImportConfig {
kind: DavKindArg::Caldav,
url: format!("{url}/dav/cal/"),
auth: DavAuth::Basic {
user: user.to_owned(),
password: pass.to_owned(),
},
allow_cleartext: true,
dav_connections: 1,
multiget_batch: 50,
allow_source_change: false,
};
run(common(&archive), config("[email protected]", "passA")).expect("user A import ok");
let err = run(common(&archive), config("[email protected]", "passB")).unwrap_err();
let _ = std::fs::remove_file(&archive);
assert!(
matches!(err, vandelay::error::Error::SourceChange(_)),
"importing a different user into the same archive must trigger source-change protection; got {err:?}"
);
}
#[test]
fn source_change_protection_rejects_different_session_url_same_account() {
use rusqlite::Connection;
+110 -1
View File
@@ -511,6 +511,43 @@ fn control_script_one_folder(uidvalidity: u32, uidnext: u32, uids: &'static [u32
})
}
fn control_script_present_flags(
uidvalidity: u32,
uidnext: u32,
uids: &'static [u32],
flags_reply: &'static [(u32, &'static str)],
) -> Script {
Box::new(move |conn: &mut MockConn| -> std::io::Result<()> {
auth_preamble(conn, "IMAP4rev2 LITERAL+ AUTH=PLAIN")?;
let (tag, cmd) = conn.read_command()?;
assert_eq!(cmd, "LIST \"\" \"*\"");
conn.write_line("* LIST () \"/\" \"INBOX\"")?;
conn.write_line(&format!("{tag} OK LIST done"))?;
let (tag, cmd) = conn.read_command()?;
assert_eq!(cmd, "LSUB \"\" \"*\"");
conn.write_line(&format!("{tag} OK LSUB done"))?;
let (tag, cmd) = conn.read_command()?;
assert_eq!(cmd, "SELECT \"INBOX\"");
write_select(conn, &tag, uidvalidity, uidnext, uids.len() as u32)?;
let (tag, cmd) = conn.read_command()?;
assert_eq!(cmd, "UID SEARCH ALL");
let uid_strs: Vec<String> = uids.iter().map(|u| u.to_string()).collect();
conn.write_line(&format!("* SEARCH {}", uid_strs.join(" ")))?;
conn.write_line(&format!("{tag} OK SEARCH done"))?;
let (tag, cmd) = conn.read_command()?;
assert!(
cmd.starts_with("UID FETCH") && cmd.contains("(UID FLAGS)") && !cmd.contains("BODY"),
"expected body-less flags fetch on the present set, got {cmd}"
);
for (uid, flags) in flags_reply {
conn.write_line(&format!("* {uid} FETCH (UID {uid} FLAGS ({flags}))"))?;
}
conn.write_line(&format!("{tag} OK FETCH done"))?;
drain_until_close(conn);
Ok(())
})
}
#[test]
fn coordinator_imports_one_folder_one_message() {
let server = MockImap::start_scripts(vec![
@@ -755,7 +792,7 @@ fn coordinator_present_run_is_convergent() {
let mut scripts: Vec<Script> = Vec::new();
scripts.extend(single_inbox_scripts(100, 2, MSG_BODY));
scripts.push(control_script_one_folder(100, 2, &[1]));
scripts.push(control_script_present_flags(100, 2, &[1], &[(1, "\\Seen")]));
scripts.push(worker_idle_script("IMAP4rev2 LITERAL+ AUTH=PLAIN"));
let server = MockImap::start_scripts(scripts);
let archive = tempfile("converge");
@@ -768,6 +805,78 @@ fn coordinator_present_run_is_convergent() {
.unwrap();
assert_eq!(email.1.created, 0, "convergent run creates nothing");
assert_eq!(email.1.deleted, 0, "convergent run deletes nothing");
assert_eq!(email.1.updated, 0, "unchanged flags update nothing");
let dbc = Connection::open(&archive).unwrap();
assert_eq!(count(&dbc, "blobs"), 1, "no body re-fetched on a present-only run");
}
#[test]
fn coordinator_present_flag_change_updates_keywords() {
let mut scripts: Vec<Script> = Vec::new();
scripts.extend(single_inbox_scripts(100, 2, MSG_BODY));
scripts.push(control_script_present_flags(
100,
2,
&[1],
&[(1, "\\Seen \\Flagged")],
));
scripts.push(worker_idle_script("IMAP4rev2 LITERAL+ AUTH=PLAIN"));
let server = MockImap::start_scripts(scripts);
let archive = tempfile("flagupdate");
run_import(&server, "alice", archive.clone(), |_| {}).expect("first import");
let summary = run_import(&server, "alice", archive.clone(), |_| {}).expect("second import");
let email = summary
.per_type
.iter()
.find(|(k, _)| *k == "email")
.unwrap();
assert_eq!(email.1.updated, 1, "a changed flag set is counted as updated");
assert_eq!(email.1.created, 0, "present message is not re-created");
assert_eq!(email.1.fetched, 0, "no body fetched on a present-only run");
let dbc = Connection::open(&archive).unwrap();
assert_eq!(count(&dbc, "blobs"), 1, "body not re-fetched");
let kw: String = dbc
.query_row("SELECT keywords FROM emails LIMIT 1", [], |r| r.get(0))
.unwrap();
assert!(
kw.contains("$seen") && kw.contains("$flagged"),
"keywords should reflect the new \\Flagged: {kw}"
);
}
#[test]
fn coordinator_present_newly_deleted_is_left_intact() {
let mut scripts: Vec<Script> = Vec::new();
scripts.extend(single_inbox_scripts(100, 2, MSG_BODY));
scripts.push(control_script_present_flags(
100,
2,
&[1],
&[(1, "\\Seen \\Deleted")],
));
scripts.push(worker_idle_script("IMAP4rev2 LITERAL+ AUTH=PLAIN"));
let server = MockImap::start_scripts(scripts);
let archive = tempfile("presentdeleted");
run_import(&server, "alice", archive.clone(), |_| {}).expect("first import");
let summary = run_import(&server, "alice", archive.clone(), |_| {}).expect("second import");
let email = summary
.per_type
.iter()
.find(|(k, _)| *k == "email")
.unwrap();
assert_eq!(
email.1.updated, 0,
"a present message that newly gained \\Deleted is skipped, not updated"
);
let dbc = Connection::open(&archive).unwrap();
assert_eq!(count(&dbc, "emails"), 1, "the archived message is preserved");
let kw: String = dbc
.query_row("SELECT keywords FROM emails LIMIT 1", [], |r| r.get(0))
.unwrap();
assert!(
kw.contains("$seen") && !kw.contains("$deleted"),
"keywords left intact: {kw}"
);
}
#[test]
+121 -11
View File
@@ -1098,17 +1098,17 @@ fn export_calendar_creates_only_missing() {
}
#[test]
fn export_sieve_script_skips_matching_blob_content() {
fn export_sieve_script_matches_by_name_not_content() {
let mut server = mockito::Server::new();
let base = server.url();
let api = "/jmap/api";
let archive = tmp();
let local_script = b"require [\"fileinto\"];\nkeep;\n";
let new_script = b"require [\"reject\"];\nreject \"go away\";\n";
let keepall_local = b"require [\"fileinto\"];\nkeep;\n";
let reject_local = b"require [\"reject\"];\nreject \"go away\";\n";
{
let conn = db::init::open(&archive).unwrap();
let blob1 = db::blobs::intern_blob(&conn, local_script).unwrap();
let blob2 = db::blobs::intern_blob(&conn, new_script).unwrap();
let blob1 = db::blobs::intern_blob(&conn, keepall_local).unwrap();
let blob2 = db::blobs::intern_blob(&conn, reject_local).unwrap();
conn.execute(
"INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (1,'keepall',1,?1)",
rusqlite::params![blob1],
@@ -1132,16 +1132,16 @@ fn export_sieve_script_skips_matching_blob_content() {
.match_body(Matcher::Regex("SieveScript/get".into()))
.with_body(
json!({"methodResponses":[["SieveScript/get",{"accountId":"w","list":[
{"id":"S1","name":"already-there","isActive":false,"blobId":"BSRV"}
{"id":"S1","name":"keepall","isActive":false,"blobId":"BSRV"}
],"notFound":[]},"g"]]})
.to_string(),
)
.expect(1)
.create();
let _dl = server
let no_download = server
.mock("GET", Matcher::Regex("/jmap/dl/w/BSRV/.*".into()))
.with_body(local_script.as_slice())
.expect(1)
.with_body(b"unused".as_slice())
.expect(0)
.create();
let upload = server
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
@@ -1177,6 +1177,7 @@ fn export_sieve_script_skips_matching_blob_content() {
.expect("export");
upload.assert();
create.assert();
no_download.assert();
let counts = summary
.per_type
.iter()
@@ -1185,9 +1186,118 @@ fn export_sieve_script_skips_matching_blob_content() {
.expect("sieve counts");
assert_eq!(
counts.skipped, 1,
"matching-blob script is skipped regardless of name"
"name-matched script is skipped even though its content differs from the target"
);
assert_eq!(counts.created, 1, "differing-blob script is created");
assert_eq!(counts.created, 1, "the unmatched name is created");
assert_eq!(counts.failed, 0);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn export_sieve_scripts_identical_content_different_names_both_created() {
let mut server = mockito::Server::new();
let base = server.url();
let api = "/jmap/api";
let archive = tmp();
let shared = b"require [\"fileinto\"];\nfileinto \"Archive\";\n";
{
let conn = db::init::open(&archive).unwrap();
let blob = db::blobs::intern_blob(&conn, shared).unwrap();
conn.execute(
"INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (1,'duplicate-A',0,?1)",
rusqlite::params![blob],
)
.unwrap();
conn.execute(
"INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (2,'duplicate-B',0,?1)",
rusqlite::params![blob],
)
.unwrap();
let dup_count: i64 = conn
.query_row(
"SELECT count(DISTINCT blob_id) FROM sieve_scripts",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(dup_count, 1, "both scripts share one blob (byte-identical)");
}
let _root = server.mock("GET", "/").with_status(404).create();
let _wk = server
.mock("GET", "/.well-known/jmap")
.with_body(session_body_full(&base))
.expect_at_least(1)
.create();
let _g = server
.mock("POST", api)
.match_body(Matcher::Regex("SieveScript/get".into()))
.with_body(
json!({"methodResponses":[["SieveScript/get",
{"accountId":"w","list":[],"notFound":[]},"g"]]})
.to_string(),
)
.expect(1)
.create();
let upload = server
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
.with_body(json!({"blobId":"UPN"}).to_string())
.expect(1)
.create();
let create_a = server
.mock("POST", api)
.match_body(Matcher::AllOf(vec![
Matcher::Regex("SieveScript/set".into()),
Matcher::Regex("duplicate-A".into()),
]))
.with_body(
json!({"methodResponses":[["SieveScript/set",{"accountId":"w",
"created":{"c1":{"id":"S1"}}},"s"]]})
.to_string(),
)
.expect(1)
.create();
let create_b = server
.mock("POST", api)
.match_body(Matcher::AllOf(vec![
Matcher::Regex("SieveScript/set".into()),
Matcher::Regex("duplicate-B".into()),
]))
.with_body(
json!({"methodResponses":[["SieveScript/set",{"accountId":"w",
"created":{"c2":{"id":"S2"}}},"s"]]})
.to_string(),
)
.expect(1)
.create();
let _deactivate = server
.mock("POST", api)
.match_body(Matcher::Regex("onSuccessDeactivateScript".into()))
.with_body(
json!({"methodResponses":[["SieveScript/set",{"accountId":"w"},"a"]]}).to_string(),
)
.expect(1)
.create();
let summary = sync::export::run(
common(&archive),
export_cfg_objects(&base, vec![ObjectType::SieveScript]),
)
.expect("export");
upload.assert();
create_a.assert();
create_b.assert();
let counts = summary
.per_type
.iter()
.find(|(t, _)| *t == "SieveScript")
.map(|(_, c)| c.clone())
.expect("sieve counts");
assert_eq!(
counts.created, 2,
"two scripts with identical content but distinct names must both reach the target"
);
assert_eq!(counts.skipped, 0, "neither distinct name collapses onto the other");
assert_eq!(counts.failed, 0);
let _ = std::fs::remove_file(&archive);
}