1241 lines
40 KiB
Rust
1241 lines
40 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
*
|
|
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
*/
|
|
|
|
mod integration;
|
|
mod seeder;
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use integration::stalwart::shared as shared_stalwart;
|
|
use rusqlite::Connection;
|
|
use serde_json::{Map, Value, json};
|
|
use vandelay::jmap::account::{self, AccountSelector};
|
|
use vandelay::jmap::http::{Auth, HttpClient, RetryPolicy};
|
|
use vandelay::jmap::request::Request;
|
|
use vandelay::jmap::session::Session;
|
|
use vandelay::logging::Logger;
|
|
use vandelay::sync::{self, CommonConfig, ConnectConfig, ExportConfig, ImportConfig};
|
|
|
|
fn base_url() -> &'static str {
|
|
shared_stalwart().base_url()
|
|
}
|
|
|
|
fn tmp_archive(tag: &str) -> PathBuf {
|
|
let mut p = std::env::temp_dir();
|
|
p.push(format!(
|
|
"vandelay-{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, dry_run: bool) -> CommonConfig {
|
|
CommonConfig {
|
|
archive: archive.to_path_buf(),
|
|
threads: 4,
|
|
dry_run,
|
|
max_retries: 5,
|
|
allow_invalid_certs: true,
|
|
logger: Logger::from_flags(false, 0),
|
|
}
|
|
}
|
|
|
|
fn basic(localpart: &str) -> Auth {
|
|
Auth::Basic {
|
|
user: format!("{localpart}@{}", seeder::DOMAIN),
|
|
password: seeder::USER_PASSWORD.to_owned(),
|
|
}
|
|
}
|
|
|
|
fn import_cfg(account: AccountSelector) -> ImportConfig {
|
|
ImportConfig {
|
|
connect: ConnectConfig {
|
|
url: base_url().to_owned(),
|
|
auth: basic("test1"),
|
|
account,
|
|
},
|
|
objects: None,
|
|
allow_source_change: false,
|
|
}
|
|
}
|
|
|
|
fn count(conn: &Connection, table: &str) -> i64 {
|
|
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires Docker"]
|
|
fn import_test1_full_matches_seed_stats() {
|
|
let fx = seeder::provision(base_url()).expect("provision");
|
|
let acc = fx.account("test1").expect("test1");
|
|
let archive = tmp_archive("imp1");
|
|
|
|
assert_eq!(fx.domain, seeder::DOMAIN, "fixture domain");
|
|
assert!(!fx.domain_id.is_empty(), "fixture resolved a domain id");
|
|
assert_eq!(
|
|
fx.admin_login,
|
|
(
|
|
seeder::ADMIN_USER.to_owned(),
|
|
seeder::ADMIN_PASSWORD.to_owned()
|
|
),
|
|
"fixture exposes the instance admin login"
|
|
);
|
|
|
|
let cfg = ImportConfig {
|
|
connect: ConnectConfig {
|
|
url: fx.base_url.clone(),
|
|
auth: basic("test1"),
|
|
account: AccountSelector::Id(acc.account_id.clone()),
|
|
},
|
|
objects: None,
|
|
allow_source_change: false,
|
|
};
|
|
let summary = sync::import_jmap::run(common(&archive, false), cfg).expect("import run");
|
|
assert!(!summary.any_failed(), "import had failures: {summary:?}");
|
|
|
|
let seeded = acc.seeded.as_ref().expect("seed stats");
|
|
let conn = Connection::open(&archive).unwrap();
|
|
|
|
let mailboxes = count(&conn, "mailboxes") as usize;
|
|
assert!(
|
|
mailboxes >= seeded.mailboxes_created + 4,
|
|
"mailboxes ({mailboxes}) = seeded leaf tree ({}) + Stalwart default roles",
|
|
seeded.mailboxes_created
|
|
);
|
|
assert_eq!(count(&conn, "emails") as usize, seeded.emails);
|
|
assert!(count(&conn, "file_nodes") as usize >= seeded.file_nodes);
|
|
assert!(count(&conn, "contact_cards") as usize >= seeded.contacts);
|
|
assert!(count(&conn, "calendar_events") as usize >= seeded.events);
|
|
assert!(count(&conn, "blobs") > 0);
|
|
|
|
assert_eq!(
|
|
count(&conn, "address_books") as usize,
|
|
seeded.address_books,
|
|
"address books mirror the source exactly"
|
|
);
|
|
assert_eq!(
|
|
count(&conn, "calendars") as usize,
|
|
seeded.calendars,
|
|
"calendars mirror the source exactly"
|
|
);
|
|
assert_eq!(
|
|
count(&conn, "identities") as usize,
|
|
usize::from(seeded.identity),
|
|
"identity count matches the seeded flag"
|
|
);
|
|
match seeded.sieve_active {
|
|
Some(active) => {
|
|
assert_eq!(
|
|
count(&conn, "sieve_scripts"),
|
|
2,
|
|
"seeder lays down two scripts per active layout"
|
|
);
|
|
let active_count: i64 = conn
|
|
.query_row(
|
|
"SELECT count(*) FROM sieve_scripts WHERE is_active = 1",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
active_count,
|
|
i64::from(active),
|
|
"sieve activation state matches SeedStats"
|
|
);
|
|
}
|
|
None => assert_eq!(count(&conn, "sieve_scripts"), 0, "no sieve seeded"),
|
|
}
|
|
|
|
let _ = std::fs::remove_file(&archive);
|
|
seeder::teardown(base_url()).expect("teardown");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires Docker"]
|
|
fn import_resolves_account_via_admin_principal_path() {
|
|
let fx = seeder::provision(base_url()).expect("provision");
|
|
let admin_fx = fx.account(seeder::ADMIN_LOCALPART).expect("vandeladmin");
|
|
assert!(
|
|
admin_fx.admin_role,
|
|
"vandeladmin must have the Admin role to list principals"
|
|
);
|
|
let target = fx.account("test1").expect("test1");
|
|
let archive = tmp_archive("impadmin");
|
|
|
|
let cfg = ImportConfig {
|
|
connect: ConnectConfig {
|
|
url: fx.base_url.clone(),
|
|
auth: Auth::Basic {
|
|
user: admin_fx.email.clone(),
|
|
password: seeder::ADMIN_ACCOUNT_PASSWORD.to_owned(),
|
|
},
|
|
account: AccountSelector::Name(target.email.clone()),
|
|
},
|
|
objects: Some(vec![vandelay::types::ObjectType::Mailbox]),
|
|
allow_source_change: false,
|
|
};
|
|
let summary = sync::import_jmap::run(common(&archive, false), cfg)
|
|
.expect("admin-principal-resolved import");
|
|
assert!(!summary.any_failed(), "import had failures: {summary:?}");
|
|
|
|
let conn = Connection::open(&archive).unwrap();
|
|
assert!(
|
|
count(&conn, "mailboxes") > 0,
|
|
"mailboxes imported via the admin-resolved account id"
|
|
);
|
|
let resolved: String = conn
|
|
.query_row("SELECT account_id FROM sources", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(
|
|
resolved, target.account_id,
|
|
"admin Principal path resolved to test1's data account id"
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&archive);
|
|
seeder::teardown(base_url()).expect("teardown");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires Docker"]
|
|
fn import_dry_run_writes_nothing() {
|
|
let fx = seeder::provision(base_url()).expect("provision");
|
|
let acc = fx.account("test3").expect("test3");
|
|
let archive = tmp_archive("impdry");
|
|
|
|
let mut cfg = import_cfg(AccountSelector::Id(acc.account_id.clone()));
|
|
cfg.connect.auth = basic("test3");
|
|
let summary = sync::import_jmap::run(common(&archive, true), cfg).expect("dry-run import");
|
|
assert!(summary.per_type.is_empty(), "dry-run returns empty summary");
|
|
|
|
let conn = Connection::open(&archive).unwrap();
|
|
assert_eq!(count(&conn, "emails"), 0);
|
|
assert_eq!(
|
|
count(&conn, "sources"),
|
|
0,
|
|
"dry-run must not upsert sources"
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&archive);
|
|
seeder::teardown(base_url()).expect("teardown");
|
|
}
|
|
|
|
fn export_cfg(localpart: &str, account_id: &str, prune: bool) -> ExportConfig {
|
|
ExportConfig {
|
|
connect: ConnectConfig {
|
|
url: base_url().to_owned(),
|
|
auth: Auth::Basic {
|
|
user: format!("{localpart}@{}", seeder::DOMAIN),
|
|
password: seeder::USER_PASSWORD.to_owned(),
|
|
},
|
|
account: AccountSelector::Id(account_id.to_owned()),
|
|
},
|
|
objects: None,
|
|
prune,
|
|
yes: true,
|
|
}
|
|
}
|
|
|
|
fn jmap_for(localpart: &str) -> seeder::jmap::Jmap {
|
|
seeder::jmap::Jmap::connect(
|
|
base_url(),
|
|
&format!("{localpart}@{}", seeder::DOMAIN),
|
|
seeder::USER_PASSWORD,
|
|
)
|
|
.expect("user session")
|
|
}
|
|
|
|
fn seed_deep_mailbox_chain(jmap: &seeder::jmap::Jmap, account_id: &str, depth: usize) {
|
|
const CORE: &str = "urn:ietf:params:jmap:core";
|
|
const MAIL: &str = "urn:ietf:params:jmap:mail";
|
|
let mut create = serde_json::Map::new();
|
|
for i in 0..depth {
|
|
let key = format!("d{i}");
|
|
let mut obj = serde_json::Map::new();
|
|
obj.insert(
|
|
"name".to_owned(),
|
|
serde_json::Value::String(format!("deep-{i}")),
|
|
);
|
|
if i > 0 {
|
|
obj.insert(
|
|
"parentId".to_owned(),
|
|
serde_json::Value::String(format!("#d{}", i - 1)),
|
|
);
|
|
}
|
|
create.insert(key, serde_json::Value::Object(obj));
|
|
}
|
|
jmap.set_create(
|
|
&[CORE, MAIL],
|
|
"Mailbox/set",
|
|
account_id,
|
|
serde_json::Value::Object(create),
|
|
&[],
|
|
)
|
|
.expect("seed deep mailbox chain");
|
|
}
|
|
|
|
fn seed_deep_filenode_chain(jmap: &seeder::jmap::Jmap, account_id: &str, depth: usize) {
|
|
const CORE: &str = "urn:ietf:params:jmap:core";
|
|
const FILENODE: &str = "urn:ietf:params:jmap:filenode";
|
|
let mut create = serde_json::Map::new();
|
|
for i in 0..depth {
|
|
let key = format!("fn{i}");
|
|
let mut obj = serde_json::Map::new();
|
|
obj.insert(
|
|
"name".to_owned(),
|
|
serde_json::Value::String(format!("dir-{i}")),
|
|
);
|
|
obj.insert(
|
|
"nodeType".to_owned(),
|
|
serde_json::Value::String("directory".to_owned()),
|
|
);
|
|
if i > 0 {
|
|
obj.insert(
|
|
"parentId".to_owned(),
|
|
serde_json::Value::String(format!("#fn{}", i - 1)),
|
|
);
|
|
}
|
|
create.insert(key, serde_json::Value::Object(obj));
|
|
}
|
|
jmap.set_create(
|
|
&[CORE, FILENODE],
|
|
"FileNode/set",
|
|
account_id,
|
|
serde_json::Value::Object(create),
|
|
&[],
|
|
)
|
|
.expect("seed deep filenode chain");
|
|
}
|
|
|
|
fn mailbox_depth_in_archive(conn: &Connection, leaf_name: &str) -> Option<usize> {
|
|
let mut id: Option<i64> = conn
|
|
.query_row(
|
|
"SELECT id FROM mailboxes WHERE name = ?1",
|
|
rusqlite::params![leaf_name],
|
|
|r| r.get(0),
|
|
)
|
|
.ok();
|
|
let mut depth = 0usize;
|
|
while let Some(cur) = id {
|
|
let parent: Option<i64> = conn
|
|
.query_row(
|
|
"SELECT parent_id FROM mailboxes WHERE id = ?1",
|
|
rusqlite::params![cur],
|
|
|r| r.get(0),
|
|
)
|
|
.ok()?;
|
|
match parent {
|
|
Some(p) => {
|
|
depth += 1;
|
|
id = Some(p);
|
|
}
|
|
None => break,
|
|
}
|
|
}
|
|
Some(depth)
|
|
}
|
|
|
|
fn filenode_depth_in_archive(conn: &Connection, leaf_name: &str) -> Option<usize> {
|
|
let mut id: Option<i64> = conn
|
|
.query_row(
|
|
"SELECT id FROM file_nodes WHERE name = ?1",
|
|
rusqlite::params![leaf_name],
|
|
|r| r.get(0),
|
|
)
|
|
.ok();
|
|
let mut depth = 0usize;
|
|
while let Some(cur) = id {
|
|
let parent: Option<i64> = conn
|
|
.query_row(
|
|
"SELECT parent_id FROM file_nodes WHERE id = ?1",
|
|
rusqlite::params![cur],
|
|
|r| r.get(0),
|
|
)
|
|
.ok()?;
|
|
match parent {
|
|
Some(p) => {
|
|
depth += 1;
|
|
id = Some(p);
|
|
}
|
|
None => break,
|
|
}
|
|
}
|
|
Some(depth)
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires Docker"]
|
|
fn import_export_deeply_nested_mailbox_tree() {
|
|
const DEPTH: usize = 10;
|
|
let fx = seeder::provision(base_url()).expect("provision");
|
|
let src = fx.account("test4").expect("test4 (deep source)");
|
|
let tgt = fx.account("test5").expect("test5 (deep target)");
|
|
|
|
let src_jmap = jmap_for(&src.localpart);
|
|
seed_deep_mailbox_chain(&src_jmap, &src.account_id, DEPTH);
|
|
|
|
let archive = tmp_archive("deep_mbox");
|
|
let imp = ImportConfig {
|
|
connect: ConnectConfig {
|
|
url: base_url().to_owned(),
|
|
auth: basic("test4"),
|
|
account: AccountSelector::Id(src.account_id.clone()),
|
|
},
|
|
objects: Some(vec![vandelay::types::ObjectType::Mailbox]),
|
|
allow_source_change: false,
|
|
};
|
|
let s = sync::import_jmap::run(common(&archive, false), imp).expect("import");
|
|
assert!(!s.any_failed(), "import had failures: {s:?}");
|
|
|
|
{
|
|
let conn = Connection::open(&archive).unwrap();
|
|
let d = mailbox_depth_in_archive(&conn, &format!("deep-{}", DEPTH - 1));
|
|
assert_eq!(
|
|
d,
|
|
Some(DEPTH - 1),
|
|
"imported leaf must be {} hops from a root",
|
|
DEPTH - 1
|
|
);
|
|
let dn: i64 = conn
|
|
.query_row(
|
|
"SELECT count(*) FROM mailboxes WHERE name LIKE 'deep-%'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(dn as usize, DEPTH, "all deep levels imported");
|
|
}
|
|
|
|
let exp_s = sync::export::run(
|
|
common(&archive, false),
|
|
export_cfg("test5", &tgt.account_id, false),
|
|
)
|
|
.expect("export deep tree");
|
|
assert!(
|
|
!exp_s.per_type.iter().any(|(_, c)| c.failed > 0),
|
|
"export had failures: {exp_s:?}"
|
|
);
|
|
|
|
let again_archive = tmp_archive("deep_mbox_round");
|
|
let imp_again = ImportConfig {
|
|
connect: ConnectConfig {
|
|
url: base_url().to_owned(),
|
|
auth: basic("test5"),
|
|
account: AccountSelector::Id(tgt.account_id.clone()),
|
|
},
|
|
objects: Some(vec![vandelay::types::ObjectType::Mailbox]),
|
|
allow_source_change: false,
|
|
};
|
|
sync::import_jmap::run(common(&again_archive, false), imp_again)
|
|
.expect("re-import from target");
|
|
{
|
|
let conn = Connection::open(&again_archive).unwrap();
|
|
let d = mailbox_depth_in_archive(&conn, &format!("deep-{}", DEPTH - 1));
|
|
assert_eq!(
|
|
d,
|
|
Some(DEPTH - 1),
|
|
"round-trip preserves the {DEPTH}-level chain"
|
|
);
|
|
}
|
|
|
|
let _ = std::fs::remove_file(&archive);
|
|
let _ = std::fs::remove_file(&again_archive);
|
|
seeder::teardown(base_url()).expect("teardown");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires Docker"]
|
|
fn import_export_deeply_nested_filenode_tree() {
|
|
const DEPTH: usize = 10;
|
|
let fx = seeder::provision(base_url()).expect("provision");
|
|
let src = fx.account("test4").expect("test4 (deep source)");
|
|
let tgt = fx.account("test5").expect("test5 (deep target)");
|
|
|
|
let src_jmap = jmap_for(&src.localpart);
|
|
seed_deep_filenode_chain(&src_jmap, &src.account_id, DEPTH);
|
|
|
|
let archive = tmp_archive("deep_fn");
|
|
let imp = ImportConfig {
|
|
connect: ConnectConfig {
|
|
url: base_url().to_owned(),
|
|
auth: basic("test4"),
|
|
account: AccountSelector::Id(src.account_id.clone()),
|
|
},
|
|
objects: Some(vec![vandelay::types::ObjectType::FileNode]),
|
|
allow_source_change: false,
|
|
};
|
|
let s = sync::import_jmap::run(common(&archive, false), imp).expect("import");
|
|
assert!(!s.any_failed(), "import had failures: {s:?}");
|
|
|
|
{
|
|
let conn = Connection::open(&archive).unwrap();
|
|
let d = filenode_depth_in_archive(&conn, &format!("dir-{}", DEPTH - 1));
|
|
assert_eq!(
|
|
d,
|
|
Some(DEPTH - 1),
|
|
"imported leaf must be {} hops from a root",
|
|
DEPTH - 1
|
|
);
|
|
let dn: i64 = conn
|
|
.query_row(
|
|
"SELECT count(*) FROM file_nodes WHERE name LIKE 'dir-%'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(dn as usize, DEPTH, "all deep levels imported");
|
|
}
|
|
|
|
let exp_s = sync::export::run(
|
|
common(&archive, false),
|
|
export_cfg("test5", &tgt.account_id, false),
|
|
)
|
|
.expect("export deep filenode tree");
|
|
assert!(
|
|
!exp_s.per_type.iter().any(|(_, c)| c.failed > 0),
|
|
"export had failures: {exp_s:?}"
|
|
);
|
|
|
|
let again_archive = tmp_archive("deep_fn_round");
|
|
sync::import_jmap::run(
|
|
common(&again_archive, false),
|
|
ImportConfig {
|
|
connect: ConnectConfig {
|
|
url: base_url().to_owned(),
|
|
auth: basic("test5"),
|
|
account: AccountSelector::Id(tgt.account_id.clone()),
|
|
},
|
|
objects: Some(vec![vandelay::types::ObjectType::FileNode]),
|
|
allow_source_change: false,
|
|
},
|
|
)
|
|
.expect("re-import from target");
|
|
{
|
|
let conn = Connection::open(&again_archive).unwrap();
|
|
let d = filenode_depth_in_archive(&conn, &format!("dir-{}", DEPTH - 1));
|
|
assert_eq!(
|
|
d,
|
|
Some(DEPTH - 1),
|
|
"round-trip preserves the {DEPTH}-level filenode chain"
|
|
);
|
|
}
|
|
|
|
let _ = std::fs::remove_file(&archive);
|
|
let _ = std::fs::remove_file(&again_archive);
|
|
seeder::teardown(base_url()).expect("teardown");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires Docker"]
|
|
fn prune_multilevel_mailbox_tree_destroys_leaf_first() {
|
|
const DEPTH: usize = 6;
|
|
let fx = seeder::provision(base_url()).expect("provision");
|
|
let src = fx.account("test4").expect("test4");
|
|
let tgt = fx.account("test5").expect("test5");
|
|
|
|
let src_jmap = jmap_for(&src.localpart);
|
|
seed_deep_mailbox_chain(&src_jmap, &src.account_id, DEPTH);
|
|
|
|
let arch_src = tmp_archive("prune_deep_src");
|
|
sync::import_jmap::run(
|
|
common(&arch_src, false),
|
|
ImportConfig {
|
|
connect: ConnectConfig {
|
|
url: base_url().to_owned(),
|
|
auth: basic("test4"),
|
|
account: AccountSelector::Id(src.account_id.clone()),
|
|
},
|
|
objects: Some(vec![vandelay::types::ObjectType::Mailbox]),
|
|
allow_source_change: false,
|
|
},
|
|
)
|
|
.expect("import full deep tree");
|
|
sync::export::run(
|
|
common(&arch_src, false),
|
|
export_cfg("test5", &tgt.account_id, false),
|
|
)
|
|
.expect("seed target with deep tree");
|
|
|
|
let arch_empty = tmp_archive("prune_deep_empty");
|
|
{
|
|
let _ = vandelay::db::init::open(&arch_empty).expect("init empty archive");
|
|
}
|
|
|
|
let small_jmap = jmap_for(&fx.account("test6").expect("test6").localpart);
|
|
let mut create = serde_json::Map::new();
|
|
let mut obj = serde_json::Map::new();
|
|
obj.insert(
|
|
"name".to_owned(),
|
|
serde_json::Value::String("Smallbox".to_owned()),
|
|
);
|
|
create.insert("m".to_owned(), serde_json::Value::Object(obj));
|
|
small_jmap
|
|
.set_create(
|
|
&["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
|
"Mailbox/set",
|
|
&fx.account("test6").unwrap().account_id,
|
|
serde_json::Value::Object(create),
|
|
&[],
|
|
)
|
|
.expect("seed tiny mailbox in test6");
|
|
sync::import_jmap::run(
|
|
common(&arch_empty, false),
|
|
ImportConfig {
|
|
connect: ConnectConfig {
|
|
url: base_url().to_owned(),
|
|
auth: basic("test6"),
|
|
account: AccountSelector::Id(fx.account("test6").unwrap().account_id.clone()),
|
|
},
|
|
objects: Some(vec![vandelay::types::ObjectType::Mailbox]),
|
|
allow_source_change: false,
|
|
},
|
|
)
|
|
.expect("populate archive with one trivial mailbox");
|
|
|
|
let pruned = sync::export::run(
|
|
common(&arch_empty, false),
|
|
export_cfg("test5", &tgt.account_id, true),
|
|
)
|
|
.expect("prune deep tree on target");
|
|
|
|
let mb_counts = pruned
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "Mailbox")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("mailbox counts");
|
|
assert!(
|
|
mb_counts.deleted as usize >= DEPTH,
|
|
"prune destroyed >= {DEPTH} deep mailboxes (leaf-first); got {mb_counts:?}",
|
|
);
|
|
assert_eq!(
|
|
mb_counts.failed, 0,
|
|
"no prune failures (leaf-first ordering must avoid FK errors): {mb_counts:?}"
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&arch_src);
|
|
let _ = std::fs::remove_file(&arch_empty);
|
|
seeder::teardown(base_url()).expect("teardown");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires Docker"]
|
|
fn prune_non_tree_type_contact_card() {
|
|
let fx = seeder::provision(base_url()).expect("provision");
|
|
let src = fx.account("test1").expect("test1");
|
|
let tgt = fx.account("test5").expect("test5");
|
|
|
|
let arch_full = tmp_archive("prune_cc_full");
|
|
sync::import_jmap::run(
|
|
common(&arch_full, false),
|
|
ImportConfig {
|
|
connect: ConnectConfig {
|
|
url: base_url().to_owned(),
|
|
auth: basic("test1"),
|
|
account: AccountSelector::Id(src.account_id.clone()),
|
|
},
|
|
objects: Some(vec![
|
|
vandelay::types::ObjectType::AddressBook,
|
|
vandelay::types::ObjectType::ContactCard,
|
|
]),
|
|
allow_source_change: false,
|
|
},
|
|
)
|
|
.expect("import contact cards");
|
|
sync::export::run(
|
|
common(&arch_full, false),
|
|
export_cfg("test5", &tgt.account_id, false),
|
|
)
|
|
.expect("seed target with contact cards");
|
|
|
|
let arch_empty = tmp_archive("prune_cc_empty");
|
|
{
|
|
let conn = vandelay::db::init::open(&arch_empty).expect("init empty archive");
|
|
conn.execute(
|
|
"INSERT INTO address_books (id,name,is_default) VALUES (1,'AB',1)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
let pruned = sync::export::run(
|
|
common(&arch_empty, false),
|
|
export_cfg("test5", &tgt.account_id, true),
|
|
)
|
|
.expect("prune contact cards");
|
|
let cc = pruned
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "ContactCard")
|
|
.map(|(_, c)| c.clone());
|
|
if let Some(cc) = cc {
|
|
assert!(
|
|
cc.deleted > 0,
|
|
"prune destroyed unmatched ContactCards: {cc:?}"
|
|
);
|
|
assert_eq!(cc.failed, 0, "no per-unit failures: {cc:?}");
|
|
}
|
|
|
|
let _ = std::fs::remove_file(&arch_full);
|
|
let _ = std::fs::remove_file(&arch_empty);
|
|
seeder::teardown(base_url()).expect("teardown");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires Docker"]
|
|
fn export_round_trip_and_convergence() {
|
|
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("rt");
|
|
|
|
let imp = ImportConfig {
|
|
connect: ConnectConfig {
|
|
url: base_url().to_owned(),
|
|
auth: basic("test1"),
|
|
account: AccountSelector::Id(src.account_id.clone()),
|
|
},
|
|
objects: None,
|
|
allow_source_change: false,
|
|
};
|
|
let s = sync::import_jmap::run(common(&archive, false), imp).expect("import");
|
|
assert!(!s.any_failed());
|
|
|
|
let mut tight = serde_json::Map::new();
|
|
tight.insert("uploadTtl".to_owned(), serde_json::json!(3_000));
|
|
tight.insert("uploadQuota".to_owned(), serde_json::json!(150_000));
|
|
let _quota_guard = JmapSettingsGuard::override_settings(tight);
|
|
|
|
let started_e1 = std::time::Instant::now();
|
|
let e1 = sync::export::run(
|
|
common(&archive, false),
|
|
export_cfg("test4", &tgt.account_id, false),
|
|
)
|
|
.expect("export 1");
|
|
let e1_elapsed = started_e1.elapsed();
|
|
let created_1: u64 = e1.per_type.iter().map(|(_, c)| c.created).sum();
|
|
assert!(created_1 > 0, "first export created objects: {e1:?}");
|
|
let emails = e1
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "Email")
|
|
.map(|(_, c)| c.created)
|
|
.unwrap_or(0);
|
|
assert!(emails > 0, "emails were imported to target");
|
|
assert!(
|
|
!e1.per_type.iter().any(|(_, c)| c.failed > 0),
|
|
"export 1 had failures: {e1:?}"
|
|
);
|
|
|
|
let e2 = sync::export::run(
|
|
common(&archive, false),
|
|
export_cfg("test4", &tgt.account_id, false),
|
|
)
|
|
.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 objects");
|
|
|
|
eprintln!(
|
|
"round-trip: emails created={emails} export-1 elapsed={:.1}s \
|
|
retries={} retry_after_sleeps={}",
|
|
e1_elapsed.as_secs_f64(),
|
|
e1.retries_observed,
|
|
e1.retry_after_sleeps,
|
|
);
|
|
assert!(
|
|
e1.retry_after_sleeps > 0,
|
|
"tight blob-upload quota should have provoked at least one Retry-After sleep \
|
|
during export-1; counters retries={} retry_after_sleeps={}",
|
|
e1.retries_observed,
|
|
e1.retry_after_sleeps,
|
|
);
|
|
|
|
drop(_quota_guard);
|
|
let _ = std::fs::remove_file(&archive);
|
|
seeder::teardown(base_url()).expect("teardown");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires Docker"]
|
|
fn export_prune_destroys_unmatched() {
|
|
let fx = seeder::provision(base_url()).expect("provision");
|
|
let big = fx.account("test1").expect("test1");
|
|
let small = fx.account("test3").expect("test3");
|
|
let tgt = fx.account("test5").expect("test5");
|
|
|
|
let arch_big = tmp_archive("prbig");
|
|
sync::import_jmap::run(
|
|
common(&arch_big, false),
|
|
ImportConfig {
|
|
connect: ConnectConfig {
|
|
url: base_url().to_owned(),
|
|
auth: basic("test1"),
|
|
account: AccountSelector::Id(big.account_id.clone()),
|
|
},
|
|
objects: Some(vec![vandelay::types::ObjectType::Mailbox]),
|
|
allow_source_change: false,
|
|
},
|
|
)
|
|
.expect("import big");
|
|
sync::export::run(
|
|
common(&arch_big, false),
|
|
export_cfg("test5", &tgt.account_id, false),
|
|
)
|
|
.expect("seed target with big mailbox tree");
|
|
|
|
let arch_small = tmp_archive("prsmall");
|
|
sync::import_jmap::run(
|
|
common(&arch_small, false),
|
|
ImportConfig {
|
|
connect: ConnectConfig {
|
|
url: base_url().to_owned(),
|
|
auth: basic("test3"),
|
|
account: AccountSelector::Id(small.account_id.clone()),
|
|
},
|
|
objects: Some(vec![vandelay::types::ObjectType::Mailbox]),
|
|
allow_source_change: false,
|
|
},
|
|
)
|
|
.expect("import small");
|
|
|
|
let pruned = sync::export::run(
|
|
common(&arch_small, false),
|
|
export_cfg("test5", &tgt.account_id, true),
|
|
)
|
|
.expect("export with prune");
|
|
let deleted: u64 = pruned.per_type.iter().map(|(_, c)| c.deleted).sum();
|
|
assert!(
|
|
deleted > 0,
|
|
"prune destroyed target mailboxes absent from the small archive: {pruned:?}"
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&arch_big);
|
|
let _ = std::fs::remove_file(&arch_small);
|
|
seeder::teardown(base_url()).expect("teardown");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires Docker"]
|
|
fn import_resume_converges_after_partial() {
|
|
let fx = seeder::provision(base_url()).expect("provision");
|
|
let acc = fx.account("test2").expect("test2");
|
|
let archive = tmp_archive("impresume");
|
|
|
|
let mk = || ImportConfig {
|
|
connect: ConnectConfig {
|
|
url: base_url().to_owned(),
|
|
auth: basic("test2"),
|
|
account: AccountSelector::Id(acc.account_id.clone()),
|
|
},
|
|
objects: None,
|
|
allow_source_change: false,
|
|
};
|
|
|
|
let mut first = mk();
|
|
first.objects = Some(vec![vandelay::types::ObjectType::Mailbox]);
|
|
sync::import_jmap::run(common(&archive, false), first).expect("partial import");
|
|
|
|
let conn = Connection::open(&archive).unwrap();
|
|
assert_eq!(count(&conn, "emails"), 0, "only mailboxes imported so far");
|
|
drop(conn);
|
|
|
|
let summary = sync::import_jmap::run(common(&archive, false), mk()).expect("resume");
|
|
assert!(!summary.any_failed());
|
|
let seeded = acc.seeded.as_ref().unwrap();
|
|
{
|
|
let conn = Connection::open(&archive).unwrap();
|
|
assert_eq!(count(&conn, "emails") as usize, seeded.emails);
|
|
}
|
|
|
|
let again = sync::import_jmap::run(common(&archive, false), mk()).expect("idempotent rerun");
|
|
assert!(!again.any_failed());
|
|
let fetched_on_rerun: u64 = again.per_type.iter().map(|(_, c)| c.fetched).sum();
|
|
assert_eq!(fetched_on_rerun, 0, "rerun fetched nothing new");
|
|
{
|
|
let conn2 = Connection::open(&archive).unwrap();
|
|
assert_eq!(count(&conn2, "emails") as usize, seeded.emails);
|
|
}
|
|
|
|
let _ = std::fs::remove_file(&archive);
|
|
seeder::teardown(base_url()).expect("teardown");
|
|
}
|
|
|
|
// TODO: re-enable once we have a way to make Stalwart enforce
|
|
// maxConcurrentRequests strictly under testcontainers.
|
|
#[cfg(any())]
|
|
#[test]
|
|
#[ignore = "requires Docker"]
|
|
fn live_burst_exceeds_concurrent_requests_and_recovers() {
|
|
use std::sync::Arc;
|
|
use vandelay::jmap::http::{HttpClient, RetryPolicy};
|
|
use vandelay::jmap::request::{Request, check_method_error};
|
|
use vandelay::jmap::session::{Limits, Session};
|
|
|
|
let fx = seeder::provision(base_url()).expect("provision");
|
|
let acc = fx.account("test1").expect("test1");
|
|
|
|
let client = HttpClient::new(basic("test1"), RetryPolicy::new(20), true);
|
|
let session = Session::discover(&client, base_url()).expect("discover session");
|
|
let server_limits = session.core_limits().expect("core limits");
|
|
|
|
let bypass_gate = Limits {
|
|
max_concurrent_requests: 1024,
|
|
max_concurrent_upload: 1024,
|
|
..server_limits
|
|
};
|
|
client.set_limits(&bypass_gate);
|
|
|
|
let n_threads = (server_limits.max_concurrent_requests as usize) * 4;
|
|
let calls_per_thread = 4u32;
|
|
|
|
let client = Arc::new(client);
|
|
let api = Arc::new(session.api_url.clone());
|
|
let account_id = Arc::new(acc.account_id.clone());
|
|
|
|
let mut handles = Vec::with_capacity(n_threads);
|
|
for _ in 0..n_threads {
|
|
let client = client.clone();
|
|
let api = api.clone();
|
|
let account_id = account_id.clone();
|
|
handles.push(std::thread::spawn(move || {
|
|
let mut failures: Vec<String> = Vec::new();
|
|
for _ in 0..calls_per_thread {
|
|
let mut req = Request::new();
|
|
req.call(
|
|
"Mailbox/query",
|
|
serde_json::json!({
|
|
"accountId": &*account_id,
|
|
"filter": { "name": "__sleep" }
|
|
}),
|
|
"q",
|
|
);
|
|
match req.send(&client, &api) {
|
|
Ok(resp) => match resp.first().and_then(|mr| {
|
|
check_method_error(mr)?;
|
|
Ok(())
|
|
}) {
|
|
Ok(()) => {}
|
|
Err(e) => {
|
|
failures.push(format!("method: {e}"));
|
|
break;
|
|
}
|
|
},
|
|
Err(e) => {
|
|
failures.push(format!("send: {e}"));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
failures
|
|
}));
|
|
}
|
|
|
|
let mut all_failures = Vec::new();
|
|
for h in handles {
|
|
all_failures.extend(h.join().expect("worker panicked"));
|
|
}
|
|
let retries = client.retries_observed();
|
|
let retry_after = client.retry_after_sleeps();
|
|
eprintln!(
|
|
"burst against server-cap maxConcurrentRequests={} with {} threads x {} calls: \
|
|
retries={retries} retry_after_sleeps={retry_after} failures={}",
|
|
server_limits.max_concurrent_requests,
|
|
n_threads,
|
|
calls_per_thread,
|
|
all_failures.len()
|
|
);
|
|
assert!(
|
|
all_failures.is_empty(),
|
|
"all calls should eventually succeed via retry/backoff; first failure: {:?}",
|
|
all_failures.first()
|
|
);
|
|
assert!(
|
|
retries > 0,
|
|
"burst should have provoked at least one 400 limit:maxConcurrentRequests retry; \
|
|
counters retries={retries} retry_after_sleeps={retry_after}"
|
|
);
|
|
|
|
seeder::teardown(base_url()).expect("teardown");
|
|
}
|
|
|
|
struct JmapSettingsGuard {
|
|
admin: vandelay::jmap::http::HttpClient,
|
|
admin_api: String,
|
|
admin_account: String,
|
|
previous: serde_json::Map<String, serde_json::Value>,
|
|
}
|
|
|
|
impl JmapSettingsGuard {
|
|
fn override_settings(updates: serde_json::Map<String, serde_json::Value>) -> Self {
|
|
use vandelay::jmap::http::{Auth, HttpClient, RetryPolicy};
|
|
use vandelay::jmap::session::Session;
|
|
let admin = HttpClient::new(
|
|
Auth::Basic {
|
|
user: seeder::ADMIN_USER.to_owned(),
|
|
password: seeder::ADMIN_PASSWORD.to_owned(),
|
|
},
|
|
RetryPolicy::new(5),
|
|
true,
|
|
);
|
|
let session = Session::discover(&admin, base_url()).expect("admin discover");
|
|
let admin_account = session
|
|
.accounts
|
|
.first()
|
|
.map(|(id, _)| id.clone())
|
|
.expect("admin session has an account id");
|
|
let admin_api = session.api_url.clone();
|
|
let previous = read_jmap_settings(
|
|
&admin,
|
|
&admin_api,
|
|
&admin_account,
|
|
updates
|
|
.keys()
|
|
.map(String::as_str)
|
|
.collect::<Vec<_>>()
|
|
.as_slice(),
|
|
);
|
|
apply_jmap_settings(&admin, &admin_api, &admin_account, &updates);
|
|
JmapSettingsGuard {
|
|
admin,
|
|
admin_api,
|
|
admin_account,
|
|
previous,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for JmapSettingsGuard {
|
|
fn drop(&mut self) {
|
|
apply_jmap_settings(
|
|
&self.admin,
|
|
&self.admin_api,
|
|
&self.admin_account,
|
|
&self.previous,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn read_jmap_settings(
|
|
admin: &vandelay::jmap::http::HttpClient,
|
|
api: &str,
|
|
account: &str,
|
|
properties: &[&str],
|
|
) -> serde_json::Map<String, serde_json::Value> {
|
|
let body = serde_json::json!({
|
|
"using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"],
|
|
"methodCalls": [["x:Jmap/get", {
|
|
"accountId": account,
|
|
"ids": ["singleton"],
|
|
"properties": properties,
|
|
}, "g"]]
|
|
});
|
|
let resp = admin.post_json(api, &body).expect("x:Jmap/get");
|
|
let obj = resp
|
|
.pointer("/methodResponses/0/1/list/0")
|
|
.and_then(serde_json::Value::as_object)
|
|
.expect("x:Jmap/get returned a singleton")
|
|
.clone();
|
|
let mut out = serde_json::Map::new();
|
|
for p in properties {
|
|
if let Some(v) = obj.get(*p) {
|
|
out.insert((*p).to_owned(), v.clone());
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn apply_jmap_settings(
|
|
admin: &vandelay::jmap::http::HttpClient,
|
|
api: &str,
|
|
account: &str,
|
|
updates: &serde_json::Map<String, serde_json::Value>,
|
|
) {
|
|
let body = serde_json::json!({
|
|
"using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"],
|
|
"methodCalls": [
|
|
["x:Jmap/set", {
|
|
"accountId": account,
|
|
"update": { "singleton": serde_json::Value::Object(updates.clone()) }
|
|
}, "u"],
|
|
["x:Action/set", {
|
|
"accountId": account,
|
|
"create": { "r": { "@type": "ReloadSettings" } }
|
|
}, "r"]
|
|
]
|
|
});
|
|
let resp = admin
|
|
.post_json(api, &body)
|
|
.expect("x:Jmap/set + ReloadSettings");
|
|
assert!(
|
|
resp.pointer("/methodResponses/0/1/updated/singleton")
|
|
.is_some(),
|
|
"x:Jmap/set not applied: {resp}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires Docker"]
|
|
fn live_blob_quota_429_triggers_retry_after_then_succeeds() {
|
|
use vandelay::jmap::blobxfer;
|
|
use vandelay::jmap::http::{HttpClient, RetryPolicy};
|
|
use vandelay::jmap::session::Session;
|
|
|
|
let fx = seeder::provision(base_url()).expect("provision");
|
|
let acc = fx.account("test1").expect("test1");
|
|
|
|
let mut updates = serde_json::Map::new();
|
|
updates.insert("uploadTtl".to_owned(), serde_json::json!(5_000));
|
|
let _ttl_guard = JmapSettingsGuard::override_settings(updates);
|
|
|
|
let client = HttpClient::new(basic("test1"), RetryPolicy::new(20), true);
|
|
let session = Session::discover(&client, base_url()).expect("discover session");
|
|
let limits = session.core_limits().expect("core limits");
|
|
client.set_limits(&limits);
|
|
|
|
let blob_size = 8 * 1024 * 1024;
|
|
let mut blob = vec![0u8; blob_size];
|
|
let max_uploads = 8u32;
|
|
let mut accepted = 0u32;
|
|
let mut first_failure: Option<String> = None;
|
|
for i in 0..max_uploads {
|
|
for (j, b) in blob.iter_mut().enumerate().take(64) {
|
|
*b = ((i as usize).wrapping_mul(2654435761).wrapping_add(j)) as u8;
|
|
}
|
|
match blobxfer::upload_bytes(
|
|
&client,
|
|
&session,
|
|
&acc.account_id,
|
|
"application/octet-stream",
|
|
&blob,
|
|
) {
|
|
Ok(_) => accepted += 1,
|
|
Err(e) => {
|
|
first_failure = Some(format!("upload {i}: {e}"));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
let retries = client.retries_observed();
|
|
let retry_after = client.retry_after_sleeps();
|
|
eprintln!(
|
|
"blob quota burst: accepted={accepted}/{max_uploads} ({} MiB each) \
|
|
retries={retries} retry_after_sleeps={retry_after} first_failure={:?}",
|
|
blob_size / (1024 * 1024),
|
|
first_failure,
|
|
);
|
|
assert!(
|
|
first_failure.is_none(),
|
|
"all uploads should eventually succeed via Retry-After backoff: {:?}",
|
|
first_failure
|
|
);
|
|
assert!(
|
|
retry_after > 0,
|
|
"expected at least one Retry-After sleep from the blob-upload quota class; \
|
|
counters retries={retries} retry_after_sleeps={retry_after}"
|
|
);
|
|
|
|
drop(_ttl_guard);
|
|
seeder::teardown(base_url()).expect("teardown");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires Docker"]
|
|
fn import_delta_propagates_email_keyword_change_via_changes() {
|
|
let fx = seeder::provision(base_url()).expect("provision");
|
|
let acc = fx.account("test1").expect("test1");
|
|
let archive = tmp_archive("delta-email");
|
|
|
|
sync::import_jmap::run(
|
|
common(&archive, false),
|
|
import_cfg(AccountSelector::Id(acc.account_id.clone())),
|
|
)
|
|
.expect("first import");
|
|
|
|
let (jmap_id, local_id): (String, i64) = {
|
|
let conn = Connection::open(&archive).unwrap();
|
|
conn.query_row(
|
|
"SELECT s.jmap_id, s.local_id FROM sync_id_jmap s
|
|
JOIN emails e ON e.id = s.local_id
|
|
WHERE s.type_name = 'Email' AND e.keywords NOT LIKE '%$flagged%'
|
|
LIMIT 1",
|
|
[],
|
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
|
)
|
|
.expect("an unflagged email exists in the archive")
|
|
};
|
|
|
|
let client = HttpClient::new(basic("test1"), RetryPolicy::new(5), true);
|
|
let session = Session::discover(&client, base_url()).expect("session discovered");
|
|
let account = account::resolve(
|
|
&AccountSelector::Id(acc.account_id.clone()),
|
|
&session,
|
|
&client,
|
|
)
|
|
.expect("account resolved");
|
|
let mut update = Map::new();
|
|
update.insert(jmap_id.clone(), json!({ "keywords/$flagged": true }));
|
|
let mut req = Request::new();
|
|
req.call(
|
|
"Email/set",
|
|
json!({ "accountId": account, "update": Value::Object(update) }),
|
|
"s",
|
|
);
|
|
let resp = req.send(&client, &session.api_url).expect("Email/set sent");
|
|
let mr = resp.first().expect("a method response");
|
|
assert!(
|
|
mr.args
|
|
.get("updated")
|
|
.and_then(|u| u.get(&jmap_id))
|
|
.is_some(),
|
|
"server accepted the keyword update: {:?}",
|
|
mr.args
|
|
);
|
|
|
|
let s2 = sync::import_jmap::run(
|
|
common(&archive, false),
|
|
import_cfg(AccountSelector::Id(acc.account_id.clone())),
|
|
)
|
|
.expect("second import");
|
|
|
|
let conn = Connection::open(&archive).unwrap();
|
|
let kw: String = conn
|
|
.query_row(
|
|
"SELECT keywords FROM emails WHERE id = ?1",
|
|
[local_id],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
kw.contains("$flagged"),
|
|
"delta re-import propagated the new flag into the archive: {kw}"
|
|
);
|
|
let em = s2
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "Email")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("email counts");
|
|
assert!(
|
|
em.updated >= 1,
|
|
"the changed email was detected via Email/changes and refreshed (updated={})",
|
|
em.updated
|
|
);
|
|
drop(conn);
|
|
seeder::teardown(base_url()).expect("teardown");
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|