327 lines
9.9 KiB
Rust
327 lines
9.9 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[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 vandelay::jmap::account::AccountSelector;
|
|
use vandelay::jmap::http::Auth;
|
|
use vandelay::logging::Logger;
|
|
use vandelay::sync::import_dav::{DavAuth, DavImportConfig, DavKindArg};
|
|
use vandelay::sync::{self, CommonConfig, ConnectConfig, ExportConfig, ImportConfig};
|
|
|
|
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) -> 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 webdav_cfg(root_url: String, domain: &str, localpart: &str) -> DavImportConfig {
|
|
DavImportConfig {
|
|
kind: DavKindArg::Webdav,
|
|
url: root_url,
|
|
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 file_blob_hashes(conn: &Connection) -> HashSet<Vec<u8>> {
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT blobs.hash FROM file_nodes
|
|
JOIN blobs ON blobs.id = file_nodes.blob_id
|
|
WHERE file_nodes.node_type = 'file'",
|
|
)
|
|
.unwrap();
|
|
let rows = stmt.query_map([], |r| r.get::<_, Vec<u8>>(0)).unwrap();
|
|
let mut out = HashSet::new();
|
|
for r in rows {
|
|
out.insert(r.unwrap());
|
|
}
|
|
out
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires Docker"]
|
|
fn webdav_import_test1_walks_directory_tree() {
|
|
let fx = seeder::provision(base_url()).expect("provision");
|
|
let acc = fx.account("test1").expect("test1");
|
|
let archive = tmp_archive("webdav");
|
|
|
|
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.file_nodes > 0, "test1 layout seeds file nodes");
|
|
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.calendars > 0, "test1 has seeded calendars");
|
|
assert!(seeded.events > 0, "test1 has seeded events");
|
|
assert!(seeded.identity, "test1 has a custom identity");
|
|
assert_eq!(
|
|
seeded.sieve_active,
|
|
Some(true),
|
|
"test1 has an active sieve script"
|
|
);
|
|
|
|
let root = format!("{}/dav/file/", fx.base_url);
|
|
let summary = sync::import_dav::run(
|
|
common(&archive),
|
|
webdav_cfg(root, &fx.domain, &acc.localpart),
|
|
)
|
|
.expect("import");
|
|
assert!(!summary.any_failed(), "import had failures: {summary:?}");
|
|
|
|
let conn = Connection::open(&archive).unwrap();
|
|
let nodes = count(&conn, "file_nodes") as usize;
|
|
assert!(
|
|
nodes >= seeded.file_nodes,
|
|
"imported file_nodes ({nodes}) covers seeded layout ({})",
|
|
seeded.file_nodes,
|
|
);
|
|
|
|
seeder::teardown(base_url()).expect("teardown");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires Docker"]
|
|
fn webdav_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("webdav-rt");
|
|
|
|
let root = format!("{}/dav/file/", fx.base_url);
|
|
let imp = sync::import_dav::run(
|
|
common(&archive),
|
|
webdav_cfg(root, &fx.domain, &src.localpart),
|
|
)
|
|
.expect("dav import");
|
|
assert!(!imp.any_failed(), "dav import had failures: {imp:?}");
|
|
|
|
let conn = Connection::open(&archive).unwrap();
|
|
let local_files: i64 = conn
|
|
.query_row(
|
|
"SELECT count(*) FROM file_nodes WHERE node_type = 'file'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert!(local_files > 0, "DAV import landed files 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_files_1: u64 = e1
|
|
.per_type
|
|
.iter()
|
|
.filter(|(t, _)| *t == "FileNode")
|
|
.map(|(_, c)| c.created)
|
|
.sum();
|
|
assert!(
|
|
created_files_1 > 0,
|
|
"first export created FileNode 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 webdav_cross_protocol_parity_with_jmap_matches_blob_hashes() {
|
|
let fx = seeder::provision(base_url()).expect("provision");
|
|
let src = fx.account("test1").expect("test1");
|
|
|
|
let archive_jmap = tmp_archive("webdav-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("webdav-parity-dav");
|
|
let root = format!("{}/dav/file/", fx.base_url);
|
|
let s_dav = sync::import_dav::run(
|
|
common(&archive_dav),
|
|
webdav_cfg(root, &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 hashes_jmap = file_blob_hashes(&conn_jmap);
|
|
drop(conn_jmap);
|
|
|
|
let conn_dav = Connection::open(&archive_dav).unwrap();
|
|
let hashes_dav = file_blob_hashes(&conn_dav);
|
|
drop(conn_dav);
|
|
|
|
assert!(
|
|
!hashes_jmap.is_empty(),
|
|
"jmap import should yield at least one file blob"
|
|
);
|
|
assert!(
|
|
!hashes_dav.is_empty(),
|
|
"dav import should yield at least one file blob"
|
|
);
|
|
let only_in_jmap: HashSet<_> = hashes_jmap.difference(&hashes_dav).collect();
|
|
let only_in_dav: HashSet<_> = hashes_dav.difference(&hashes_jmap).collect();
|
|
assert!(
|
|
only_in_jmap.is_empty() && only_in_dav.is_empty(),
|
|
"BLAKE3 hash sets must match across protocols (both store bytes verbatim): \
|
|
only-in-jmap={} only-in-dav={}",
|
|
only_in_jmap.len(),
|
|
only_in_dav.len(),
|
|
);
|
|
|
|
seeder::teardown(base_url()).expect("teardown");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires Docker"]
|
|
fn webdav_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("webdav-idem");
|
|
|
|
let root = format!("{}/dav/file/", fx.base_url);
|
|
sync::import_dav::run(
|
|
common(&archive),
|
|
webdav_cfg(root.clone(), &fx.domain, &acc.localpart),
|
|
)
|
|
.expect("import 1");
|
|
let conn1 = Connection::open(&archive).unwrap();
|
|
let n1 = count(&conn1, "file_nodes");
|
|
let b1 = count(&conn1, "blobs");
|
|
drop(conn1);
|
|
|
|
sync::import_dav::run(
|
|
common(&archive),
|
|
webdav_cfg(root, &fx.domain, &acc.localpart),
|
|
)
|
|
.expect("import 2");
|
|
let conn2 = Connection::open(&archive).unwrap();
|
|
let n2 = count(&conn2, "file_nodes");
|
|
let b2 = count(&conn2, "blobs");
|
|
assert_eq!(n1, n2, "second import does not duplicate file_nodes");
|
|
assert_eq!(
|
|
b1, b2,
|
|
"second import does not duplicate blobs (BLAKE3 dedup)"
|
|
);
|
|
|
|
seeder::teardown(base_url()).expect("teardown");
|
|
}
|