The crate, binary, library path and every name the tool writes or reads become inbuxa-migrate: the CLI name and about text, the HTTP user agent, the credential variables (INBUXA_MIGRATE_PASSWORD, _TOKEN, _EWS_CLIENT_SECRET and _GRAPH_TOKEN, with no fallback to the old names), thread names, synthesized UIDs and calendar addresses (urn:x-inbuxa-migrate:attendee:), and the Maildir file suffix used by the export script. This is a new tool, so archives written by Vandelay are not read back. The version moves to the date scheme the release tags use (2026.9.30), and the package metadata points at the inbuxa-migrate repository. The cargo-dist and MSI metadata goes with the release tooling it served. Modified files carry an added copyright line; the original notices stay.
371 lines
12 KiB
Rust
371 lines
12 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
* SPDX-FileCopyrightText: 2026 John Coffey <[email protected]>
|
|
*
|
|
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
*/
|
|
|
|
mod integration;
|
|
mod seeder;
|
|
|
|
use std::collections::HashSet;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use integration::stalwart::shared as shared_stalwart;
|
|
use rusqlite::Connection;
|
|
|
|
fn base_url() -> &'static str {
|
|
shared_stalwart().base_url()
|
|
}
|
|
use inbuxa_migrate::jmap::account::AccountSelector;
|
|
use inbuxa_migrate::jmap::http::Auth;
|
|
use inbuxa_migrate::logging::Logger;
|
|
use inbuxa_migrate::sync::import_dav::{DavAuth, DavImportConfig, DavKindArg};
|
|
use inbuxa_migrate::sync::{self, CommonConfig, ConnectConfig, ExportConfig, ImportConfig};
|
|
|
|
fn tmp_archive(tag: &str) -> PathBuf {
|
|
let mut p = std::env::temp_dir();
|
|
p.push(format!(
|
|
"inbuxa-migrate-{tag}-{}-{}.sqlite",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
));
|
|
let _ = std::fs::remove_file(&p);
|
|
p
|
|
}
|
|
|
|
fn common(archive: &Path) -> CommonConfig {
|
|
CommonConfig {
|
|
archive: archive.to_path_buf(),
|
|
threads: 4,
|
|
dry_run: false,
|
|
max_retries: 5,
|
|
allow_invalid_certs: true,
|
|
logger: Logger::from_flags(false, 0),
|
|
}
|
|
}
|
|
|
|
fn caldav_cfg(base_url: &str, domain: &str, localpart: &str) -> DavImportConfig {
|
|
DavImportConfig {
|
|
kind: DavKindArg::Caldav,
|
|
url: base_url.to_owned(),
|
|
auth: DavAuth::Basic {
|
|
user: format!("{localpart}@{domain}"),
|
|
password: seeder::USER_PASSWORD.to_owned(),
|
|
},
|
|
allow_cleartext: false,
|
|
dav_connections: 4,
|
|
multiget_batch: 50,
|
|
allow_source_change: false,
|
|
}
|
|
}
|
|
|
|
fn count(conn: &Connection, table: &str) -> i64 {
|
|
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
|
|
.unwrap()
|
|
}
|
|
|
|
fn jmap_basic(localpart: &str, domain: &str) -> Auth {
|
|
Auth::Basic {
|
|
user: format!("{localpart}@{domain}"),
|
|
password: seeder::USER_PASSWORD.to_owned(),
|
|
}
|
|
}
|
|
|
|
fn export_cfg(base_url: &str, domain: &str, localpart: &str, account_id: &str) -> ExportConfig {
|
|
ExportConfig {
|
|
connect: ConnectConfig {
|
|
url: base_url.to_owned(),
|
|
auth: jmap_basic(localpart, domain),
|
|
account: AccountSelector::Id(account_id.to_owned()),
|
|
},
|
|
objects: None,
|
|
prune: false,
|
|
yes: true,
|
|
}
|
|
}
|
|
|
|
fn jmap_import_cfg(
|
|
base_url: &str,
|
|
domain: &str,
|
|
localpart: &str,
|
|
account_id: &str,
|
|
) -> ImportConfig {
|
|
ImportConfig {
|
|
connect: ConnectConfig {
|
|
url: base_url.to_owned(),
|
|
auth: jmap_basic(localpart, domain),
|
|
account: AccountSelector::Id(account_id.to_owned()),
|
|
},
|
|
objects: None,
|
|
allow_source_change: false,
|
|
}
|
|
}
|
|
|
|
fn event_uids(conn: &Connection) -> HashSet<String> {
|
|
let mut stmt = conn
|
|
.prepare("SELECT json_extract(data, '$.uid') FROM calendar_events")
|
|
.unwrap();
|
|
let rows = stmt
|
|
.query_map([], |r| r.get::<_, Option<String>>(0))
|
|
.unwrap();
|
|
let mut out = HashSet::new();
|
|
for r in rows {
|
|
if let Some(u) = r.unwrap() {
|
|
out.insert(u);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires Docker"]
|
|
fn caldav_import_test1_yields_calendars_and_events() {
|
|
let fx = seeder::provision(base_url()).expect("provision");
|
|
let acc = fx.account("test1").expect("test1");
|
|
let archive = tmp_archive("caldav");
|
|
|
|
assert_eq!(fx.domain, seeder::DOMAIN);
|
|
assert!(!fx.domain_id.is_empty(), "domain id resolved");
|
|
assert_eq!(
|
|
fx.admin_login,
|
|
(
|
|
seeder::ADMIN_USER.to_owned(),
|
|
seeder::ADMIN_PASSWORD.to_owned()
|
|
)
|
|
);
|
|
assert!(!acc.admin_role, "test1 is a regular user, not admin");
|
|
let seeded = acc.seeded.as_ref().expect("test1 seed stats");
|
|
assert!(
|
|
seeded.calendars > 0,
|
|
"test1 layout requests an extra calendar"
|
|
);
|
|
assert!(seeded.events > 0, "test1 layout seeds events");
|
|
assert!(
|
|
seeded.mailboxes_created > 0,
|
|
"test1 has a seeded mailbox tree"
|
|
);
|
|
assert!(seeded.emails > 0, "test1 has seeded emails");
|
|
assert!(seeded.address_books > 0, "test1 has seeded address books");
|
|
assert!(seeded.contacts > 0, "test1 has seeded contacts");
|
|
assert!(seeded.file_nodes > 0, "test1 has seeded file nodes");
|
|
assert!(seeded.identity, "test1 has a custom identity");
|
|
assert_eq!(
|
|
seeded.sieve_active,
|
|
Some(true),
|
|
"test1 has an active sieve script"
|
|
);
|
|
|
|
let summary = sync::import_dav::run(
|
|
common(&archive),
|
|
caldav_cfg(&fx.base_url, &fx.domain, &acc.localpart),
|
|
)
|
|
.expect("import");
|
|
assert!(!summary.any_failed(), "import had failures: {summary:?}");
|
|
|
|
let conn = Connection::open(&archive).unwrap();
|
|
let calendars = count(&conn, "calendars") as usize;
|
|
let events = count(&conn, "calendar_events") as usize;
|
|
assert!(
|
|
calendars >= seeded.calendars,
|
|
"imported calendars ({calendars}) covers seeded layout ({})",
|
|
seeded.calendars,
|
|
);
|
|
assert!(
|
|
events >= seeded.events,
|
|
"imported events ({events}) covers seeded layout ({})",
|
|
seeded.events,
|
|
);
|
|
|
|
seeder::teardown(base_url()).expect("teardown");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires Docker"]
|
|
fn caldav_import_covers_all_three_sync_in_accounts() {
|
|
let fx = seeder::provision(base_url()).expect("provision");
|
|
for localpart in ["test1", "test2", "test3"] {
|
|
let acc = fx.account(localpart).expect(localpart);
|
|
let archive = tmp_archive(&format!("caldav-all-{localpart}"));
|
|
let summary = sync::import_dav::run(
|
|
common(&archive),
|
|
caldav_cfg(&fx.base_url, &fx.domain, &acc.localpart),
|
|
)
|
|
.unwrap_or_else(|e| panic!("import {localpart} failed: {e:?}"));
|
|
assert!(
|
|
!summary.any_failed(),
|
|
"{localpart} import had failures: {summary:?}"
|
|
);
|
|
let conn = Connection::open(&archive).unwrap();
|
|
let calendars = count(&conn, "calendars") as usize;
|
|
assert!(
|
|
calendars >= 1,
|
|
"{localpart} has at least one calendar (Stalwart default)"
|
|
);
|
|
}
|
|
seeder::teardown(base_url()).expect("teardown");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires Docker"]
|
|
fn caldav_import_with_tiny_multiget_batch_converges_identically() {
|
|
let fx = seeder::provision(base_url()).expect("provision");
|
|
let acc = fx.account("test1").expect("test1");
|
|
|
|
let archive_big = tmp_archive("caldav-batch-big");
|
|
sync::import_dav::run(
|
|
common(&archive_big),
|
|
caldav_cfg(&fx.base_url, &fx.domain, &acc.localpart),
|
|
)
|
|
.expect("import big batch");
|
|
let conn = Connection::open(&archive_big).unwrap();
|
|
let big = count(&conn, "calendar_events");
|
|
drop(conn);
|
|
|
|
let archive_small = tmp_archive("caldav-batch-small");
|
|
let mut cfg = caldav_cfg(&fx.base_url, &fx.domain, &acc.localpart);
|
|
cfg.multiget_batch = 1;
|
|
sync::import_dav::run(common(&archive_small), cfg).expect("import small batch");
|
|
let conn = Connection::open(&archive_small).unwrap();
|
|
let small = count(&conn, "calendar_events");
|
|
assert_eq!(big, small, "batch size must not affect imported count");
|
|
|
|
seeder::teardown(base_url()).expect("teardown");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires Docker"]
|
|
fn caldav_round_trip_via_jmap_export_converges() {
|
|
let fx = seeder::provision(base_url()).expect("provision");
|
|
let src = fx.account("test1").expect("test1");
|
|
let tgt = fx.account("test4").expect("test4");
|
|
let archive = tmp_archive("caldav-rt");
|
|
|
|
let imp = sync::import_dav::run(
|
|
common(&archive),
|
|
caldav_cfg(&fx.base_url, &fx.domain, &src.localpart),
|
|
)
|
|
.expect("dav import");
|
|
assert!(!imp.any_failed(), "dav import had failures: {imp:?}");
|
|
|
|
let conn = Connection::open(&archive).unwrap();
|
|
let local_events = count(&conn, "calendar_events");
|
|
assert!(local_events > 0, "DAV import landed events in the archive");
|
|
drop(conn);
|
|
|
|
let e1 = sync::export::run(
|
|
common(&archive),
|
|
export_cfg(&fx.base_url, &fx.domain, &tgt.localpart, &tgt.account_id),
|
|
)
|
|
.expect("export 1");
|
|
assert!(!e1.any_failed(), "export 1 had failures: {e1:?}");
|
|
let created_events_1: u64 = e1
|
|
.per_type
|
|
.iter()
|
|
.filter(|(t, _)| *t == "CalendarEvent")
|
|
.map(|(_, c)| c.created)
|
|
.sum();
|
|
assert!(
|
|
created_events_1 > 0,
|
|
"first export created CalendarEvent rows on the target: {e1:?}"
|
|
);
|
|
|
|
let e2 = sync::export::run(
|
|
common(&archive),
|
|
export_cfg(&fx.base_url, &fx.domain, &tgt.localpart, &tgt.account_id),
|
|
)
|
|
.expect("export 2");
|
|
let created_2: u64 = e2.per_type.iter().map(|(_, c)| c.created).sum();
|
|
assert_eq!(
|
|
created_2, 0,
|
|
"second export must be convergent (no new creates): {e2:?}"
|
|
);
|
|
let skipped_2: u64 = e2.per_type.iter().map(|(_, c)| c.skipped).sum();
|
|
assert!(
|
|
skipped_2 > 0,
|
|
"second export matched existing target objects: {e2:?}"
|
|
);
|
|
|
|
seeder::teardown(base_url()).expect("teardown");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires Docker"]
|
|
fn caldav_cross_protocol_parity_with_jmap_matches_uid_set() {
|
|
let fx = seeder::provision(base_url()).expect("provision");
|
|
let src = fx.account("test1").expect("test1");
|
|
|
|
let archive_jmap = tmp_archive("caldav-parity-jmap");
|
|
let s_jmap = sync::import_jmap::run(
|
|
common(&archive_jmap),
|
|
jmap_import_cfg(&fx.base_url, &fx.domain, &src.localpart, &src.account_id),
|
|
)
|
|
.expect("jmap import");
|
|
assert!(!s_jmap.any_failed(), "jmap import failures: {s_jmap:?}");
|
|
|
|
let archive_dav = tmp_archive("caldav-parity-dav");
|
|
let s_dav = sync::import_dav::run(
|
|
common(&archive_dav),
|
|
caldav_cfg(&fx.base_url, &fx.domain, &src.localpart),
|
|
)
|
|
.expect("dav import");
|
|
assert!(!s_dav.any_failed(), "dav import failures: {s_dav:?}");
|
|
|
|
let conn_jmap = Connection::open(&archive_jmap).unwrap();
|
|
let uids_jmap = event_uids(&conn_jmap);
|
|
drop(conn_jmap);
|
|
|
|
let conn_dav = Connection::open(&archive_dav).unwrap();
|
|
let uids_dav = event_uids(&conn_dav);
|
|
drop(conn_dav);
|
|
|
|
assert!(
|
|
!uids_jmap.is_empty(),
|
|
"jmap import should yield at least one event uid"
|
|
);
|
|
assert!(
|
|
!uids_dav.is_empty(),
|
|
"dav import should yield at least one event uid"
|
|
);
|
|
let only_in_jmap: HashSet<_> = uids_jmap.difference(&uids_dav).collect();
|
|
let only_in_dav: HashSet<_> = uids_dav.difference(&uids_jmap).collect();
|
|
assert!(
|
|
only_in_jmap.is_empty() && only_in_dav.is_empty(),
|
|
"uid sets must match across protocols: only-in-jmap={only_in_jmap:?} only-in-dav={only_in_dav:?}"
|
|
);
|
|
|
|
seeder::teardown(base_url()).expect("teardown");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires Docker"]
|
|
fn caldav_import_is_idempotent_on_second_run() {
|
|
let fx = seeder::provision(base_url()).expect("provision");
|
|
let acc = fx.account("test1").expect("test1");
|
|
assert!(!acc.admin_role);
|
|
let archive = tmp_archive("caldav-idem");
|
|
|
|
sync::import_dav::run(
|
|
common(&archive),
|
|
caldav_cfg(&fx.base_url, &fx.domain, &acc.localpart),
|
|
)
|
|
.expect("import 1");
|
|
let conn1 = Connection::open(&archive).unwrap();
|
|
let n1 = count(&conn1, "calendar_events");
|
|
drop(conn1);
|
|
|
|
sync::import_dav::run(
|
|
common(&archive),
|
|
caldav_cfg(&fx.base_url, &fx.domain, &acc.localpart),
|
|
)
|
|
.expect("import 2");
|
|
let conn2 = Connection::open(&archive).unwrap();
|
|
let n2 = count(&conn2, "calendar_events");
|
|
assert_eq!(n1, n2, "second import does not duplicate events");
|
|
|
|
seeder::teardown(base_url()).expect("teardown");
|
|
}
|