Initial commit
This commit is contained in:
@@ -0,0 +1,463 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use rusqlite::params;
|
||||
use testcontainers::core::{IntoContainerPort, WaitFor};
|
||||
use testcontainers::runners::{SyncBuilder, SyncRunner};
|
||||
use testcontainers::{Container, GenericBuildableImage, GenericImage, ImageExt};
|
||||
|
||||
use super::data::{RawFixture, load_icals, load_vcards, malformed_ical, rewrite_uid};
|
||||
use super::dav_client::DavSeed;
|
||||
use super::error::{ContainerError, ContainerResult};
|
||||
use super::layouts::{self, Layout};
|
||||
use super::{Account, Endpoint};
|
||||
|
||||
const BASE_IMAGE: &str = "ckulka/baikal:0.10.1-nginx";
|
||||
const HTTP_PORT: u16 = 80;
|
||||
const REALM: &str = "BaikalDAV";
|
||||
|
||||
const ADMIN_PASSWORD_HASH: &str = "004b53ae84286ba6003b34e8ef404826";
|
||||
const DIGEST_USER1: &str = "b7a47e657a4a966d0d844b1211416024";
|
||||
const DIGEST_USER2: &str = "9fc67834c5a099f0a2eccea3f6918169";
|
||||
const DIGEST_USER3: &str = "e1b30b108dad01b323a999b0bb1252ab";
|
||||
|
||||
const SCHEMA: &str = include_str!("baikal_schema.sql");
|
||||
|
||||
const BAIKAL_YAML: &str = "system:
|
||||
configured_version: '0.10.1'
|
||||
timezone: 'UTC'
|
||||
card_enabled: true
|
||||
cal_enabled: true
|
||||
invite_from: '[email protected]'
|
||||
dav_auth_type: 'Basic'
|
||||
admin_passwordhash: '004b53ae84286ba6003b34e8ef404826'
|
||||
failed_access_message: 'user %u authentication failure for Baikal'
|
||||
auth_realm: 'BaikalDAV'
|
||||
base_uri: ''
|
||||
database:
|
||||
backend: 'sqlite'
|
||||
sqlite_file: '/var/www/baikal/Specific/db/db.sqlite'
|
||||
";
|
||||
|
||||
const DOCKERFILE: &str = r#"FROM ckulka/baikal:0.10.1-nginx
|
||||
|
||||
COPY baikal.yaml /var/www/baikal/config/baikal.yaml
|
||||
COPY db.sqlite /var/www/baikal/Specific/db/db.sqlite
|
||||
RUN touch /var/www/baikal/Specific/INSTALL_DISABLED && \
|
||||
chown -R www-data:www-data /var/www/baikal/config /var/www/baikal/Specific && \
|
||||
chmod 0640 /var/www/baikal/config/baikal.yaml /var/www/baikal/Specific/db/db.sqlite
|
||||
"#;
|
||||
|
||||
pub struct Baikal {
|
||||
container: Container<GenericImage>,
|
||||
pub http: Endpoint,
|
||||
pub accounts: Vec<Account>,
|
||||
}
|
||||
|
||||
impl Baikal {
|
||||
pub fn start() -> ContainerResult<Self> {
|
||||
let db_bytes = build_sqlite_db()?;
|
||||
|
||||
let image: GenericImage = GenericBuildableImage::new("vandelay-baikal", "test")
|
||||
.with_dockerfile_string(DOCKERFILE.to_owned())
|
||||
.with_data(BAIKAL_YAML.as_bytes().to_vec(), "baikal.yaml")
|
||||
.with_data(db_bytes, "db.sqlite")
|
||||
.build_image()
|
||||
.map_err(|e| ContainerError::Seed(format!("baikal build: {e}")))?;
|
||||
|
||||
let _ = BASE_IMAGE;
|
||||
|
||||
let request = image
|
||||
.with_exposed_port(HTTP_PORT.tcp())
|
||||
.with_wait_for(WaitFor::message_on_stderr("start worker process"))
|
||||
.with_startup_timeout(Duration::from_secs(180));
|
||||
|
||||
let container = request.start()?;
|
||||
let host = container.get_host()?.to_string();
|
||||
let http = Endpoint::new(host, container.get_host_port_ipv4(HTTP_PORT.tcp())?);
|
||||
|
||||
let accounts: Vec<Account> = layouts::accounts()
|
||||
.iter()
|
||||
.map(|name| Account {
|
||||
username: (*name).to_owned(),
|
||||
password: layouts::PASSWORD.to_owned(),
|
||||
layout: layouts::layout_for(name),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Self {
|
||||
container,
|
||||
http,
|
||||
accounts,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> String {
|
||||
self.http.http_base()
|
||||
}
|
||||
|
||||
pub fn dav_root(&self) -> String {
|
||||
format!("{}/dav.php", self.base_url())
|
||||
}
|
||||
|
||||
pub fn calendar_home(&self, account: &Account) -> String {
|
||||
format!("{}/calendars/{}/", self.dav_root(), account.username)
|
||||
}
|
||||
|
||||
pub fn addressbook_home(&self, account: &Account) -> String {
|
||||
format!("{}/addressbooks/{}/", self.dav_root(), account.username)
|
||||
}
|
||||
|
||||
pub fn seed_all(&self) -> ContainerResult<Vec<AccountSeed>> {
|
||||
let icals = load_icals()?;
|
||||
let vcards = load_vcards()?;
|
||||
let mut out = Vec::new();
|
||||
for acct in &self.accounts {
|
||||
let seed = self.seed_account(acct, &icals, &vcards)?;
|
||||
out.push(seed);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn seed_account(
|
||||
&self,
|
||||
account: &Account,
|
||||
icals: &[RawFixture],
|
||||
vcards: &[RawFixture],
|
||||
) -> ContainerResult<AccountSeed> {
|
||||
let client = DavSeed::new(self.dav_root(), &account.username, &account.password);
|
||||
let layout = &account.layout;
|
||||
let mut seed = AccountSeed::new(account.username.clone());
|
||||
|
||||
for (idx, cal) in layout.calendars.iter().enumerate() {
|
||||
let segment = collection_segment("cal", idx, cal);
|
||||
let path = format!("/calendars/{}/{}/", account.username, segment);
|
||||
let mkcal = format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:set><d:prop>
|
||||
<d:displayname>{cal}</d:displayname>
|
||||
<c:supported-calendar-component-set>
|
||||
<c:comp name="VEVENT"/>
|
||||
<c:comp name="VTODO"/>
|
||||
</c:supported-calendar-component-set>
|
||||
</d:prop></d:set>
|
||||
</c:mkcalendar>"#
|
||||
);
|
||||
client.mkcalendar(&path, Some(&mkcal))?;
|
||||
let mut plan = CollectionPlan::new((*cal).to_owned(), path.clone());
|
||||
|
||||
let count = events_per_calendar(layout, idx);
|
||||
let offset = events_offset(layout, idx);
|
||||
for (i, fx) in icals.iter().cycle().skip(offset).take(count).enumerate() {
|
||||
let item = format!("{path}{}-{}.ics", fx.name, i);
|
||||
let suffix = format!("{segment}-{i}");
|
||||
let (bytes, uid) = rewrite_uid(&fx.bytes, &suffix).ok_or_else(|| {
|
||||
ContainerError::Seed(format!("ical fixture {} has no UID", fx.name))
|
||||
})?;
|
||||
client.put(&item, "text/calendar; charset=utf-8", &bytes)?;
|
||||
plan.items.push(SeededItem {
|
||||
uid,
|
||||
href: item,
|
||||
source: bytes,
|
||||
});
|
||||
}
|
||||
seed.calendars.push(plan);
|
||||
}
|
||||
|
||||
for (idx, ab) in layout.address_books.iter().enumerate() {
|
||||
let segment = collection_segment("ab", idx, ab);
|
||||
let path = format!("/addressbooks/{}/{}/", account.username, segment);
|
||||
let mkbook = format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:mkcol xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav">
|
||||
<d:set><d:prop>
|
||||
<d:resourcetype><d:collection/><c:addressbook/></d:resourcetype>
|
||||
<d:displayname>{ab}</d:displayname>
|
||||
</d:prop></d:set>
|
||||
</d:mkcol>"#
|
||||
);
|
||||
client.mkcol(&path, Some(&mkbook))?;
|
||||
let mut plan = CollectionPlan::new((*ab).to_owned(), path.clone());
|
||||
|
||||
let count = contacts_per_book(layout, idx);
|
||||
let offset = contacts_offset(layout, idx);
|
||||
for (i, fx) in vcards.iter().cycle().skip(offset).take(count).enumerate() {
|
||||
let item = format!("{path}{}-{}.vcf", fx.name, i);
|
||||
let suffix = format!("{segment}-{i}");
|
||||
let (bytes, uid) = rewrite_uid(&fx.bytes, &suffix).ok_or_else(|| {
|
||||
ContainerError::Seed(format!("vcard fixture {} has no UID", fx.name))
|
||||
})?;
|
||||
client.put(&item, "text/vcard; charset=utf-8", &bytes)?;
|
||||
plan.items.push(SeededItem {
|
||||
uid,
|
||||
href: item,
|
||||
source: bytes,
|
||||
});
|
||||
}
|
||||
seed.address_books.push(plan);
|
||||
}
|
||||
|
||||
Ok(seed)
|
||||
}
|
||||
|
||||
pub fn seed_malformed_event(
|
||||
&self,
|
||||
account: &Account,
|
||||
calendar_segment_idx: usize,
|
||||
tag: &str,
|
||||
) -> ContainerResult<String> {
|
||||
let client = DavSeed::new(self.dav_root(), &account.username, &account.password);
|
||||
let segment = collection_segment(
|
||||
"cal",
|
||||
calendar_segment_idx,
|
||||
account.layout.calendars[calendar_segment_idx],
|
||||
);
|
||||
let path = format!(
|
||||
"/calendars/{}/{}/broken-{tag}.ics",
|
||||
account.username, segment
|
||||
);
|
||||
let bad = malformed_ical(tag);
|
||||
client.put(&path, "text/calendar; charset=utf-8", &bad.bytes)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn delete_item(&self, account: &Account, path: &str) -> ContainerResult<()> {
|
||||
let client = DavSeed::new(self.dav_root(), &account.username, &account.password);
|
||||
client.delete(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_event(
|
||||
&self,
|
||||
account: &Account,
|
||||
calendar_segment_idx: usize,
|
||||
tag: &str,
|
||||
icals: &[RawFixture],
|
||||
) -> ContainerResult<(String, String)> {
|
||||
let client = DavSeed::new(self.dav_root(), &account.username, &account.password);
|
||||
let display = account.layout.calendars[calendar_segment_idx];
|
||||
let segment = collection_segment("cal", calendar_segment_idx, display);
|
||||
let fixture = icals.first().ok_or_else(|| {
|
||||
ContainerError::Seed("no ical fixture available for add_event".to_owned())
|
||||
})?;
|
||||
let suffix = format!("added-{tag}");
|
||||
let (bytes, uid) = rewrite_uid(&fixture.bytes, &suffix)
|
||||
.ok_or_else(|| ContainerError::Seed("ical fixture has no UID".to_owned()))?;
|
||||
let path = format!("/calendars/{}/{segment}/added-{tag}.ics", account.username);
|
||||
client.put(&path, "text/calendar; charset=utf-8", &bytes)?;
|
||||
Ok((path, uid))
|
||||
}
|
||||
|
||||
pub fn add_contact(
|
||||
&self,
|
||||
account: &Account,
|
||||
book_segment_idx: usize,
|
||||
tag: &str,
|
||||
vcards: &[RawFixture],
|
||||
) -> ContainerResult<(String, String)> {
|
||||
let client = DavSeed::new(self.dav_root(), &account.username, &account.password);
|
||||
let display = account.layout.address_books[book_segment_idx];
|
||||
let segment = collection_segment("ab", book_segment_idx, display);
|
||||
let fixture = vcards.first().ok_or_else(|| {
|
||||
ContainerError::Seed("no vcard fixture available for add_contact".to_owned())
|
||||
})?;
|
||||
let suffix = format!("added-{tag}");
|
||||
let (bytes, uid) = rewrite_uid(&fixture.bytes, &suffix)
|
||||
.ok_or_else(|| ContainerError::Seed("vcard fixture has no UID".to_owned()))?;
|
||||
let path = format!(
|
||||
"/addressbooks/{}/{segment}/added-{tag}.vcf",
|
||||
account.username
|
||||
);
|
||||
client.put(&path, "text/vcard; charset=utf-8", &bytes)?;
|
||||
Ok((path, uid))
|
||||
}
|
||||
|
||||
pub fn verify_seed(&self, seeds: &[AccountSeed]) -> ContainerResult<()> {
|
||||
for (acct, seed) in self.accounts.iter().zip(seeds) {
|
||||
let client = DavSeed::new(self.dav_root(), &acct.username, &acct.password);
|
||||
let body = client.propfind(&format!("/calendars/{}/", acct.username), 1)?;
|
||||
if !body.contains("multistatus") {
|
||||
return Err(ContainerError::Protocol(format!(
|
||||
"baikal propfind for {} returned no multistatus",
|
||||
acct.username
|
||||
)));
|
||||
}
|
||||
let _ = seed;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop(self) -> ContainerResult<()> {
|
||||
self.container.stop()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_sqlite_db() -> ContainerResult<Vec<u8>> {
|
||||
let tmp = tempfile::NamedTempFile::new()
|
||||
.map_err(|e| ContainerError::Seed(format!("baikal tempfile: {e}")))?;
|
||||
let path = tmp.path().to_path_buf();
|
||||
drop(tmp);
|
||||
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&path)
|
||||
.map_err(|e| ContainerError::Seed(format!("baikal sqlite open: {e}")))?;
|
||||
conn.execute_batch(SCHEMA)
|
||||
.map_err(|e| ContainerError::Seed(format!("baikal schema: {e}")))?;
|
||||
|
||||
let users: [(&str, &str); 3] = [
|
||||
(layouts::ACCOUNT1, DIGEST_USER1),
|
||||
(layouts::ACCOUNT2, DIGEST_USER2),
|
||||
(layouts::ACCOUNT3, DIGEST_USER3),
|
||||
];
|
||||
for (name, digest) in users {
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, digesta1) VALUES (?1, ?2)",
|
||||
params![name, digest],
|
||||
)
|
||||
.map_err(|e| ContainerError::Seed(format!("baikal user {name}: {e}")))?;
|
||||
let uri = format!("principals/{name}");
|
||||
let email = format!("{name}@vandelay.test");
|
||||
let display = format!("Vandelay {name}");
|
||||
conn.execute(
|
||||
"INSERT INTO principals (uri, email, displayname) VALUES (?1, ?2, ?3)",
|
||||
params![uri, email, display],
|
||||
)
|
||||
.map_err(|e| ContainerError::Seed(format!("baikal principal {name}: {e}")))?;
|
||||
conn.execute(
|
||||
"INSERT INTO principals (uri, email, displayname) VALUES (?1, NULL, NULL)",
|
||||
params![format!("{uri}/calendar-proxy-read")],
|
||||
)
|
||||
.map_err(|e| ContainerError::Seed(format!("baikal cal-proxy-read {name}: {e}")))?;
|
||||
conn.execute(
|
||||
"INSERT INTO principals (uri, email, displayname) VALUES (?1, NULL, NULL)",
|
||||
params![format!("{uri}/calendar-proxy-write")],
|
||||
)
|
||||
.map_err(|e| ContainerError::Seed(format!("baikal cal-proxy-write {name}: {e}")))?;
|
||||
}
|
||||
|
||||
let _ = REALM;
|
||||
let _ = ADMIN_PASSWORD_HASH;
|
||||
}
|
||||
|
||||
let bytes =
|
||||
std::fs::read(&path).map_err(|e| ContainerError::Seed(format!("baikal read db: {e}")))?;
|
||||
let _ = std::fs::remove_file(&path);
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn collection_segment(prefix: &str, idx: usize, name: &str) -> String {
|
||||
let mut out = format!("{prefix}-{:02}-", idx);
|
||||
for c in name.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
out.push(c.to_ascii_lowercase());
|
||||
} else if !out.ends_with('-') {
|
||||
out.push('-');
|
||||
}
|
||||
}
|
||||
out.trim_end_matches('-').to_owned()
|
||||
}
|
||||
|
||||
fn events_per_calendar(layout: &Layout, idx: usize) -> usize {
|
||||
if layout.calendars.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let base = layout.event_count / layout.calendars.len().max(1);
|
||||
let extra = if idx == 0 {
|
||||
layout.event_count % layout.calendars.len()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
base + extra
|
||||
}
|
||||
|
||||
fn events_offset(layout: &Layout, idx: usize) -> usize {
|
||||
(0..idx).map(|i| events_per_calendar(layout, i)).sum()
|
||||
}
|
||||
|
||||
fn contacts_per_book(layout: &Layout, idx: usize) -> usize {
|
||||
if layout.address_books.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let base = layout.contact_count / layout.address_books.len().max(1);
|
||||
let extra = if idx == 0 {
|
||||
layout.contact_count % layout.address_books.len()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
base + extra
|
||||
}
|
||||
|
||||
fn contacts_offset(layout: &Layout, idx: usize) -> usize {
|
||||
(0..idx).map(|i| contacts_per_book(layout, i)).sum()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SeededItem {
|
||||
pub uid: String,
|
||||
pub href: String,
|
||||
pub source: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CollectionPlan {
|
||||
pub display_name: String,
|
||||
pub base_href: String,
|
||||
pub items: Vec<SeededItem>,
|
||||
}
|
||||
|
||||
impl CollectionPlan {
|
||||
fn new(display_name: String, base_href: String) -> Self {
|
||||
Self {
|
||||
display_name,
|
||||
base_href,
|
||||
items: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccountSeed {
|
||||
pub username: String,
|
||||
pub calendars: Vec<CollectionPlan>,
|
||||
pub address_books: Vec<CollectionPlan>,
|
||||
}
|
||||
|
||||
impl AccountSeed {
|
||||
fn new(username: String) -> Self {
|
||||
Self {
|
||||
username,
|
||||
calendars: Vec::new(),
|
||||
address_books: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn total_events(&self) -> usize {
|
||||
self.calendars.iter().map(|c| c.items.len()).sum()
|
||||
}
|
||||
|
||||
pub fn total_contacts(&self) -> usize {
|
||||
self.address_books.iter().map(|c| c.items.len()).sum()
|
||||
}
|
||||
|
||||
pub fn event_uids(&self) -> HashSet<String> {
|
||||
self.calendars
|
||||
.iter()
|
||||
.flat_map(|c| c.items.iter().map(|i| i.uid.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn contact_uids(&self) -> HashSet<String> {
|
||||
self.address_books
|
||||
.iter()
|
||||
.flat_map(|c| c.items.iter().map(|i| i.uid.clone()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
CREATE TABLE addressbooks (
|
||||
id integer primary key asc NOT NULL,
|
||||
principaluri text NOT NULL,
|
||||
displayname text,
|
||||
uri text NOT NULL,
|
||||
description text,
|
||||
synctoken integer DEFAULT 1 NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE cards (
|
||||
id integer primary key asc NOT NULL,
|
||||
addressbookid integer NOT NULL,
|
||||
carddata blob,
|
||||
uri text NOT NULL,
|
||||
lastmodified integer,
|
||||
etag text,
|
||||
size integer
|
||||
);
|
||||
|
||||
CREATE TABLE addressbookchanges (
|
||||
id integer primary key asc NOT NULL,
|
||||
uri text,
|
||||
synctoken integer NOT NULL,
|
||||
addressbookid integer NOT NULL,
|
||||
operation integer NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX addressbookid_synctoken ON addressbookchanges (addressbookid, synctoken);
|
||||
CREATE TABLE calendarobjects (
|
||||
id integer primary key asc NOT NULL,
|
||||
calendardata blob NOT NULL,
|
||||
uri text NOT NULL,
|
||||
calendarid integer NOT NULL,
|
||||
lastmodified integer NOT NULL,
|
||||
etag text NOT NULL,
|
||||
size integer NOT NULL,
|
||||
componenttype text,
|
||||
firstoccurence integer,
|
||||
lastoccurence integer,
|
||||
uid text
|
||||
);
|
||||
|
||||
CREATE TABLE calendars (
|
||||
id integer primary key asc NOT NULL,
|
||||
synctoken integer DEFAULT 1 NOT NULL,
|
||||
components text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE calendarinstances (
|
||||
id integer primary key asc NOT NULL,
|
||||
calendarid integer,
|
||||
principaluri text,
|
||||
access integer,
|
||||
displayname text,
|
||||
uri text NOT NULL,
|
||||
description text,
|
||||
calendarorder integer,
|
||||
calendarcolor text,
|
||||
timezone text,
|
||||
transparent bool,
|
||||
share_href text,
|
||||
share_displayname text,
|
||||
share_invitestatus integer DEFAULT '2',
|
||||
UNIQUE (principaluri, uri),
|
||||
UNIQUE (calendarid, principaluri),
|
||||
UNIQUE (calendarid, share_href)
|
||||
);
|
||||
|
||||
CREATE TABLE calendarchanges (
|
||||
id integer primary key asc NOT NULL,
|
||||
uri text,
|
||||
synctoken integer NOT NULL,
|
||||
calendarid integer NOT NULL,
|
||||
operation integer NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX calendarid_synctoken ON calendarchanges (calendarid, synctoken);
|
||||
|
||||
CREATE TABLE calendarsubscriptions (
|
||||
id integer primary key asc NOT NULL,
|
||||
uri text NOT NULL,
|
||||
principaluri text NOT NULL,
|
||||
source text NOT NULL,
|
||||
displayname text,
|
||||
refreshrate text,
|
||||
calendarorder integer,
|
||||
calendarcolor text,
|
||||
striptodos bool,
|
||||
stripalarms bool,
|
||||
stripattachments bool,
|
||||
lastmodified int
|
||||
);
|
||||
|
||||
CREATE TABLE schedulingobjects (
|
||||
id integer primary key asc NOT NULL,
|
||||
principaluri text NOT NULL,
|
||||
calendardata blob,
|
||||
uri text NOT NULL,
|
||||
lastmodified integer,
|
||||
etag text NOT NULL,
|
||||
size integer NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX principaluri_uri ON calendarsubscriptions (principaluri, uri);
|
||||
BEGIN TRANSACTION;
|
||||
CREATE TABLE locks (
|
||||
id integer primary key asc NOT NULL,
|
||||
owner text,
|
||||
timeout integer,
|
||||
created integer,
|
||||
token text,
|
||||
scope integer,
|
||||
depth integer,
|
||||
uri text
|
||||
);
|
||||
COMMIT;
|
||||
CREATE TABLE principals (
|
||||
id INTEGER PRIMARY KEY ASC NOT NULL,
|
||||
uri TEXT NOT NULL,
|
||||
email TEXT,
|
||||
displayname TEXT,
|
||||
UNIQUE(uri)
|
||||
);
|
||||
|
||||
CREATE TABLE groupmembers (
|
||||
id INTEGER PRIMARY KEY ASC NOT NULL,
|
||||
principal_id INTEGER NOT NULL,
|
||||
member_id INTEGER NOT NULL,
|
||||
UNIQUE(principal_id, member_id)
|
||||
);
|
||||
|
||||
CREATE TABLE propertystorage (
|
||||
id integer primary key asc NOT NULL,
|
||||
path text NOT NULL,
|
||||
name text NOT NULL,
|
||||
valuetype integer NOT NULL,
|
||||
value string
|
||||
);
|
||||
|
||||
|
||||
CREATE UNIQUE INDEX path_property ON propertystorage (path, name);
|
||||
CREATE TABLE users (
|
||||
id integer primary key asc NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
digesta1 TEXT NOT NULL,
|
||||
UNIQUE(username)
|
||||
);
|
||||
@@ -0,0 +1,393 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use testcontainers::core::{IntoContainerPort, WaitFor};
|
||||
use testcontainers::runners::{SyncBuilder, SyncRunner};
|
||||
use testcontainers::{Container, GenericBuildableImage, GenericImage, ImageExt};
|
||||
|
||||
use super::data::{MboxMessage, load_mbox};
|
||||
use super::error::{ContainerError, ContainerResult};
|
||||
use super::imap_client::{ImapSeed, full_path};
|
||||
use super::layouts::{self};
|
||||
use super::{Account, Endpoint};
|
||||
|
||||
const IMAP_PORT: u16 = 143;
|
||||
|
||||
const DOCKERFILE: &str = r#"FROM debian:bookworm-20260518-slim
|
||||
|
||||
RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
cyrus-imapd cyrus-admin cyrus-clients sasl2-bin \
|
||||
libsasl2-modules libsasl2-modules-db \
|
||||
ca-certificates && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
mkdir -p /var/lib/cyrus /var/spool/cyrus /run/cyrus && \
|
||||
chown -R cyrus:mail /var/lib/cyrus /var/spool/cyrus /run/cyrus
|
||||
|
||||
COPY imapd.conf /etc/imapd.conf
|
||||
COPY cyrus.conf /etc/cyrus.conf
|
||||
COPY sasldb.txt /tmp/sasldb.txt
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 143
|
||||
CMD ["/entrypoint.sh"]
|
||||
"#;
|
||||
|
||||
const IMAPD_CONF: &str = r#"configdirectory: /var/lib/cyrus
|
||||
partition-default: /var/spool/cyrus
|
||||
admins: cyrus
|
||||
allowanonymouslogin: no
|
||||
allowplaintext: yes
|
||||
sasl_mech_list: PLAIN LOGIN
|
||||
sasl_pwcheck_method: auxprop
|
||||
sasl_auxprop_plugin: sasldb
|
||||
sasl_sasldb_path: /etc/sasldb2
|
||||
unixhierarchysep: yes
|
||||
altnamespace: yes
|
||||
sasl_minimum_layer: 0
|
||||
"#;
|
||||
|
||||
const CYRUS_CONF: &str = r#"START {
|
||||
recover cmd="ctl_cyrusdb -r"
|
||||
}
|
||||
|
||||
SERVICES {
|
||||
imap cmd="imapd" listen="143" prefork=0
|
||||
}
|
||||
|
||||
EVENTS {
|
||||
checkpoint cmd="ctl_cyrusdb -c" period=30
|
||||
delprune cmd="cyr_expire -E 3" at=0400
|
||||
}
|
||||
"#;
|
||||
|
||||
fn sasldb_entries() -> String {
|
||||
let mut out = String::new();
|
||||
for u in layouts::accounts() {
|
||||
out.push_str(u);
|
||||
out.push(' ');
|
||||
out.push_str(layouts::PASSWORD);
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str("cyrus admin-secret\n");
|
||||
out
|
||||
}
|
||||
|
||||
const ENTRYPOINT: &str = r#"#!/bin/bash
|
||||
set -e
|
||||
|
||||
while IFS=' ' read -r user pw; do
|
||||
[ -z "$user" ] && continue
|
||||
echo "$pw" | saslpasswd2 -p -c -f /etc/sasldb2 "$user"
|
||||
done < /tmp/sasldb.txt
|
||||
chown cyrus:mail /etc/sasldb2
|
||||
chmod 0640 /etc/sasldb2
|
||||
|
||||
mkdir -p /var/lib/cyrus /var/spool/cyrus /run/cyrus
|
||||
chown -R cyrus:mail /var/lib/cyrus /var/spool/cyrus /run/cyrus
|
||||
su cyrus -s /bin/bash -c '/usr/sbin/cyrus makedirs' || true
|
||||
|
||||
/usr/sbin/cyrmaster -C /etc/imapd.conf -M /etc/cyrus.conf &
|
||||
master_pid=$!
|
||||
|
||||
for _ in $(seq 1 120); do
|
||||
if (exec 3<>/dev/tcp/127.0.0.1/143) 2>/dev/null; then
|
||||
exec 3<&- 3>&-
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
provision() {
|
||||
exec 3<>/dev/tcp/127.0.0.1/143
|
||||
read -r _greeting <&3
|
||||
printf 'a1 LOGIN cyrus admin-secret\r\n' >&3
|
||||
read -r _ <&3
|
||||
local tag=1
|
||||
for u in "$@"; do
|
||||
tag=$((tag + 1))
|
||||
printf 'a%d CREATE user/%s\r\n' "$tag" "$u" >&3
|
||||
read -r _ <&3
|
||||
done
|
||||
tag=$((tag + 1))
|
||||
printf 'a%d LOGOUT\r\n' "$tag" >&3
|
||||
read -r _ <&3
|
||||
exec 3<&- 3>&-
|
||||
}
|
||||
|
||||
users=()
|
||||
while IFS=' ' read -r user _pw; do
|
||||
[ -z "$user" ] && continue
|
||||
[ "$user" = "cyrus" ] && continue
|
||||
users+=("$user")
|
||||
done < /tmp/sasldb.txt
|
||||
provision "${users[@]}"
|
||||
|
||||
echo "cyrus provisioned"
|
||||
wait "$master_pid"
|
||||
"#;
|
||||
|
||||
pub struct Cyrus {
|
||||
container: Container<GenericImage>,
|
||||
pub imap: Endpoint,
|
||||
pub accounts: Vec<Account>,
|
||||
}
|
||||
|
||||
impl Cyrus {
|
||||
pub fn start() -> ContainerResult<Self> {
|
||||
let image: GenericImage = GenericBuildableImage::new("vandelay-cyrus", "test")
|
||||
.with_dockerfile_string(DOCKERFILE.to_owned())
|
||||
.with_data(IMAPD_CONF.as_bytes().to_vec(), "imapd.conf")
|
||||
.with_data(CYRUS_CONF.as_bytes().to_vec(), "cyrus.conf")
|
||||
.with_data(sasldb_entries().into_bytes(), "sasldb.txt")
|
||||
.with_data(ENTRYPOINT.as_bytes().to_vec(), "entrypoint.sh")
|
||||
.build_image()
|
||||
.map_err(|e| ContainerError::Seed(format!("cyrus build: {e}")))?;
|
||||
|
||||
let request = image
|
||||
.with_exposed_port(IMAP_PORT.tcp())
|
||||
.with_wait_for(WaitFor::message_on_stdout("cyrus provisioned"))
|
||||
.with_startup_timeout(Duration::from_secs(180));
|
||||
|
||||
let container = request.start()?;
|
||||
let host = container.get_host()?.to_string();
|
||||
let imap = Endpoint::new(host, container.get_host_port_ipv4(IMAP_PORT.tcp())?);
|
||||
|
||||
let accounts: Vec<Account> = layouts::accounts()
|
||||
.iter()
|
||||
.map(|name| Account {
|
||||
username: (*name).to_owned(),
|
||||
password: layouts::PASSWORD.to_owned(),
|
||||
layout: layouts::layout_for(name),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Self {
|
||||
container,
|
||||
imap,
|
||||
accounts,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn seed_all(&self) -> ContainerResult<Vec<AccountSeed>> {
|
||||
let limit = self
|
||||
.accounts
|
||||
.iter()
|
||||
.map(|a| a.layout.email_count)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max(1);
|
||||
let messages = load_mbox(limit)?;
|
||||
let mut out = Vec::new();
|
||||
for acct in &self.accounts {
|
||||
let m = self.seed_account(acct, &messages)?;
|
||||
out.push(m);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn seed_account(
|
||||
&self,
|
||||
account: &Account,
|
||||
messages: &[MboxMessage],
|
||||
) -> ContainerResult<AccountSeed> {
|
||||
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
|
||||
client.login(&account.username, &account.password)?;
|
||||
let sep = client.discover_separator().unwrap_or('/');
|
||||
|
||||
let mut paths = Vec::new();
|
||||
for spec in account.layout.mailboxes {
|
||||
let path = full_path(account.layout.mailboxes, spec.key, sep).ok_or_else(|| {
|
||||
ContainerError::Seed(format!("missing mailbox key: {}", spec.key))
|
||||
})?;
|
||||
client.create(&path)?;
|
||||
client.subscribe(&path)?;
|
||||
paths.push(path);
|
||||
}
|
||||
let mut targets: Vec<String> = vec!["INBOX".to_owned()];
|
||||
targets.extend(paths.iter().cloned());
|
||||
let mut histogram: HashMap<String, usize> =
|
||||
targets.iter().map(|t| (t.clone(), 0)).collect();
|
||||
let mut appends: Vec<SeededAppend> = Vec::new();
|
||||
|
||||
let total = account.layout.email_count.min(messages.len());
|
||||
if total < account.layout.email_count {
|
||||
return Err(ContainerError::Seed(format!(
|
||||
"{} only has {} messages but layout requires {}",
|
||||
account.username,
|
||||
messages.len(),
|
||||
account.layout.email_count
|
||||
)));
|
||||
}
|
||||
for (i, msg) in messages.iter().take(total).enumerate() {
|
||||
let target = targets[i % targets.len()].clone();
|
||||
client.append_with_flags(&target, &[], &msg.raw)?;
|
||||
*histogram.entry(target.clone()).or_insert(0) += 1;
|
||||
appends.push(SeededAppend {
|
||||
raw: msg.raw.clone(),
|
||||
target,
|
||||
flags: Vec::new(),
|
||||
tag: SeedTag::Bulk(i),
|
||||
});
|
||||
}
|
||||
|
||||
let mut dedup_target: Option<String> = None;
|
||||
if targets.len() > 1 && !messages.is_empty() {
|
||||
let target = targets[1].clone();
|
||||
client.append_with_flags(&target, &[], &messages[0].raw)?;
|
||||
*histogram.entry(target.clone()).or_insert(0) += 1;
|
||||
appends.push(SeededAppend {
|
||||
raw: messages[0].raw.clone(),
|
||||
target: target.clone(),
|
||||
flags: Vec::new(),
|
||||
tag: SeedTag::Dedup,
|
||||
});
|
||||
dedup_target = Some(target);
|
||||
}
|
||||
|
||||
let mut flagged_target: Option<String> = None;
|
||||
if !messages.is_empty() {
|
||||
let target = "INBOX".to_owned();
|
||||
let probe = flag_probe_message();
|
||||
client.append_with_flags(&target, &["\\Seen", "\\Flagged"], &probe)?;
|
||||
*histogram.entry(target.clone()).or_insert(0) += 1;
|
||||
appends.push(SeededAppend {
|
||||
raw: probe,
|
||||
target: target.clone(),
|
||||
flags: vec!["$seen".to_owned(), "$flagged".to_owned()],
|
||||
tag: SeedTag::FlagProbe,
|
||||
});
|
||||
flagged_target = Some(target);
|
||||
}
|
||||
|
||||
client.logout()?;
|
||||
|
||||
let total_appends = histogram.values().sum();
|
||||
Ok(AccountSeed {
|
||||
username: account.username.clone(),
|
||||
paths,
|
||||
histogram,
|
||||
total_appends,
|
||||
dedup_target,
|
||||
flagged_target,
|
||||
appends,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_first_inbox_message(&self, account: &Account) -> ContainerResult<()> {
|
||||
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
|
||||
client.login(&account.username, &account.password)?;
|
||||
client.delete_and_expunge_first("INBOX")?;
|
||||
client.logout()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn append_new_message(
|
||||
&self,
|
||||
account: &Account,
|
||||
mailbox: &str,
|
||||
tag: &str,
|
||||
) -> ContainerResult<(Vec<u8>, String)> {
|
||||
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
|
||||
client.login(&account.username, &account.password)?;
|
||||
let message_id = format!("<cyrus-added-{tag}-{}@vandelay.test>", account.username);
|
||||
let body = format!(
|
||||
"From: cyrus-added-{tag}@vandelay.test\r\n\
|
||||
To: {}@vandelay.test\r\n\
|
||||
Subject: Cyrus added probe {tag}\r\n\
|
||||
Message-ID: {message_id}\r\n\
|
||||
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
|
||||
\r\n\
|
||||
Cyrus added probe body {tag}.\r\n",
|
||||
account.username
|
||||
);
|
||||
let raw = body.into_bytes();
|
||||
client.append_with_flags(mailbox, &[], &raw)?;
|
||||
client.logout()?;
|
||||
Ok((raw, message_id))
|
||||
}
|
||||
|
||||
pub fn verify_seed(&self, seeds: &[AccountSeed]) -> ContainerResult<()> {
|
||||
for (acct, seed) in self.accounts.iter().zip(seeds) {
|
||||
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
|
||||
client.login(&acct.username, &acct.password)?;
|
||||
let names = client.list_all()?;
|
||||
let expected = acct.layout.mailboxes.len() + 1;
|
||||
if names.len() < expected {
|
||||
return Err(ContainerError::Seed(format!(
|
||||
"cyrus {}: expected >= {expected} mailboxes, got {}",
|
||||
acct.username,
|
||||
names.len()
|
||||
)));
|
||||
}
|
||||
let inbox_n = client.select("INBOX")?;
|
||||
if acct.layout.email_count > 0 && inbox_n == 0 {
|
||||
return Err(ContainerError::Seed(format!(
|
||||
"cyrus {}: INBOX EXISTS = 0 after seed",
|
||||
acct.username
|
||||
)));
|
||||
}
|
||||
for path in &seed.paths {
|
||||
let n = client.select(path)?;
|
||||
let want = seed.histogram.get(path).copied().unwrap_or(0);
|
||||
if n < want {
|
||||
return Err(ContainerError::Seed(format!(
|
||||
"cyrus {}: mailbox {path} EXISTS={n} but {want} were appended",
|
||||
acct.username
|
||||
)));
|
||||
}
|
||||
}
|
||||
client.logout()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop(self) -> ContainerResult<()> {
|
||||
self.container.stop()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn flag_probe_message() -> Vec<u8> {
|
||||
let body = "From: [email protected]\r\n\
|
||||
To: [email protected]\r\n\
|
||||
Subject: Cyrus flag probe\r\n\
|
||||
Message-ID: <[email protected]>\r\n\
|
||||
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
|
||||
\r\n\
|
||||
Cyrus flag probe body.\r\n";
|
||||
body.as_bytes().to_vec()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccountSeed {
|
||||
pub username: String,
|
||||
pub paths: Vec<String>,
|
||||
pub histogram: HashMap<String, usize>,
|
||||
pub total_appends: usize,
|
||||
pub dedup_target: Option<String>,
|
||||
pub flagged_target: Option<String>,
|
||||
pub appends: Vec<SeededAppend>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SeededAppend {
|
||||
pub raw: Vec<u8>,
|
||||
pub target: String,
|
||||
pub flags: Vec<String>,
|
||||
pub tag: SeedTag,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SeedTag {
|
||||
Bulk(usize),
|
||||
Dedup,
|
||||
FlagProbe,
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::io::{BufReader, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use flate2::read::GzDecoder;
|
||||
use mail_parser::mailbox::mbox::MessageIterator;
|
||||
|
||||
use super::error::{ContainerError, ContainerResult};
|
||||
|
||||
fn resources_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("resources")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MboxMessage {
|
||||
pub raw: Vec<u8>,
|
||||
pub received_at: i64,
|
||||
}
|
||||
|
||||
pub fn load_mbox(limit: usize) -> ContainerResult<Vec<MboxMessage>> {
|
||||
let path = resources_dir().join("mailbox.gz");
|
||||
if path.exists() {
|
||||
let file = std::fs::File::open(&path)?;
|
||||
let mut decoder = GzDecoder::new(file);
|
||||
let mut bytes = Vec::new();
|
||||
decoder.read_to_end(&mut bytes)?;
|
||||
let reader = BufReader::new(std::io::Cursor::new(bytes));
|
||||
let mut out = Vec::new();
|
||||
for item in MessageIterator::new(reader) {
|
||||
let message = item.map_err(|e| ContainerError::Resource(format!("mbox parse: {e}")))?;
|
||||
let received_at = message.internal_date() as i64;
|
||||
out.push(MboxMessage {
|
||||
raw: message.unwrap_contents(),
|
||||
received_at,
|
||||
});
|
||||
if out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !out.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
if let Ok(takeout) = load_takeout_mbox("all_mail_including_spam_and_trash.mbox")
|
||||
&& !takeout.is_empty()
|
||||
{
|
||||
return Ok(takeout.into_iter().take(limit).collect());
|
||||
}
|
||||
Ok(synth_messages(limit))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RawFixture {
|
||||
pub name: String,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn load_vcards() -> ContainerResult<Vec<RawFixture>> {
|
||||
Ok(synth_vcards(48))
|
||||
}
|
||||
|
||||
pub fn load_icals() -> ContainerResult<Vec<RawFixture>> {
|
||||
Ok(synth_icals(48))
|
||||
}
|
||||
|
||||
pub fn load_takeout_mbox(name: &str) -> ContainerResult<Vec<MboxMessage>> {
|
||||
let path = resources_dir().join("takeout").join(name);
|
||||
let bytes = std::fs::read(&path)
|
||||
.map_err(|e| ContainerError::Resource(format!("open {}: {e}", path.display())))?;
|
||||
let reader = BufReader::new(std::io::Cursor::new(bytes));
|
||||
let mut out = Vec::new();
|
||||
for item in MessageIterator::new(reader) {
|
||||
let message = item.map_err(|e| ContainerError::Resource(format!("mbox parse: {e}")))?;
|
||||
let received_at = message.internal_date() as i64;
|
||||
out.push(MboxMessage {
|
||||
raw: message.unwrap_contents(),
|
||||
received_at,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn synth_messages(n: usize) -> Vec<MboxMessage> {
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let body = format!(
|
||||
"From: sender{i}@vandelay.test\r\n\
|
||||
To: [email protected]\r\n\
|
||||
Subject: Synthetic test message {i}\r\n\
|
||||
Message-ID: <synth-{i}@vandelay.test>\r\n\
|
||||
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
|
||||
MIME-Version: 1.0\r\n\
|
||||
Content-Type: text/plain; charset=utf-8\r\n\
|
||||
\r\n\
|
||||
This is synthetic seed message #{i} for vandelay container tests.\r\n",
|
||||
);
|
||||
out.push(MboxMessage {
|
||||
raw: body.into_bytes(),
|
||||
received_at: 0,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
const VCARD_VARIANTS: &[fn(usize) -> String] = &[
|
||||
vcard_v3_basic,
|
||||
vcard_v3_full,
|
||||
vcard_v3_multi_email,
|
||||
vcard_v3_org_and_title,
|
||||
vcard_v4_basic,
|
||||
vcard_v4_with_address,
|
||||
vcard_v4_nickname,
|
||||
vcard_v4_birthday,
|
||||
];
|
||||
|
||||
fn synth_vcards(n: usize) -> Vec<RawFixture> {
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let body = VCARD_VARIANTS[i % VCARD_VARIANTS.len()](i);
|
||||
out.push(RawFixture {
|
||||
name: format!("synth-{i:03}"),
|
||||
bytes: body.into_bytes(),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn vcard_v3_basic(i: usize) -> String {
|
||||
format!(
|
||||
"BEGIN:VCARD\r\n\
|
||||
VERSION:3.0\r\n\
|
||||
UID:vandelay-card-basic-{i}\r\n\
|
||||
FN:Basic Contact {i}\r\n\
|
||||
N:Contact{i};Basic;;;\r\n\
|
||||
EMAIL;TYPE=INTERNET:basic-{i}@vandelay.test\r\n\
|
||||
END:VCARD\r\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn vcard_v3_full(i: usize) -> String {
|
||||
format!(
|
||||
"BEGIN:VCARD\r\n\
|
||||
VERSION:3.0\r\n\
|
||||
UID:vandelay-card-full-{i}\r\n\
|
||||
FN:Full Contact {i}\r\n\
|
||||
N:Contact{i};Full;Middle;;\r\n\
|
||||
EMAIL;TYPE=INTERNET;TYPE=WORK:full-{i}@vandelay.test\r\n\
|
||||
TEL;TYPE=CELL:+15550010{i:03}\r\n\
|
||||
ORG:Vandelay Test\r\n\
|
||||
TITLE:Senior Test Engineer\r\n\
|
||||
URL:https://vandelay.test/{i}\r\n\
|
||||
NOTE:Synthetic full v3 contact #{i}\r\n\
|
||||
END:VCARD\r\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn vcard_v3_multi_email(i: usize) -> String {
|
||||
format!(
|
||||
"BEGIN:VCARD\r\n\
|
||||
VERSION:3.0\r\n\
|
||||
UID:vandelay-card-multi-{i}\r\n\
|
||||
FN:Multi Email {i}\r\n\
|
||||
N:Contact{i};MultiEmail;;;\r\n\
|
||||
EMAIL;TYPE=INTERNET;TYPE=HOME:multi-{i}[email protected]\r\n\
|
||||
EMAIL;TYPE=INTERNET;TYPE=WORK:multi-{i}[email protected]\r\n\
|
||||
END:VCARD\r\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn vcard_v3_org_and_title(i: usize) -> String {
|
||||
format!(
|
||||
"BEGIN:VCARD\r\n\
|
||||
VERSION:3.0\r\n\
|
||||
UID:vandelay-card-org-{i}\r\n\
|
||||
FN:Org Contact {i}\r\n\
|
||||
N:Contact{i};Org;;Dr.;PhD\r\n\
|
||||
ORG:Vandelay Industries;Engineering;Test Group\r\n\
|
||||
TITLE:Principal Researcher\r\n\
|
||||
EMAIL;TYPE=INTERNET:org-{i}@vandelay.test\r\n\
|
||||
END:VCARD\r\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn vcard_v4_basic(i: usize) -> String {
|
||||
format!(
|
||||
"BEGIN:VCARD\r\n\
|
||||
VERSION:4.0\r\n\
|
||||
UID:urn:uuid:00000000-0000-4000-a000-0000{i:08x}\r\n\
|
||||
FN:V4 Basic {i}\r\n\
|
||||
N:Contact{i};V4Basic;;;\r\n\
|
||||
EMAIL:v4-basic-{i}@vandelay.test\r\n\
|
||||
END:VCARD\r\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn vcard_v4_with_address(i: usize) -> String {
|
||||
format!(
|
||||
"BEGIN:VCARD\r\n\
|
||||
VERSION:4.0\r\n\
|
||||
UID:urn:uuid:00000000-0000-4000-b000-0000{i:08x}\r\n\
|
||||
FN:V4 Address {i}\r\n\
|
||||
N:Contact{i};V4Address;;;\r\n\
|
||||
EMAIL:v4-address-{i}@vandelay.test\r\n\
|
||||
ADR;TYPE=home:;;{i} Test Street;Springfield;OR;97477;US\r\n\
|
||||
END:VCARD\r\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn vcard_v4_nickname(i: usize) -> String {
|
||||
format!(
|
||||
"BEGIN:VCARD\r\n\
|
||||
VERSION:4.0\r\n\
|
||||
UID:urn:uuid:00000000-0000-4000-c000-0000{i:08x}\r\n\
|
||||
FN:V4 Nick {i}\r\n\
|
||||
N:Contact{i};V4Nick;;;\r\n\
|
||||
NICKNAME:Nicky{i}\r\n\
|
||||
EMAIL:v4-nick-{i}@vandelay.test\r\n\
|
||||
END:VCARD\r\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn vcard_v4_birthday(i: usize) -> String {
|
||||
let year = 1970 + (i % 30);
|
||||
let month = (i % 12) + 1;
|
||||
let day = (i % 28) + 1;
|
||||
format!(
|
||||
"BEGIN:VCARD\r\n\
|
||||
VERSION:4.0\r\n\
|
||||
UID:urn:uuid:00000000-0000-4000-d000-0000{i:08x}\r\n\
|
||||
FN:V4 Birthday {i}\r\n\
|
||||
N:Contact{i};V4Birthday;;;\r\n\
|
||||
BDAY:{year:04}{month:02}{day:02}\r\n\
|
||||
EMAIL:v4-bday-{i}@vandelay.test\r\n\
|
||||
END:VCARD\r\n"
|
||||
)
|
||||
}
|
||||
|
||||
const ICAL_VARIANTS: &[fn(usize) -> String] = &[
|
||||
ical_simple_dt,
|
||||
ical_all_day,
|
||||
ical_multi_day_dt,
|
||||
ical_with_organizer_attendees,
|
||||
ical_with_categories_location,
|
||||
ical_recurring_daily,
|
||||
ical_recurring_weekly_until,
|
||||
ical_with_alarm,
|
||||
];
|
||||
|
||||
fn synth_icals(n: usize) -> Vec<RawFixture> {
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let body = ICAL_VARIANTS[i % ICAL_VARIANTS.len()](i);
|
||||
out.push(RawFixture {
|
||||
name: format!("synth-{i:03}"),
|
||||
bytes: body.into_bytes(),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn ical_simple_dt(i: usize) -> String {
|
||||
let month = ((i % 12) + 1) as u32;
|
||||
let day = ((i % 28) + 1) as u32;
|
||||
let hour = (i % 24) as u32;
|
||||
format!(
|
||||
"BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
PRODID:-//vandelay//container-test//EN\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
UID:vandelay-simple-{i}@vandelay.test\r\n\
|
||||
DTSTAMP:20250101T120000Z\r\n\
|
||||
DTSTART:2025{month:02}{day:02}T{hour:02}0000Z\r\n\
|
||||
DTEND:2025{month:02}{day:02}T{hour:02}3000Z\r\n\
|
||||
SUMMARY:Simple datetime event {i}\r\n\
|
||||
DESCRIPTION:Simple synthetic event #{i}\r\n\
|
||||
END:VEVENT\r\n\
|
||||
END:VCALENDAR\r\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn ical_all_day(i: usize) -> String {
|
||||
let month = ((i % 12) + 1) as u32;
|
||||
let day = ((i % 28) + 1) as u32;
|
||||
let next_day = day + 1;
|
||||
format!(
|
||||
"BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
PRODID:-//vandelay//container-test//EN\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
UID:vandelay-allday-{i}@vandelay.test\r\n\
|
||||
DTSTAMP:20250101T120000Z\r\n\
|
||||
DTSTART;VALUE=DATE:2025{month:02}{day:02}\r\n\
|
||||
DTEND;VALUE=DATE:2025{month:02}{next_day:02}\r\n\
|
||||
SUMMARY:All-day event {i}\r\n\
|
||||
END:VEVENT\r\n\
|
||||
END:VCALENDAR\r\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn ical_multi_day_dt(i: usize) -> String {
|
||||
let month = ((i % 12) + 1) as u32;
|
||||
let day = ((i % 27) + 1) as u32;
|
||||
format!(
|
||||
"BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
PRODID:-//vandelay//container-test//EN\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
UID:vandelay-multiday-{i}@vandelay.test\r\n\
|
||||
DTSTAMP:20250101T120000Z\r\n\
|
||||
DTSTART:2025{month:02}{day:02}T090000Z\r\n\
|
||||
DTEND:2025{month:02}{day:02}T170000Z\r\n\
|
||||
SUMMARY:Workshop session {i}\r\n\
|
||||
DESCRIPTION:Multi-hour workshop with description.\r\n\
|
||||
END:VEVENT\r\n\
|
||||
END:VCALENDAR\r\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn ical_with_organizer_attendees(i: usize) -> String {
|
||||
let month = ((i % 12) + 1) as u32;
|
||||
let day = ((i % 28) + 1) as u32;
|
||||
format!(
|
||||
"BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
PRODID:-//vandelay//container-test//EN\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
UID:vandelay-meeting-{i}@vandelay.test\r\n\
|
||||
DTSTAMP:20250101T120000Z\r\n\
|
||||
DTSTART:2025{month:02}{day:02}T140000Z\r\n\
|
||||
DTEND:2025{month:02}{day:02}T150000Z\r\n\
|
||||
SUMMARY:Sync meeting {i}\r\n\
|
||||
ORGANIZER;CN=Organiser:mailto:organiser-{i}@vandelay.test\r\n\
|
||||
ATTENDEE;CN=Alice;PARTSTAT=ACCEPTED:mailto:alice-{i}@vandelay.test\r\n\
|
||||
ATTENDEE;CN=Bob;PARTSTAT=NEEDS-ACTION:mailto:bob-{i}@vandelay.test\r\n\
|
||||
END:VEVENT\r\n\
|
||||
END:VCALENDAR\r\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn ical_with_categories_location(i: usize) -> String {
|
||||
let month = ((i % 12) + 1) as u32;
|
||||
let day = ((i % 28) + 1) as u32;
|
||||
format!(
|
||||
"BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
PRODID:-//vandelay//container-test//EN\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
UID:vandelay-cat-{i}@vandelay.test\r\n\
|
||||
DTSTAMP:20250101T120000Z\r\n\
|
||||
DTSTART:2025{month:02}{day:02}T100000Z\r\n\
|
||||
DTEND:2025{month:02}{day:02}T110000Z\r\n\
|
||||
SUMMARY:Categorised event {i}\r\n\
|
||||
LOCATION:Test Lab\\, Room {i}\r\n\
|
||||
CATEGORIES:VANDELAY,TEST,FIXTURE\r\n\
|
||||
END:VEVENT\r\n\
|
||||
END:VCALENDAR\r\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn ical_recurring_daily(i: usize) -> String {
|
||||
let month = ((i % 12) + 1) as u32;
|
||||
let day = ((i % 28) + 1) as u32;
|
||||
format!(
|
||||
"BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
PRODID:-//vandelay//container-test//EN\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
UID:vandelay-daily-{i}@vandelay.test\r\n\
|
||||
DTSTAMP:20250101T120000Z\r\n\
|
||||
DTSTART:2025{month:02}{day:02}T070000Z\r\n\
|
||||
DTEND:2025{month:02}{day:02}T073000Z\r\n\
|
||||
RRULE:FREQ=DAILY;COUNT=10\r\n\
|
||||
SUMMARY:Daily standup {i}\r\n\
|
||||
END:VEVENT\r\n\
|
||||
END:VCALENDAR\r\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn ical_recurring_weekly_until(i: usize) -> String {
|
||||
let month = ((i % 12) + 1) as u32;
|
||||
let day = ((i % 28) + 1) as u32;
|
||||
let until_month = (month % 12) + 1;
|
||||
format!(
|
||||
"BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
PRODID:-//vandelay//container-test//EN\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
UID:vandelay-weekly-{i}@vandelay.test\r\n\
|
||||
DTSTAMP:20250101T120000Z\r\n\
|
||||
DTSTART:2025{month:02}{day:02}T160000Z\r\n\
|
||||
DTEND:2025{month:02}{day:02}T170000Z\r\n\
|
||||
RRULE:FREQ=WEEKLY;UNTIL=2025{until_month:02}{day:02}T160000Z;BYDAY=MO\r\n\
|
||||
SUMMARY:Weekly retro {i}\r\n\
|
||||
END:VEVENT\r\n\
|
||||
END:VCALENDAR\r\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn ical_with_alarm(i: usize) -> String {
|
||||
let month = ((i % 12) + 1) as u32;
|
||||
let day = ((i % 28) + 1) as u32;
|
||||
format!(
|
||||
"BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
PRODID:-//vandelay//container-test//EN\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
UID:vandelay-alarm-{i}@vandelay.test\r\n\
|
||||
DTSTAMP:20250101T120000Z\r\n\
|
||||
DTSTART:2025{month:02}{day:02}T080000Z\r\n\
|
||||
DTEND:2025{month:02}{day:02}T083000Z\r\n\
|
||||
SUMMARY:Event with alarm {i}\r\n\
|
||||
BEGIN:VALARM\r\n\
|
||||
ACTION:DISPLAY\r\n\
|
||||
DESCRIPTION:Reminder {i}\r\n\
|
||||
TRIGGER:-PT15M\r\n\
|
||||
END:VALARM\r\n\
|
||||
END:VEVENT\r\n\
|
||||
END:VCALENDAR\r\n"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn malformed_ical(name: &str) -> RawFixture {
|
||||
let body = format!(
|
||||
"BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
PRODID:-//vandelay//broken//EN\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
UID:broken-{name}@vandelay.test\r\n\
|
||||
DTSTAMP:20250101T120000Z\r\n\
|
||||
DTSTART:NOT-A-DATE\r\n\
|
||||
RRULE:FREQ=BOGUS;INTERVAL=oops\r\n\
|
||||
END:NOT-A-VEVENT\r\n",
|
||||
);
|
||||
RawFixture {
|
||||
name: format!("broken-{name}"),
|
||||
bytes: body.into_bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rewrite_uid(bytes: &[u8], suffix: &str) -> Option<(Vec<u8>, String)> {
|
||||
let text = std::str::from_utf8(bytes).ok()?;
|
||||
let mut out = String::with_capacity(text.len() + 32);
|
||||
let mut new_uid: Option<String> = None;
|
||||
for line in text.lines() {
|
||||
if let Some(rest) = line.strip_prefix("UID:") {
|
||||
let combined = format!("{rest}-{suffix}");
|
||||
if new_uid.is_none() {
|
||||
new_uid = Some(combined.clone());
|
||||
}
|
||||
out.push_str("UID:");
|
||||
out.push_str(&combined);
|
||||
} else {
|
||||
out.push_str(line);
|
||||
}
|
||||
out.push_str("\r\n");
|
||||
}
|
||||
let uid = new_uid?;
|
||||
Some((out.into_bytes(), uid))
|
||||
}
|
||||
|
||||
pub fn extract_uid(bytes: &[u8]) -> Option<String> {
|
||||
let text = std::str::from_utf8(bytes).ok()?;
|
||||
for line in text.lines() {
|
||||
let l = line.trim_end_matches('\r');
|
||||
if let Some(rest) = l.strip_prefix("UID:") {
|
||||
return Some(rest.to_owned());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as B64;
|
||||
use ureq::Agent;
|
||||
use ureq::http::{Method, Request};
|
||||
|
||||
use super::error::{ContainerError, ContainerResult};
|
||||
|
||||
pub struct DavSeed {
|
||||
agent: Agent,
|
||||
base: String,
|
||||
auth: String,
|
||||
}
|
||||
|
||||
impl DavSeed {
|
||||
pub fn new(base: impl Into<String>, user: &str, password: &str) -> Self {
|
||||
let agent = ureq::Agent::config_builder()
|
||||
.http_status_as_error(false)
|
||||
.allow_non_standard_methods(true)
|
||||
.timeout_global(Some(Duration::from_secs(30)))
|
||||
.build()
|
||||
.new_agent();
|
||||
let credentials = B64.encode(format!("{user}:{password}"));
|
||||
Self {
|
||||
agent,
|
||||
base: base.into(),
|
||||
auth: format!("Basic {credentials}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> String {
|
||||
if path.starts_with("http://") || path.starts_with("https://") {
|
||||
path.to_owned()
|
||||
} else if path.starts_with('/') {
|
||||
format!("{}{path}", self.base.trim_end_matches('/'))
|
||||
} else {
|
||||
format!("{}/{}", self.base.trim_end_matches('/'), path)
|
||||
}
|
||||
}
|
||||
|
||||
fn run(
|
||||
&self,
|
||||
method: &str,
|
||||
url: &str,
|
||||
depth: Option<u8>,
|
||||
content_type: Option<&str>,
|
||||
body: Option<&[u8]>,
|
||||
) -> ContainerResult<(u16, Vec<u8>)> {
|
||||
let parsed = Method::from_bytes(method.as_bytes())
|
||||
.map_err(|e| ContainerError::Protocol(format!("bad method {method}: {e}")))?;
|
||||
let mut builder = Request::builder()
|
||||
.method(parsed)
|
||||
.uri(url)
|
||||
.header("Authorization", &self.auth);
|
||||
if let Some(d) = depth {
|
||||
builder = builder.header("Depth", d.to_string());
|
||||
}
|
||||
if let Some(ct) = content_type {
|
||||
builder = builder.header("Content-Type", ct);
|
||||
}
|
||||
let payload: Vec<u8> = body.map(|b| b.to_vec()).unwrap_or_default();
|
||||
let request = builder
|
||||
.body(payload)
|
||||
.map_err(|e| ContainerError::Protocol(format!("build request: {e}")))?;
|
||||
let mut response = self.agent.run(request)?;
|
||||
let status = response.status().as_u16();
|
||||
let bytes = response.body_mut().read_to_vec()?;
|
||||
Ok((status, bytes))
|
||||
}
|
||||
|
||||
pub fn mkcol(&self, path: &str, body: Option<&str>) -> ContainerResult<u16> {
|
||||
self.mkcol_with_method("MKCOL", path, body)
|
||||
}
|
||||
|
||||
pub fn mkcalendar(&self, path: &str, body: Option<&str>) -> ContainerResult<u16> {
|
||||
self.mkcol_with_method("MKCALENDAR", path, body)
|
||||
}
|
||||
|
||||
fn mkcol_with_method(
|
||||
&self,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Option<&str>,
|
||||
) -> ContainerResult<u16> {
|
||||
let url = self.url(path);
|
||||
let bytes = body.map(|b| b.as_bytes());
|
||||
let (status, response_body) = self.run(
|
||||
method,
|
||||
&url,
|
||||
None,
|
||||
Some("application/xml; charset=utf-8"),
|
||||
bytes,
|
||||
)?;
|
||||
if !is_collection_created(status) {
|
||||
return Err(ContainerError::Protocol(format!(
|
||||
"{method} {url} -> {status}: {}",
|
||||
truncate(&response_body)
|
||||
)));
|
||||
}
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
pub fn put(&self, path: &str, content_type: &str, body: &[u8]) -> ContainerResult<u16> {
|
||||
let url = self.url(path);
|
||||
let (status, response_body) =
|
||||
self.run("PUT", &url, None, Some(content_type), Some(body))?;
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(ContainerError::Protocol(format!(
|
||||
"PUT {url} -> {status}: {}",
|
||||
truncate(&response_body)
|
||||
)));
|
||||
}
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
pub fn delete(&self, path: &str) -> ContainerResult<u16> {
|
||||
let url = self.url(path);
|
||||
let (status, response_body) = self.run("DELETE", &url, None, None, None)?;
|
||||
if !(200..300).contains(&status) && status != 404 {
|
||||
return Err(ContainerError::Protocol(format!(
|
||||
"DELETE {url} -> {status}: {}",
|
||||
truncate(&response_body)
|
||||
)));
|
||||
}
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
pub fn propfind(&self, path: &str, depth: u8) -> ContainerResult<String> {
|
||||
let url = self.url(path);
|
||||
let body = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<propfind xmlns="DAV:"><prop><resourcetype/><displayname/></prop></propfind>"#;
|
||||
let (status, bytes) = self.run(
|
||||
"PROPFIND",
|
||||
&url,
|
||||
Some(depth),
|
||||
Some("application/xml; charset=utf-8"),
|
||||
Some(body.as_bytes()),
|
||||
)?;
|
||||
if !(200..400).contains(&status) {
|
||||
return Err(ContainerError::Protocol(format!(
|
||||
"PROPFIND {url} -> {status}: {}",
|
||||
truncate(&bytes)
|
||||
)));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_collection_created(status: u16) -> bool {
|
||||
matches!(status, 200 | 201 | 204)
|
||||
}
|
||||
|
||||
fn truncate(body: &[u8]) -> String {
|
||||
let s = String::from_utf8_lossy(body);
|
||||
if s.len() > 2000 {
|
||||
format!("{}…", &s[..2000])
|
||||
} else {
|
||||
s.into_owned()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use testcontainers::core::{IntoContainerPort, WaitFor};
|
||||
use testcontainers::runners::{SyncBuilder, SyncRunner};
|
||||
use testcontainers::{Container, GenericBuildableImage, GenericImage, ImageExt};
|
||||
|
||||
use super::data::{MboxMessage, load_mbox};
|
||||
use super::error::{ContainerError, ContainerResult};
|
||||
use super::imap_client::{ImapSeed, full_path};
|
||||
use super::layouts::{self};
|
||||
use super::sieve_client::SieveSeed as SieveClient;
|
||||
use super::{Account, Endpoint};
|
||||
|
||||
const IMAP_PORT: u16 = 143;
|
||||
const SIEVE_PORT: u16 = 4190;
|
||||
|
||||
const DOCKERFILE: &str = r#"FROM debian:bookworm-20260518-slim
|
||||
|
||||
RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
dovecot-imapd dovecot-managesieved dovecot-pop3d dovecot-sieve \
|
||||
openssl ca-certificates && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
addgroup --gid 5000 vmail && \
|
||||
adduser --system --no-create-home --uid 5000 --gid 5000 vmail && \
|
||||
mkdir -p /var/vmail && chown -R vmail:vmail /var/vmail && \
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout /etc/dovecot/dovecot.key \
|
||||
-out /etc/dovecot/dovecot.crt \
|
||||
-subj /CN=localhost && \
|
||||
chmod 600 /etc/dovecot/dovecot.key
|
||||
|
||||
COPY dovecot.conf /etc/dovecot/dovecot.conf
|
||||
COPY users /etc/dovecot/users
|
||||
|
||||
EXPOSE 143 4190
|
||||
CMD ["dovecot", "-F"]
|
||||
"#;
|
||||
|
||||
const DOVECOT_CONF: &str = r#"protocols = imap sieve
|
||||
listen = *
|
||||
|
||||
mail_location = maildir:/var/vmail/%u/Maildir
|
||||
mail_uid = 5000
|
||||
mail_gid = 5000
|
||||
mail_privileged_group = vmail
|
||||
|
||||
namespace inbox {
|
||||
inbox = yes
|
||||
separator = /
|
||||
prefix =
|
||||
}
|
||||
|
||||
auth_mechanisms = plain login
|
||||
disable_plaintext_auth = no
|
||||
|
||||
passdb {
|
||||
driver = passwd-file
|
||||
args = /etc/dovecot/users
|
||||
}
|
||||
userdb {
|
||||
driver = static
|
||||
args = uid=5000 gid=5000 home=/var/vmail/%u
|
||||
}
|
||||
|
||||
service imap-login {
|
||||
inet_listener imap {
|
||||
port = 143
|
||||
}
|
||||
}
|
||||
|
||||
service managesieve-login {
|
||||
inet_listener sieve {
|
||||
port = 4190
|
||||
}
|
||||
}
|
||||
|
||||
protocol sieve {
|
||||
managesieve_max_line_length = 1M
|
||||
managesieve_max_compile_errors = 5
|
||||
}
|
||||
|
||||
ssl = yes
|
||||
ssl_cert = </etc/dovecot/dovecot.crt
|
||||
ssl_key = </etc/dovecot/dovecot.key
|
||||
log_path = /dev/stderr
|
||||
info_log_path = /dev/stderr
|
||||
|
||||
plugin {
|
||||
sieve = file:~/sieve;active=~/.dovecot.sieve
|
||||
sieve_max_script_size = 1M
|
||||
}
|
||||
"#;
|
||||
|
||||
fn users_file() -> String {
|
||||
let mut out = String::new();
|
||||
for u in layouts::accounts() {
|
||||
out.push_str(u);
|
||||
out.push_str(":{plain}");
|
||||
out.push_str(layouts::PASSWORD);
|
||||
out.push_str(":5000:5000::/var/vmail/");
|
||||
out.push_str(u);
|
||||
out.push_str("::\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub struct Dovecot {
|
||||
container: Container<GenericImage>,
|
||||
pub imap: Endpoint,
|
||||
pub sieve: Endpoint,
|
||||
pub accounts: Vec<Account>,
|
||||
}
|
||||
|
||||
impl Dovecot {
|
||||
pub fn start() -> ContainerResult<Self> {
|
||||
let image: GenericImage = GenericBuildableImage::new("vandelay-dovecot", "test")
|
||||
.with_dockerfile_string(DOCKERFILE.to_owned())
|
||||
.with_data(DOVECOT_CONF.as_bytes().to_vec(), "dovecot.conf")
|
||||
.with_data(users_file().into_bytes(), "users")
|
||||
.build_image()
|
||||
.map_err(|e| ContainerError::Seed(format!("dovecot build: {e}")))?;
|
||||
|
||||
let request = image
|
||||
.with_exposed_port(IMAP_PORT.tcp())
|
||||
.with_exposed_port(SIEVE_PORT.tcp())
|
||||
.with_wait_for(WaitFor::message_on_stderr("starting up"))
|
||||
.with_startup_timeout(Duration::from_secs(120));
|
||||
|
||||
let container = request.start()?;
|
||||
let host = container.get_host()?.to_string();
|
||||
let imap = Endpoint::new(host.clone(), container.get_host_port_ipv4(IMAP_PORT.tcp())?);
|
||||
let sieve = Endpoint::new(host, container.get_host_port_ipv4(SIEVE_PORT.tcp())?);
|
||||
|
||||
let accounts: Vec<Account> = layouts::accounts()
|
||||
.iter()
|
||||
.map(|name| Account {
|
||||
username: (*name).to_owned(),
|
||||
password: layouts::PASSWORD.to_owned(),
|
||||
layout: layouts::layout_for(name),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Self {
|
||||
container,
|
||||
imap,
|
||||
sieve,
|
||||
accounts,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn seed_all(&self) -> ContainerResult<Vec<AccountSeed>> {
|
||||
let limit = self
|
||||
.accounts
|
||||
.iter()
|
||||
.map(|a| a.layout.email_count)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max(1);
|
||||
let messages = load_mbox(limit)?;
|
||||
let mut out = Vec::new();
|
||||
for acct in &self.accounts {
|
||||
let mailbox = self.seed_imap(acct, &messages)?;
|
||||
let sieve = self.seed_sieve(acct)?;
|
||||
out.push(AccountSeed {
|
||||
username: acct.username.clone(),
|
||||
mailbox,
|
||||
sieve,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn seed_imap(
|
||||
&self,
|
||||
account: &Account,
|
||||
messages: &[MboxMessage],
|
||||
) -> ContainerResult<MailboxSeed> {
|
||||
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
|
||||
client.login(&account.username, &account.password)?;
|
||||
let sep = client.discover_separator().unwrap_or('/');
|
||||
|
||||
let mut paths = Vec::new();
|
||||
for spec in account.layout.mailboxes {
|
||||
let path = full_path(account.layout.mailboxes, spec.key, sep).ok_or_else(|| {
|
||||
ContainerError::Seed(format!("missing mailbox key: {}", spec.key))
|
||||
})?;
|
||||
client.create(&path)?;
|
||||
client.subscribe(&path)?;
|
||||
paths.push(path);
|
||||
}
|
||||
|
||||
let mut targets: Vec<String> = vec!["INBOX".to_owned()];
|
||||
targets.extend(paths.iter().cloned());
|
||||
let mut histogram: HashMap<String, usize> =
|
||||
targets.iter().map(|t| (t.clone(), 0)).collect();
|
||||
let mut appends: Vec<SeededAppend> = Vec::new();
|
||||
|
||||
let total = account.layout.email_count.min(messages.len());
|
||||
if total < account.layout.email_count {
|
||||
return Err(ContainerError::Seed(format!(
|
||||
"{} only has {} messages but layout requires {}",
|
||||
account.username,
|
||||
messages.len(),
|
||||
account.layout.email_count
|
||||
)));
|
||||
}
|
||||
for (i, msg) in messages.iter().take(total).enumerate() {
|
||||
let target = targets[i % targets.len()].clone();
|
||||
client.append_with_flags(&target, &[], &msg.raw)?;
|
||||
*histogram.entry(target.clone()).or_insert(0) += 1;
|
||||
appends.push(SeededAppend {
|
||||
raw: msg.raw.clone(),
|
||||
target,
|
||||
flags: Vec::new(),
|
||||
tag: SeedTag::Bulk(i),
|
||||
});
|
||||
}
|
||||
|
||||
let mut dedup_target: Option<String> = None;
|
||||
if targets.len() > 1 && !messages.is_empty() {
|
||||
let target = targets[1].clone();
|
||||
client.append_with_flags(&target, &[], &messages[0].raw)?;
|
||||
*histogram.entry(target.clone()).or_insert(0) += 1;
|
||||
appends.push(SeededAppend {
|
||||
raw: messages[0].raw.clone(),
|
||||
target: target.clone(),
|
||||
flags: Vec::new(),
|
||||
tag: SeedTag::Dedup,
|
||||
});
|
||||
dedup_target = Some(target);
|
||||
}
|
||||
|
||||
let mut flagged_target: Option<String> = None;
|
||||
if !messages.is_empty() {
|
||||
let target = "INBOX".to_owned();
|
||||
let probe = flag_probe_message();
|
||||
client.append_with_flags(&target, &["\\Seen", "\\Flagged"], &probe)?;
|
||||
*histogram.entry(target.clone()).or_insert(0) += 1;
|
||||
appends.push(SeededAppend {
|
||||
raw: probe,
|
||||
target: target.clone(),
|
||||
flags: vec!["$seen".to_owned(), "$flagged".to_owned()],
|
||||
tag: SeedTag::FlagProbe,
|
||||
});
|
||||
flagged_target = Some(target);
|
||||
}
|
||||
|
||||
let mut nomid_target: Option<String> = None;
|
||||
if !messages.is_empty() {
|
||||
let target = "INBOX".to_owned();
|
||||
let probe = no_message_id_probe();
|
||||
client.append_with_flags(&target, &[], &probe)?;
|
||||
*histogram.entry(target.clone()).or_insert(0) += 1;
|
||||
appends.push(SeededAppend {
|
||||
raw: probe,
|
||||
target: target.clone(),
|
||||
flags: Vec::new(),
|
||||
tag: SeedTag::NoMessageId,
|
||||
});
|
||||
nomid_target = Some(target);
|
||||
}
|
||||
|
||||
let mut mid_dedup_targets: Option<(String, String)> = None;
|
||||
if targets.len() >= 3 {
|
||||
let body = shared_message_id_probe();
|
||||
let a = targets[1].clone();
|
||||
let b = targets[2].clone();
|
||||
client.append_with_flags(&a, &[], &body)?;
|
||||
client.append_with_flags(&b, &[], &body)?;
|
||||
*histogram.entry(a.clone()).or_insert(0) += 1;
|
||||
*histogram.entry(b.clone()).or_insert(0) += 1;
|
||||
appends.push(SeededAppend {
|
||||
raw: body.clone(),
|
||||
target: a.clone(),
|
||||
flags: Vec::new(),
|
||||
tag: SeedTag::SharedMid,
|
||||
});
|
||||
appends.push(SeededAppend {
|
||||
raw: body,
|
||||
target: b.clone(),
|
||||
flags: Vec::new(),
|
||||
tag: SeedTag::SharedMid,
|
||||
});
|
||||
mid_dedup_targets = Some((a, b));
|
||||
}
|
||||
|
||||
client.logout()?;
|
||||
|
||||
let extras = ExtraAppends {
|
||||
dedup_target,
|
||||
flagged_target,
|
||||
nomid_target,
|
||||
mid_dedup_targets,
|
||||
};
|
||||
|
||||
let total_appends = histogram_total(&histogram);
|
||||
Ok(MailboxSeed {
|
||||
paths,
|
||||
histogram,
|
||||
total_appends,
|
||||
extras,
|
||||
appends,
|
||||
})
|
||||
}
|
||||
|
||||
fn seed_sieve(&self, account: &Account) -> ContainerResult<SieveSeed> {
|
||||
if account.layout.sieve_scripts.is_empty() {
|
||||
return Ok(SieveSeed::default());
|
||||
}
|
||||
let mut client = SieveClient::connect_seed(&self.sieve.host, self.sieve.port)?;
|
||||
client.authenticate(&account.username, &account.password)?;
|
||||
let mut active_name: Option<&'static str> = None;
|
||||
let mut names = Vec::new();
|
||||
for script in account.layout.sieve_scripts {
|
||||
client.putscript(script.name, script.body)?;
|
||||
names.push(script.name.to_owned());
|
||||
if script.active {
|
||||
active_name = Some(script.name);
|
||||
}
|
||||
}
|
||||
if let Some(name) = active_name {
|
||||
client.setactive(name)?;
|
||||
}
|
||||
client.logout()?;
|
||||
Ok(SieveSeed {
|
||||
names,
|
||||
active: active_name.map(str::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_first_inbox_message(&self, account: &Account) -> ContainerResult<()> {
|
||||
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
|
||||
client.login(&account.username, &account.password)?;
|
||||
client.delete_and_expunge_first("INBOX")?;
|
||||
client.logout()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn append_new_message(
|
||||
&self,
|
||||
account: &Account,
|
||||
mailbox: &str,
|
||||
tag: &str,
|
||||
) -> ContainerResult<(Vec<u8>, String)> {
|
||||
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
|
||||
client.login(&account.username, &account.password)?;
|
||||
let message_id = format!("<added-{tag}-{}@vandelay.test>", account.username);
|
||||
let body = format!(
|
||||
"From: added-{tag}@vandelay.test\r\n\
|
||||
To: {}@vandelay.test\r\n\
|
||||
Subject: Added probe {tag}\r\n\
|
||||
Message-ID: {message_id}\r\n\
|
||||
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
|
||||
\r\n\
|
||||
Added probe body {tag}.\r\n",
|
||||
account.username
|
||||
);
|
||||
let raw = body.into_bytes();
|
||||
client.append_with_flags(mailbox, &[], &raw)?;
|
||||
client.logout()?;
|
||||
Ok((raw, message_id))
|
||||
}
|
||||
|
||||
pub fn install_broken_sieve(&self, account: &Account) -> ContainerResult<String> {
|
||||
let mut client = SieveClient::connect_seed(&self.sieve.host, self.sieve.port)?;
|
||||
client.authenticate(&account.username, &account.password)?;
|
||||
let name = "broken-script";
|
||||
client.putscript_raw(name, "INVALID:::not_a_sieve_program;;;")?;
|
||||
client.logout()?;
|
||||
Ok(name.to_owned())
|
||||
}
|
||||
|
||||
pub fn verify_seed(&self, seeds: &[AccountSeed]) -> ContainerResult<()> {
|
||||
for (acct, seed) in self.accounts.iter().zip(seeds) {
|
||||
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
|
||||
client.login(&acct.username, &acct.password)?;
|
||||
let names = client.list_all()?;
|
||||
let expected = acct.layout.mailboxes.len() + 1;
|
||||
if names.len() < expected {
|
||||
return Err(ContainerError::Seed(format!(
|
||||
"{}: expected >= {expected} mailboxes, got {}",
|
||||
acct.username,
|
||||
names.len()
|
||||
)));
|
||||
}
|
||||
let inbox_n = client.select("INBOX")?;
|
||||
if acct.layout.email_count > 0 && inbox_n == 0 {
|
||||
return Err(ContainerError::Seed(format!(
|
||||
"{}: INBOX EXISTS = 0 after seed",
|
||||
acct.username
|
||||
)));
|
||||
}
|
||||
for path in &seed.mailbox.paths {
|
||||
let n = client.select(path)?;
|
||||
let want = seed.mailbox.histogram.get(path).copied().unwrap_or(0);
|
||||
if n < want {
|
||||
return Err(ContainerError::Seed(format!(
|
||||
"{}: mailbox {path} EXISTS={n} but {want} were appended",
|
||||
acct.username
|
||||
)));
|
||||
}
|
||||
}
|
||||
client.logout()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop(self) -> ContainerResult<()> {
|
||||
self.container.stop()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn histogram_total(h: &HashMap<String, usize>) -> usize {
|
||||
h.values().sum()
|
||||
}
|
||||
|
||||
pub fn flag_probe_message() -> Vec<u8> {
|
||||
let body = "From: [email protected]\r\n\
|
||||
To: [email protected]\r\n\
|
||||
Subject: Flag probe\r\n\
|
||||
Message-ID: <[email protected]>\r\n\
|
||||
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
|
||||
\r\n\
|
||||
Flag probe body.\r\n";
|
||||
body.as_bytes().to_vec()
|
||||
}
|
||||
|
||||
pub fn no_message_id_probe() -> Vec<u8> {
|
||||
let body = "From: [email protected]\r\n\
|
||||
To: [email protected]\r\n\
|
||||
Subject: No Message-ID probe\r\n\
|
||||
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
|
||||
\r\n\
|
||||
No Message-ID body.\r\n";
|
||||
body.as_bytes().to_vec()
|
||||
}
|
||||
|
||||
pub fn shared_message_id_probe() -> Vec<u8> {
|
||||
let body = "From: [email protected]\r\n\
|
||||
To: [email protected]\r\n\
|
||||
Subject: Shared MID probe\r\n\
|
||||
Message-ID: <[email protected]>\r\n\
|
||||
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
|
||||
\r\n\
|
||||
Shared MID body.\r\n";
|
||||
body.as_bytes().to_vec()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MailboxSeed {
|
||||
pub paths: Vec<String>,
|
||||
pub histogram: HashMap<String, usize>,
|
||||
pub total_appends: usize,
|
||||
pub extras: ExtraAppends,
|
||||
pub appends: Vec<SeededAppend>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SeededAppend {
|
||||
pub raw: Vec<u8>,
|
||||
pub target: String,
|
||||
pub flags: Vec<String>,
|
||||
pub tag: SeedTag,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SeedTag {
|
||||
Bulk(usize),
|
||||
Dedup,
|
||||
FlagProbe,
|
||||
NoMessageId,
|
||||
SharedMid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExtraAppends {
|
||||
pub dedup_target: Option<String>,
|
||||
pub flagged_target: Option<String>,
|
||||
pub nomid_target: Option<String>,
|
||||
pub mid_dedup_targets: Option<(String, String)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct SieveSeed {
|
||||
pub names: Vec<String>,
|
||||
pub active: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccountSeed {
|
||||
pub username: String,
|
||||
pub mailbox: MailboxSeed,
|
||||
pub sieve: SieveSeed,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::io;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
pub type ContainerResult<T> = Result<T, ContainerError>;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ContainerError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
#[error("testcontainers: {0}")]
|
||||
Testcontainers(#[from] testcontainers::TestcontainersError),
|
||||
#[error("ureq: {0}")]
|
||||
Ureq(#[from] Box<ureq::Error>),
|
||||
#[error("utf8: {0}")]
|
||||
Utf8(#[from] std::string::FromUtf8Error),
|
||||
#[error("env: {0}")]
|
||||
Env(String),
|
||||
#[error("protocol: {0}")]
|
||||
Protocol(String),
|
||||
#[error("seed: {0}")]
|
||||
Seed(String),
|
||||
#[error("resource: {0}")]
|
||||
Resource(String),
|
||||
}
|
||||
|
||||
impl From<ureq::Error> for ContainerError {
|
||||
fn from(e: ureq::Error) -> Self {
|
||||
ContainerError::Ureq(Box::new(e))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::error::{ContainerError, ContainerResult};
|
||||
use super::layouts::MailboxSpec;
|
||||
|
||||
pub struct ImapSeed {
|
||||
reader: BufReader<TcpStream>,
|
||||
writer: TcpStream,
|
||||
tag_seq: u32,
|
||||
pub separator: char,
|
||||
}
|
||||
|
||||
impl ImapSeed {
|
||||
pub fn connect(host: &str, port: u16) -> ContainerResult<Self> {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||
let stream = loop {
|
||||
match TcpStream::connect((host, port)) {
|
||||
Ok(s) => break s,
|
||||
Err(e) => {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Err(e.into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
};
|
||||
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_secs(30)))?;
|
||||
let writer = stream.try_clone()?;
|
||||
let mut me = Self {
|
||||
reader: BufReader::new(stream),
|
||||
writer,
|
||||
tag_seq: 0,
|
||||
separator: '/',
|
||||
};
|
||||
let greeting = me.read_line()?;
|
||||
if !greeting.starts_with("* OK") && !greeting.starts_with("* PREAUTH") {
|
||||
return Err(ContainerError::Protocol(format!(
|
||||
"imap greeting unexpected: {greeting}"
|
||||
)));
|
||||
}
|
||||
Ok(me)
|
||||
}
|
||||
|
||||
fn next_tag(&mut self) -> String {
|
||||
self.tag_seq += 1;
|
||||
format!("a{:04}", self.tag_seq)
|
||||
}
|
||||
|
||||
fn read_line(&mut self) -> ContainerResult<String> {
|
||||
let mut line = String::new();
|
||||
let n = self.reader.read_line(&mut line)?;
|
||||
if n == 0 {
|
||||
return Err(ContainerError::Protocol("imap eof".to_owned()));
|
||||
}
|
||||
if line.ends_with('\n') {
|
||||
line.pop();
|
||||
if line.ends_with('\r') {
|
||||
line.pop();
|
||||
}
|
||||
}
|
||||
Ok(line)
|
||||
}
|
||||
|
||||
fn read_tagged(&mut self, tag: &str) -> ContainerResult<Vec<String>> {
|
||||
let mut untagged = Vec::new();
|
||||
loop {
|
||||
let line = self.read_line()?;
|
||||
if let Some(rest) = line.strip_prefix(&format!("{tag} ")) {
|
||||
if rest.starts_with("OK") {
|
||||
return Ok(untagged);
|
||||
}
|
||||
return Err(ContainerError::Protocol(format!(
|
||||
"imap tagged failure: {line}"
|
||||
)));
|
||||
}
|
||||
untagged.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
fn send(&mut self, line: &str) -> ContainerResult<()> {
|
||||
self.writer.write_all(line.as_bytes())?;
|
||||
self.writer.write_all(b"\r\n")?;
|
||||
self.writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn login(&mut self, user: &str, password: &str) -> ContainerResult<()> {
|
||||
let tag = self.next_tag();
|
||||
let escaped_user = quote(user);
|
||||
let escaped_pw = quote(password);
|
||||
self.send(&format!("{tag} LOGIN {escaped_user} {escaped_pw}"))?;
|
||||
self.read_tagged(&tag)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn discover_separator(&mut self) -> ContainerResult<char> {
|
||||
let tag = self.next_tag();
|
||||
self.send(&format!(r#"{tag} LIST "" """#))?;
|
||||
let untagged = self.read_tagged(&tag)?;
|
||||
for line in &untagged {
|
||||
if let Some(rest) = line.strip_prefix("* LIST ")
|
||||
&& let Some(open) = rest.find('"')
|
||||
&& let Some(close) = rest[open + 1..].find('"')
|
||||
{
|
||||
let sep = &rest[open + 1..open + 1 + close];
|
||||
if let Some(c) = sep.chars().next() {
|
||||
self.separator = c;
|
||||
return Ok(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok('/')
|
||||
}
|
||||
|
||||
pub fn create(&mut self, full_path: &str) -> ContainerResult<()> {
|
||||
let tag = self.next_tag();
|
||||
self.send(&format!("{tag} CREATE {}", quote(full_path)))?;
|
||||
match self.read_tagged(&tag) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(ContainerError::Protocol(msg)) if msg.contains("already exists") => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscribe(&mut self, full_path: &str) -> ContainerResult<()> {
|
||||
let tag = self.next_tag();
|
||||
self.send(&format!("{tag} SUBSCRIBE {}", quote(full_path)))?;
|
||||
let _ = self.read_tagged(&tag);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn append(&mut self, mailbox: &str, message: &[u8]) -> ContainerResult<()> {
|
||||
self.append_with_flags(mailbox, &[], message)
|
||||
}
|
||||
|
||||
pub fn append_with_flags(
|
||||
&mut self,
|
||||
mailbox: &str,
|
||||
flags: &[&str],
|
||||
message: &[u8],
|
||||
) -> ContainerResult<()> {
|
||||
let tag = self.next_tag();
|
||||
let flag_clause = if flags.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" ({})", flags.join(" "))
|
||||
};
|
||||
let cmd = format!(
|
||||
"{tag} APPEND {}{flag_clause} {{{}}}\r\n",
|
||||
quote(mailbox),
|
||||
message.len(),
|
||||
);
|
||||
self.writer.write_all(cmd.as_bytes())?;
|
||||
self.writer.flush()?;
|
||||
let cont = self.read_line()?;
|
||||
if !cont.starts_with('+') {
|
||||
return Err(ContainerError::Protocol(format!(
|
||||
"append expected continuation, got: {cont}"
|
||||
)));
|
||||
}
|
||||
self.writer.write_all(message)?;
|
||||
self.writer.write_all(b"\r\n")?;
|
||||
self.writer.flush()?;
|
||||
self.read_tagged(&tag)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_all(&mut self) -> ContainerResult<Vec<String>> {
|
||||
let tag = self.next_tag();
|
||||
self.send(&format!(r#"{tag} LIST "" "*""#))?;
|
||||
let untagged = self.read_tagged(&tag)?;
|
||||
let mut names = Vec::new();
|
||||
for line in untagged {
|
||||
if let Some(rest) = line.strip_prefix("* LIST ")
|
||||
&& let Some(name) = parse_list_name(rest)
|
||||
{
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
pub fn select(&mut self, mailbox: &str) -> ContainerResult<usize> {
|
||||
let tag = self.next_tag();
|
||||
self.send(&format!("{tag} SELECT {}", quote(mailbox)))?;
|
||||
let untagged = self.read_tagged(&tag)?;
|
||||
for line in untagged {
|
||||
if let Some(rest) = line.strip_prefix("* ")
|
||||
&& let Some(num) = rest.strip_suffix(" EXISTS")
|
||||
&& let Ok(n) = num.parse::<usize>()
|
||||
{
|
||||
return Ok(n);
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
pub fn delete_and_expunge_first(&mut self, mailbox: &str) -> ContainerResult<()> {
|
||||
self.select(mailbox)?;
|
||||
let store_tag = self.next_tag();
|
||||
self.send(&format!("{store_tag} STORE 1 +FLAGS (\\Deleted)"))?;
|
||||
self.read_tagged(&store_tag)?;
|
||||
let expunge_tag = self.next_tag();
|
||||
self.send(&format!("{expunge_tag} EXPUNGE"))?;
|
||||
self.read_tagged(&expunge_tag)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn logout(&mut self) -> ContainerResult<()> {
|
||||
let tag = self.next_tag();
|
||||
self.send(&format!("{tag} LOGOUT"))?;
|
||||
let mut buf = String::new();
|
||||
let _ = self.reader.read_to_string(&mut buf);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn full_path(specs: &[MailboxSpec], key: &str, separator: char) -> Option<String> {
|
||||
let mut chain: Vec<&str> = Vec::new();
|
||||
let mut cur = key;
|
||||
loop {
|
||||
let spec = specs.iter().find(|s| s.key == cur)?;
|
||||
chain.push(spec.name);
|
||||
match spec.parent {
|
||||
Some(p) => cur = p,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
chain.reverse();
|
||||
Some(chain.join(&separator.to_string()))
|
||||
}
|
||||
|
||||
fn quote(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
if c == '\\' || c == '"' {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_list_name(line: &str) -> Option<String> {
|
||||
let mut chars = line.chars();
|
||||
let _ = chars.next()?;
|
||||
let mut depth = 1;
|
||||
let mut idx = 1;
|
||||
for (i, c) in line[1..].char_indices() {
|
||||
match c {
|
||||
'(' => depth += 1,
|
||||
')' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
idx = i + 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let rest = line[idx..].trim_start();
|
||||
let mut parts = rest.splitn(2, ' ');
|
||||
let _sep_token = parts.next()?;
|
||||
let name_token = parts.next()?.trim();
|
||||
if let Some(stripped) = name_token.strip_prefix('"') {
|
||||
let end = stripped.find('"')?;
|
||||
Some(stripped[..end].to_owned())
|
||||
} else {
|
||||
Some(name_token.to_owned())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MailboxSpec {
|
||||
pub key: &'static str,
|
||||
pub name: &'static str,
|
||||
pub parent: Option<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FileSpec {
|
||||
pub key: &'static str,
|
||||
pub name: &'static str,
|
||||
pub parent: Option<&'static str>,
|
||||
pub directory: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SieveScript {
|
||||
pub name: &'static str,
|
||||
pub body: &'static str,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Layout {
|
||||
pub mailboxes: &'static [MailboxSpec],
|
||||
pub files: &'static [FileSpec],
|
||||
pub calendars: &'static [&'static str],
|
||||
pub address_books: &'static [&'static str],
|
||||
pub sieve_scripts: &'static [SieveScript],
|
||||
pub email_count: usize,
|
||||
pub contact_count: usize,
|
||||
pub event_count: usize,
|
||||
}
|
||||
|
||||
macro_rules! mailbox {
|
||||
($k:expr, $n:expr, $p:expr) => {
|
||||
MailboxSpec {
|
||||
key: $k,
|
||||
name: $n,
|
||||
parent: $p,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! node {
|
||||
($k:expr, $n:expr, $p:expr, $d:expr) => {
|
||||
FileSpec {
|
||||
key: $k,
|
||||
name: $n,
|
||||
parent: $p,
|
||||
directory: $d,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub const ACCOUNT1: &str = "user1";
|
||||
pub const ACCOUNT2: &str = "user2";
|
||||
pub const ACCOUNT3: &str = "user3";
|
||||
|
||||
pub const PASSWORD: &str = "VandelayUser#2026";
|
||||
|
||||
pub static MAILBOXES_DEEP: &[MailboxSpec] = &[
|
||||
mailbox!("proj", "Projects", None),
|
||||
mailbox!("y2024", "2024", Some("proj")),
|
||||
mailbox!("q1", "Q1", Some("y2024")),
|
||||
mailbox!("acme", "ClientAcme", Some("q1")),
|
||||
mailbox!("acme_in", "Internal", Some("acme")),
|
||||
mailbox!("q2", "Q2", Some("y2024")),
|
||||
mailbox!("globex", "ClientGlobex", Some("q2")),
|
||||
mailbox!("y2025", "2025", Some("proj")),
|
||||
mailbox!("alpha", "Alpha", Some("y2025")),
|
||||
mailbox!("beta", "Beta", Some("y2025")),
|
||||
mailbox!("travel", "Travel", None),
|
||||
mailbox!("asia", "Asia", Some("travel")),
|
||||
mailbox!("jp", "Japan", Some("asia")),
|
||||
mailbox!("tokyo", "Tokyo", Some("jp")),
|
||||
mailbox!("eu", "Europe", Some("travel")),
|
||||
mailbox!("it", "Italy", Some("eu")),
|
||||
mailbox!("rome", "Rome", Some("it")),
|
||||
mailbox!("arch", "Archive", None),
|
||||
mailbox!("arch23", "2023", Some("arch")),
|
||||
mailbox!("arch24", "2024", Some("arch")),
|
||||
];
|
||||
|
||||
pub static MAILBOXES_MEDIUM: &[MailboxSpec] = &[
|
||||
mailbox!("work", "Work", None),
|
||||
mailbox!("clients", "Clients", Some("work")),
|
||||
mailbox!("acme", "AcmeCorp", Some("clients")),
|
||||
mailbox!("inv", "Invoices", Some("acme")),
|
||||
mailbox!("globex", "Globex", Some("clients")),
|
||||
mailbox!("internal", "Internal", Some("work")),
|
||||
mailbox!("pers", "Personal", None),
|
||||
mailbox!("rcpt", "Receipts", Some("pers")),
|
||||
];
|
||||
|
||||
pub static MAILBOXES_FLAT: &[MailboxSpec] = &[
|
||||
mailbox!("lists", "Lists", None),
|
||||
mailbox!("news", "Newsletters", Some("lists")),
|
||||
mailbox!("misc", "Misc", None),
|
||||
];
|
||||
|
||||
pub static FILES_DEEP: &[FileSpec] = &[
|
||||
node!("docs", "Documents", None, true),
|
||||
node!("rep", "Reports", Some("docs"), true),
|
||||
node!("d24", "2024", Some("rep"), true),
|
||||
node!("q1f", "q1-results.bin", Some("d24"), false),
|
||||
node!("q2f", "q2-results.bin", Some("d24"), false),
|
||||
node!("d25", "2025", Some("rep"), true),
|
||||
node!("q1f25", "q1-results.bin", Some("d25"), false),
|
||||
node!("spec", "Specs", Some("docs"), true),
|
||||
node!("ovr", "overview.bin", Some("spec"), false),
|
||||
node!("notes", "notes.bin", Some("docs"), false),
|
||||
node!("photos", "Photos", None, true),
|
||||
node!("vac", "Vacations", Some("photos"), true),
|
||||
node!("vit", "Italy", Some("vac"), true),
|
||||
node!("vrome", "rome.bin", Some("vit"), false),
|
||||
node!("vjp", "Japan", Some("vac"), true),
|
||||
node!("vtokyo", "tokyo.bin", Some("vjp"), false),
|
||||
node!("readme", "README.bin", None, false),
|
||||
];
|
||||
|
||||
pub static FILES_MEDIUM: &[FileSpec] = &[
|
||||
node!("media", "Media", None, true),
|
||||
node!("audio", "Audio", Some("media"), true),
|
||||
node!("song", "song.bin", Some("audio"), false),
|
||||
node!("video", "Video", Some("media"), true),
|
||||
node!("clip", "clip.bin", Some("video"), false),
|
||||
node!("proj", "Projects", None, true),
|
||||
node!("src", "src", Some("proj"), true),
|
||||
node!("main", "main.bin", Some("src"), false),
|
||||
];
|
||||
|
||||
pub static FILES_FLAT: &[FileSpec] = &[
|
||||
node!("bak", "Backup", None, true),
|
||||
node!("snap", "snapshot.bin", Some("bak"), false),
|
||||
node!("scratch", "scratch.bin", None, false),
|
||||
];
|
||||
|
||||
pub static CALENDARS_DEEP: &[&str] = &["Personal", "Work", "Birthdays", "Travel"];
|
||||
pub static CALENDARS_MEDIUM: &[&str] = &["Personal", "Work"];
|
||||
pub static CALENDARS_FLAT: &[&str] = &["Personal", "Holidays"];
|
||||
|
||||
pub static ADDRESS_BOOKS_DEEP: &[&str] = &["Personal", "Work", "Friends"];
|
||||
pub static ADDRESS_BOOKS_MEDIUM: &[&str] = &["Personal", "Work"];
|
||||
pub static ADDRESS_BOOKS_FLAT: &[&str] = &["Personal", "Family"];
|
||||
|
||||
pub static SIEVE_DEEP: &[SieveScript] = &[
|
||||
SieveScript {
|
||||
name: "spam",
|
||||
body: "require [\"fileinto\"];\nif header :contains \"X-Spam\" \"YES\" {\n fileinto \"Junk\";\n stop;\n}\n",
|
||||
active: true,
|
||||
},
|
||||
SieveScript {
|
||||
name: "vacation",
|
||||
body: "require [\"vacation\"];\nvacation :days 7 :subject \"Out of office\" \"I am away, will reply later.\";\n",
|
||||
active: false,
|
||||
},
|
||||
SieveScript {
|
||||
name: "organize",
|
||||
body: "require [\"fileinto\",\"mailbox\"];\nif address :is \"from\" \"[email protected]\" {\n fileinto :create \"Projects/2025/Beta\";\n}\n",
|
||||
active: false,
|
||||
},
|
||||
];
|
||||
|
||||
pub static SIEVE_MEDIUM: &[SieveScript] = &[
|
||||
SieveScript {
|
||||
name: "filter",
|
||||
body: "require [\"fileinto\"];\nif address :is \"to\" \"[email protected]\" {\n fileinto \"Work\";\n}\n",
|
||||
active: true,
|
||||
},
|
||||
SieveScript {
|
||||
name: "draft",
|
||||
body: "require [\"reject\"];\nif address :is \"from\" \"[email protected]\" {\n reject \"go away\";\n}\n",
|
||||
active: false,
|
||||
},
|
||||
];
|
||||
|
||||
pub static SIEVE_FLAT: &[SieveScript] = &[];
|
||||
|
||||
pub fn layout_for(username: &str) -> Layout {
|
||||
match username {
|
||||
s if s == ACCOUNT1 => Layout {
|
||||
mailboxes: MAILBOXES_DEEP,
|
||||
files: FILES_DEEP,
|
||||
calendars: CALENDARS_DEEP,
|
||||
address_books: ADDRESS_BOOKS_DEEP,
|
||||
sieve_scripts: SIEVE_DEEP,
|
||||
email_count: 200,
|
||||
contact_count: 40,
|
||||
event_count: 40,
|
||||
},
|
||||
s if s == ACCOUNT2 => Layout {
|
||||
mailboxes: MAILBOXES_MEDIUM,
|
||||
files: FILES_MEDIUM,
|
||||
calendars: CALENDARS_MEDIUM,
|
||||
address_books: ADDRESS_BOOKS_MEDIUM,
|
||||
sieve_scripts: SIEVE_MEDIUM,
|
||||
email_count: 80,
|
||||
contact_count: 15,
|
||||
event_count: 15,
|
||||
},
|
||||
_ => Layout {
|
||||
mailboxes: MAILBOXES_FLAT,
|
||||
files: FILES_FLAT,
|
||||
calendars: CALENDARS_FLAT,
|
||||
address_books: ADDRESS_BOOKS_FLAT,
|
||||
sieve_scripts: SIEVE_FLAT,
|
||||
email_count: 20,
|
||||
contact_count: 10,
|
||||
event_count: 10,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn accounts() -> [&'static str; 3] {
|
||||
[ACCOUNT1, ACCOUNT2, ACCOUNT3]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
#![allow(dead_code, unused_imports)]
|
||||
|
||||
pub mod baikal;
|
||||
pub mod cyrus;
|
||||
pub mod data;
|
||||
pub mod dav_client;
|
||||
pub mod dovecot;
|
||||
pub mod error;
|
||||
pub mod imap_client;
|
||||
pub mod layouts;
|
||||
pub mod radicale;
|
||||
pub mod sieve_client;
|
||||
pub mod stalwart;
|
||||
pub mod validate;
|
||||
pub mod webdav;
|
||||
|
||||
pub use error::{ContainerError, ContainerResult};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Endpoint {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl Endpoint {
|
||||
pub fn new(host: impl Into<String>, port: u16) -> Self {
|
||||
Self {
|
||||
host: host.into(),
|
||||
port,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn http_base(&self) -> String {
|
||||
format!("http://{}:{}", self.host, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Account {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub layout: layouts::Layout,
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use testcontainers::core::{IntoContainerPort, WaitFor};
|
||||
use testcontainers::runners::SyncRunner;
|
||||
use testcontainers::{Container, GenericImage, ImageExt};
|
||||
|
||||
use super::data::{RawFixture, load_icals, load_vcards, malformed_ical, rewrite_uid};
|
||||
use super::dav_client::DavSeed;
|
||||
use super::error::{ContainerError, ContainerResult};
|
||||
use super::layouts::{self, Layout};
|
||||
use super::{Account, Endpoint};
|
||||
|
||||
const IMAGE_NAME: &str = "tomsquest/docker-radicale";
|
||||
const IMAGE_TAG: &str = "3.7.3.0";
|
||||
const RADICALE_PORT: u16 = 5232;
|
||||
|
||||
const CONFIG: &str = "[server]
|
||||
hosts = 0.0.0.0:5232
|
||||
|
||||
[auth]
|
||||
type = htpasswd
|
||||
htpasswd_filename = /config/users
|
||||
htpasswd_encryption = plain
|
||||
|
||||
[storage]
|
||||
filesystem_folder = /data/collections
|
||||
";
|
||||
|
||||
pub struct Radicale {
|
||||
container: Container<GenericImage>,
|
||||
pub endpoint: Endpoint,
|
||||
pub accounts: Vec<Account>,
|
||||
}
|
||||
|
||||
impl Radicale {
|
||||
pub fn start() -> ContainerResult<Self> {
|
||||
let mut users = String::new();
|
||||
for u in layouts::accounts() {
|
||||
users.push_str(u);
|
||||
users.push(':');
|
||||
users.push_str(layouts::PASSWORD);
|
||||
users.push('\n');
|
||||
}
|
||||
|
||||
let image = GenericImage::new(IMAGE_NAME, IMAGE_TAG)
|
||||
.with_exposed_port(RADICALE_PORT.tcp())
|
||||
.with_wait_for(WaitFor::message_on_stderr("Radicale server ready"));
|
||||
|
||||
let request = image
|
||||
.with_copy_to("/config/config", CONFIG.as_bytes().to_vec())
|
||||
.with_copy_to("/config/users", users.into_bytes())
|
||||
.with_startup_timeout(Duration::from_secs(90));
|
||||
|
||||
let container = request.start()?;
|
||||
let host = container.get_host()?.to_string();
|
||||
let port = container.get_host_port_ipv4(RADICALE_PORT.tcp())?;
|
||||
|
||||
let accounts: Vec<Account> = layouts::accounts()
|
||||
.iter()
|
||||
.map(|name| Account {
|
||||
username: (*name).to_owned(),
|
||||
password: layouts::PASSWORD.to_owned(),
|
||||
layout: layouts::layout_for(name),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Self {
|
||||
container,
|
||||
endpoint: Endpoint::new(host, port),
|
||||
accounts,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> String {
|
||||
self.endpoint.http_base()
|
||||
}
|
||||
|
||||
pub fn account_url(&self, account: &Account) -> String {
|
||||
format!("{}/{}/", self.base_url(), account.username)
|
||||
}
|
||||
|
||||
pub fn seed_all(&self) -> ContainerResult<Vec<AccountSeed>> {
|
||||
let icals = load_icals()?;
|
||||
let vcards = load_vcards()?;
|
||||
let mut out = Vec::new();
|
||||
for acct in &self.accounts {
|
||||
let seed = self.seed_account(acct, &icals, &vcards)?;
|
||||
out.push(seed);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn seed_account(
|
||||
&self,
|
||||
account: &Account,
|
||||
icals: &[RawFixture],
|
||||
vcards: &[RawFixture],
|
||||
) -> ContainerResult<AccountSeed> {
|
||||
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
|
||||
let layout = &account.layout;
|
||||
let mut seed = AccountSeed::new(account.username.clone());
|
||||
|
||||
for (idx, cal) in layout.calendars.iter().enumerate() {
|
||||
let collection = collection_segment("cal", idx, cal);
|
||||
let base = format!("/{}/{}/", account.username, collection);
|
||||
let body = format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<mkcol xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<set><prop>
|
||||
<resourcetype><collection/><c:calendar/></resourcetype>
|
||||
<displayname>{cal}</displayname>
|
||||
</prop></set>
|
||||
</mkcol>"#
|
||||
);
|
||||
client.mkcol(&base, Some(&body))?;
|
||||
let mut plan = CollectionPlan::new((*cal).to_owned(), base.clone());
|
||||
|
||||
let count = events_per_calendar(layout, idx);
|
||||
let offset = events_offset(layout, idx);
|
||||
for (i, fixture) in icals.iter().cycle().skip(offset).take(count).enumerate() {
|
||||
let path = format!("{base}{}-{}.ics", fixture.name, i);
|
||||
let suffix = format!("{collection}-{i}");
|
||||
let (bytes, uid) = rewrite_uid(&fixture.bytes, &suffix).ok_or_else(|| {
|
||||
ContainerError::Seed(format!("ical fixture {} has no UID", fixture.name))
|
||||
})?;
|
||||
client.put(&path, "text/calendar; charset=utf-8", &bytes)?;
|
||||
plan.items.push(SeededItem {
|
||||
uid,
|
||||
href: path,
|
||||
source: bytes,
|
||||
});
|
||||
}
|
||||
seed.calendars.push(plan);
|
||||
}
|
||||
|
||||
for (idx, ab) in layout.address_books.iter().enumerate() {
|
||||
let collection = collection_segment("ab", idx, ab);
|
||||
let base = format!("/{}/{}/", account.username, collection);
|
||||
let body = format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<mkcol xmlns="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<set><prop>
|
||||
<resourcetype><collection/><card:addressbook/></resourcetype>
|
||||
<displayname>{ab}</displayname>
|
||||
</prop></set>
|
||||
</mkcol>"#
|
||||
);
|
||||
client.mkcol(&base, Some(&body))?;
|
||||
let mut plan = CollectionPlan::new((*ab).to_owned(), base.clone());
|
||||
|
||||
let count = contacts_per_book(layout, idx);
|
||||
let offset = contacts_offset(layout, idx);
|
||||
for (i, fixture) in vcards.iter().cycle().skip(offset).take(count).enumerate() {
|
||||
let path = format!("{base}{}-{}.vcf", fixture.name, i);
|
||||
let suffix = format!("{collection}-{i}");
|
||||
let (bytes, uid) = rewrite_uid(&fixture.bytes, &suffix).ok_or_else(|| {
|
||||
ContainerError::Seed(format!("vcard fixture {} has no UID", fixture.name))
|
||||
})?;
|
||||
client.put(&path, "text/vcard; charset=utf-8", &bytes)?;
|
||||
plan.items.push(SeededItem {
|
||||
uid,
|
||||
href: path,
|
||||
source: bytes,
|
||||
});
|
||||
}
|
||||
seed.address_books.push(plan);
|
||||
}
|
||||
|
||||
Ok(seed)
|
||||
}
|
||||
|
||||
pub fn seed_malformed_event(
|
||||
&self,
|
||||
account: &Account,
|
||||
calendar_segment_idx: usize,
|
||||
tag: &str,
|
||||
) -> ContainerResult<String> {
|
||||
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
|
||||
let collection = collection_segment(
|
||||
"cal",
|
||||
calendar_segment_idx,
|
||||
account.layout.calendars[calendar_segment_idx],
|
||||
);
|
||||
let path = format!("/{}/{}/broken-{tag}.ics", account.username, collection);
|
||||
let bad = malformed_ical(tag);
|
||||
client.put(&path, "text/calendar; charset=utf-8", &bad.bytes)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn delete_item(&self, account: &Account, path: &str) -> ContainerResult<()> {
|
||||
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
|
||||
client.delete(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_event(
|
||||
&self,
|
||||
account: &Account,
|
||||
calendar_segment_idx: usize,
|
||||
tag: &str,
|
||||
icals: &[RawFixture],
|
||||
) -> ContainerResult<(String, String)> {
|
||||
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
|
||||
let display = account.layout.calendars[calendar_segment_idx];
|
||||
let collection = collection_segment("cal", calendar_segment_idx, display);
|
||||
let fixture = icals.first().ok_or_else(|| {
|
||||
ContainerError::Seed("no ical fixture available for add_event".to_owned())
|
||||
})?;
|
||||
let suffix = format!("added-{tag}");
|
||||
let (bytes, uid) = rewrite_uid(&fixture.bytes, &suffix)
|
||||
.ok_or_else(|| ContainerError::Seed("ical fixture has no UID".to_owned()))?;
|
||||
let path = format!("/{}/{collection}/added-{tag}.ics", account.username);
|
||||
client.put(&path, "text/calendar; charset=utf-8", &bytes)?;
|
||||
Ok((path, uid))
|
||||
}
|
||||
|
||||
pub fn add_contact(
|
||||
&self,
|
||||
account: &Account,
|
||||
book_segment_idx: usize,
|
||||
tag: &str,
|
||||
vcards: &[RawFixture],
|
||||
) -> ContainerResult<(String, String)> {
|
||||
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
|
||||
let display = account.layout.address_books[book_segment_idx];
|
||||
let collection = collection_segment("ab", book_segment_idx, display);
|
||||
let fixture = vcards.first().ok_or_else(|| {
|
||||
ContainerError::Seed("no vcard fixture available for add_contact".to_owned())
|
||||
})?;
|
||||
let suffix = format!("added-{tag}");
|
||||
let (bytes, uid) = rewrite_uid(&fixture.bytes, &suffix)
|
||||
.ok_or_else(|| ContainerError::Seed("vcard fixture has no UID".to_owned()))?;
|
||||
let path = format!("/{}/{collection}/added-{tag}.vcf", account.username);
|
||||
client.put(&path, "text/vcard; charset=utf-8", &bytes)?;
|
||||
Ok((path, uid))
|
||||
}
|
||||
|
||||
pub fn verify_seed(&self, seeds: &[AccountSeed]) -> ContainerResult<()> {
|
||||
for (acct, seed) in self.accounts.iter().zip(seeds) {
|
||||
let client = DavSeed::new(self.base_url(), &acct.username, &acct.password);
|
||||
let body = client.propfind(&format!("/{}/", acct.username), 1)?;
|
||||
if !body.contains("multistatus") {
|
||||
return Err(ContainerError::Protocol(format!(
|
||||
"radicale propfind for {} returned no multistatus",
|
||||
acct.username
|
||||
)));
|
||||
}
|
||||
let expected_cal_names: HashSet<&str> = seed
|
||||
.calendars
|
||||
.iter()
|
||||
.map(|c| c.display_name.as_str())
|
||||
.collect();
|
||||
let expected_ab_names: HashSet<&str> = seed
|
||||
.address_books
|
||||
.iter()
|
||||
.map(|c| c.display_name.as_str())
|
||||
.collect();
|
||||
if expected_cal_names.is_empty() && expected_ab_names.is_empty() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop(self) -> ContainerResult<()> {
|
||||
self.container.stop()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SeededItem {
|
||||
pub uid: String,
|
||||
pub href: String,
|
||||
pub source: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CollectionPlan {
|
||||
pub display_name: String,
|
||||
pub base_href: String,
|
||||
pub items: Vec<SeededItem>,
|
||||
}
|
||||
|
||||
impl CollectionPlan {
|
||||
fn new(display_name: String, base_href: String) -> Self {
|
||||
Self {
|
||||
display_name,
|
||||
base_href,
|
||||
items: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccountSeed {
|
||||
pub username: String,
|
||||
pub calendars: Vec<CollectionPlan>,
|
||||
pub address_books: Vec<CollectionPlan>,
|
||||
}
|
||||
|
||||
impl AccountSeed {
|
||||
fn new(username: String) -> Self {
|
||||
Self {
|
||||
username,
|
||||
calendars: Vec::new(),
|
||||
address_books: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn total_events(&self) -> usize {
|
||||
self.calendars.iter().map(|c| c.items.len()).sum()
|
||||
}
|
||||
|
||||
pub fn total_contacts(&self) -> usize {
|
||||
self.address_books.iter().map(|c| c.items.len()).sum()
|
||||
}
|
||||
|
||||
pub fn event_uids(&self) -> HashSet<String> {
|
||||
self.calendars
|
||||
.iter()
|
||||
.flat_map(|c| c.items.iter().map(|i| i.uid.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn contact_uids(&self) -> HashSet<String> {
|
||||
self.address_books
|
||||
.iter()
|
||||
.flat_map(|c| c.items.iter().map(|i| i.uid.clone()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn collection_segment(prefix: &str, idx: usize, name: &str) -> String {
|
||||
let mut out = format!("{prefix}-{:02}-", idx);
|
||||
for c in name.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
out.push(c.to_ascii_lowercase());
|
||||
} else if !out.ends_with('-') {
|
||||
out.push('-');
|
||||
}
|
||||
}
|
||||
out.trim_end_matches('-').to_owned()
|
||||
}
|
||||
|
||||
fn events_per_calendar(layout: &Layout, idx: usize) -> usize {
|
||||
if layout.calendars.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let base = layout.event_count / layout.calendars.len().max(1);
|
||||
let extra = if idx == 0 {
|
||||
layout.event_count % layout.calendars.len()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
base + extra
|
||||
}
|
||||
|
||||
fn events_offset(layout: &Layout, idx: usize) -> usize {
|
||||
(0..idx).map(|i| events_per_calendar(layout, i)).sum()
|
||||
}
|
||||
|
||||
fn contacts_per_book(layout: &Layout, idx: usize) -> usize {
|
||||
if layout.address_books.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let base = layout.contact_count / layout.address_books.len().max(1);
|
||||
let extra = if idx == 0 {
|
||||
layout.contact_count % layout.address_books.len()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
base + extra
|
||||
}
|
||||
|
||||
fn contacts_offset(layout: &Layout, idx: usize) -> usize {
|
||||
(0..idx).map(|i| contacts_per_book(layout, i)).sum()
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as B64;
|
||||
|
||||
use super::error::{ContainerError, ContainerResult};
|
||||
|
||||
pub struct SieveSeed {
|
||||
reader: BufReader<TcpStream>,
|
||||
writer: TcpStream,
|
||||
}
|
||||
|
||||
impl SieveSeed {
|
||||
pub fn connect_seed(host: &str, port: u16) -> ContainerResult<Self> {
|
||||
Self::connect(host, port)
|
||||
}
|
||||
|
||||
pub fn connect(host: &str, port: u16) -> ContainerResult<Self> {
|
||||
let stream = TcpStream::connect((host, port))?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_secs(30)))?;
|
||||
let writer = stream.try_clone()?;
|
||||
let mut me = Self {
|
||||
reader: BufReader::new(stream),
|
||||
writer,
|
||||
};
|
||||
me.read_until_ok()?;
|
||||
Ok(me)
|
||||
}
|
||||
|
||||
fn read_line(&mut self) -> ContainerResult<String> {
|
||||
let mut line = String::new();
|
||||
let n = self.reader.read_line(&mut line)?;
|
||||
if n == 0 {
|
||||
return Err(ContainerError::Protocol("sieve eof".to_owned()));
|
||||
}
|
||||
if line.ends_with('\n') {
|
||||
line.pop();
|
||||
if line.ends_with('\r') {
|
||||
line.pop();
|
||||
}
|
||||
}
|
||||
Ok(line)
|
||||
}
|
||||
|
||||
fn read_until_ok(&mut self) -> ContainerResult<Vec<String>> {
|
||||
let mut lines = Vec::new();
|
||||
loop {
|
||||
let line = self.read_line()?;
|
||||
if line.starts_with("OK") {
|
||||
return Ok(lines);
|
||||
}
|
||||
if line.starts_with("NO") || line.starts_with("BYE") {
|
||||
return Err(ContainerError::Protocol(format!("sieve: {line}")));
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_all(&mut self, bytes: &[u8]) -> ContainerResult<()> {
|
||||
self.writer.write_all(bytes)?;
|
||||
self.writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn authenticate(&mut self, user: &str, password: &str) -> ContainerResult<()> {
|
||||
let mut blob = Vec::new();
|
||||
blob.push(0u8);
|
||||
blob.extend_from_slice(user.as_bytes());
|
||||
blob.push(0u8);
|
||||
blob.extend_from_slice(password.as_bytes());
|
||||
let encoded = B64.encode(&blob);
|
||||
let cmd = format!(
|
||||
"AUTHENTICATE \"PLAIN\" {{{}+}}\r\n{encoded}\r\n",
|
||||
encoded.len()
|
||||
);
|
||||
self.write_all(cmd.as_bytes())?;
|
||||
self.read_until_ok()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn putscript(&mut self, name: &str, body: &str) -> ContainerResult<()> {
|
||||
let cmd = format!(
|
||||
"PUTSCRIPT \"{}\" {{{}+}}\r\n{}\r\n",
|
||||
escape(name),
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
self.write_all(cmd.as_bytes())?;
|
||||
self.read_until_ok()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn putscript_raw(&mut self, name: &str, body: &str) -> ContainerResult<bool> {
|
||||
let cmd = format!(
|
||||
"PUTSCRIPT \"{}\" {{{}+}}\r\n{}\r\n",
|
||||
escape(name),
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
self.write_all(cmd.as_bytes())?;
|
||||
match self.read_until_ok() {
|
||||
Ok(_) => Ok(true),
|
||||
Err(ContainerError::Protocol(_)) => Ok(false),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn setactive(&mut self, name: &str) -> ContainerResult<()> {
|
||||
self.write_all(format!("SETACTIVE \"{}\"\r\n", escape(name)).as_bytes())?;
|
||||
self.read_until_ok()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn listscripts(&mut self) -> ContainerResult<Vec<(String, bool)>> {
|
||||
self.write_all(b"LISTSCRIPTS\r\n")?;
|
||||
let lines = self.read_until_ok()?;
|
||||
let mut scripts = Vec::new();
|
||||
for line in lines {
|
||||
let trimmed = line.trim();
|
||||
if let Some(stripped) = trimmed.strip_prefix('"') {
|
||||
let close = stripped.find('"').unwrap_or(stripped.len());
|
||||
let name = stripped[..close].to_owned();
|
||||
let rest = &stripped[close..];
|
||||
let active = rest.contains("ACTIVE");
|
||||
scripts.push((name, active));
|
||||
}
|
||||
}
|
||||
Ok(scripts)
|
||||
}
|
||||
|
||||
pub fn logout(&mut self) -> ContainerResult<()> {
|
||||
let _ = self.write_all(b"LOGOUT\r\n");
|
||||
let mut buf = String::new();
|
||||
let _ = self.reader.read_to_string(&mut buf);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn escape(s: &str) -> String {
|
||||
s.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::net::TcpListener;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use base64::Engine;
|
||||
use serde_json::Value;
|
||||
use testcontainers::core::{IntoContainerPort, WaitFor};
|
||||
use testcontainers::runners::SyncRunner;
|
||||
use testcontainers::{Container, GenericImage, ImageExt};
|
||||
use ureq::Agent;
|
||||
use ureq::config::RedirectAuthHeaders;
|
||||
use ureq::tls::{TlsConfig, TlsProvider};
|
||||
|
||||
use super::error::{ContainerError, ContainerResult};
|
||||
|
||||
const IMAGE_NAME: &str = "stalwartlabs/stalwart";
|
||||
const IMAGE_TAG: &str = "latest";
|
||||
|
||||
const HTTPS_PORT: u16 = 443;
|
||||
const IMAP_PORT: u16 = 143;
|
||||
const IMAPS_PORT: u16 = 993;
|
||||
const SIEVE_PORT: u16 = 4190;
|
||||
|
||||
const CONFIG_PATH: &str = "/etc/stalwart/config.json";
|
||||
const CONFIG_JSON: &str = r#"{"@type":"RocksDb","blobSize":16834,"bufferSize":134217728,"path":"/var/lib/stalwart","poolWorkers":null}"#;
|
||||
|
||||
pub const ADMIN_USER: &str = "admin";
|
||||
pub const ADMIN_PASSWORD: &str = "admin";
|
||||
|
||||
pub struct Stalwart {
|
||||
_container: Container<GenericImage>,
|
||||
pub host: String,
|
||||
pub https_port: u16,
|
||||
pub imap_port: u16,
|
||||
pub imaps_port: u16,
|
||||
pub sieve_port: u16,
|
||||
pub public_url: String,
|
||||
}
|
||||
|
||||
impl Stalwart {
|
||||
pub fn start() -> ContainerResult<Self> {
|
||||
let https_port = pick_free_port()?;
|
||||
let imap_port = pick_free_port()?;
|
||||
let imaps_port = pick_free_port()?;
|
||||
let sieve_port = pick_free_port()?;
|
||||
|
||||
let host = "127.0.0.1".to_owned();
|
||||
let public_url = format!("https://{host}:{https_port}");
|
||||
|
||||
let image = GenericImage::new(IMAGE_NAME, IMAGE_TAG)
|
||||
.with_exposed_port(HTTPS_PORT.tcp())
|
||||
.with_exposed_port(IMAP_PORT.tcp())
|
||||
.with_exposed_port(IMAPS_PORT.tcp())
|
||||
.with_exposed_port(SIEVE_PORT.tcp())
|
||||
.with_wait_for(WaitFor::seconds(2));
|
||||
|
||||
let request = image
|
||||
.with_env_var("STALWART_PUBLIC_URL", &public_url)
|
||||
.with_env_var("STALWART_RECOVERY_ADMIN", "admin:admin")
|
||||
.with_copy_to(CONFIG_PATH, CONFIG_JSON.as_bytes().to_vec())
|
||||
.with_mapped_port(https_port, HTTPS_PORT.tcp())
|
||||
.with_mapped_port(imap_port, IMAP_PORT.tcp())
|
||||
.with_mapped_port(imaps_port, IMAPS_PORT.tcp())
|
||||
.with_mapped_port(sieve_port, SIEVE_PORT.tcp())
|
||||
.with_startup_timeout(Duration::from_secs(180));
|
||||
|
||||
let container = request.start()?;
|
||||
|
||||
let me = Self {
|
||||
_container: container,
|
||||
host,
|
||||
https_port,
|
||||
imap_port,
|
||||
imaps_port,
|
||||
sieve_port,
|
||||
public_url,
|
||||
};
|
||||
|
||||
me.wait_ready(Duration::from_secs(120))?;
|
||||
Ok(me)
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> &str {
|
||||
&self.public_url
|
||||
}
|
||||
|
||||
pub fn fetch_jmap_session(&self) -> ContainerResult<Value> {
|
||||
let agent = build_agent();
|
||||
let auth = basic(ADMIN_USER, ADMIN_PASSWORD);
|
||||
let url = format!("{}/.well-known/jmap", self.public_url);
|
||||
let mut resp = agent.get(&url).header("Authorization", &auth).call()?;
|
||||
let status = resp.status().as_u16();
|
||||
let text = resp
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.map_err(|e| ContainerError::Protocol(format!("body read: {e}")))?;
|
||||
if status != 200 {
|
||||
return Err(ContainerError::Protocol(format!(
|
||||
"jmap session status {status}: {text}"
|
||||
)));
|
||||
}
|
||||
serde_json::from_str(&text)
|
||||
.map_err(|e| ContainerError::Protocol(format!("jmap session parse: {e}")))
|
||||
}
|
||||
|
||||
fn wait_ready(&self, total: Duration) -> ContainerResult<()> {
|
||||
let deadline = Instant::now() + total;
|
||||
let mut last_err = String::from("no probe attempted");
|
||||
while Instant::now() < deadline {
|
||||
match self.fetch_jmap_session() {
|
||||
Ok(v) if v.get("apiUrl").is_some() => return Ok(()),
|
||||
Ok(v) => last_err = format!("session missing apiUrl: {v}"),
|
||||
Err(e) => last_err = e.to_string(),
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
Err(ContainerError::Protocol(format!(
|
||||
"stalwart did not become ready in {total:?}: {last_err}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
static SHARED: OnceLock<Stalwart> = OnceLock::new();
|
||||
|
||||
pub fn shared() -> &'static Stalwart {
|
||||
SHARED.get_or_init(|| Stalwart::start().expect("start shared stalwart container"))
|
||||
}
|
||||
|
||||
fn pick_free_port() -> ContainerResult<u16> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
Ok(listener.local_addr()?.port())
|
||||
}
|
||||
|
||||
fn build_agent() -> Agent {
|
||||
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||
let tls = TlsConfig::builder()
|
||||
.provider(TlsProvider::Rustls)
|
||||
.unversioned_rustls_crypto_provider(provider)
|
||||
.disable_verification(true)
|
||||
.build();
|
||||
Agent::config_builder()
|
||||
.tls_config(tls)
|
||||
.http_status_as_error(false)
|
||||
.max_redirects(10)
|
||||
.redirect_auth_headers(RedirectAuthHeaders::SameHost)
|
||||
.build()
|
||||
.new_agent()
|
||||
}
|
||||
|
||||
fn basic(user: &str, password: &str) -> String {
|
||||
let mut raw = String::with_capacity(user.len() + password.len() + 1);
|
||||
raw.push_str(user);
|
||||
raw.push(':');
|
||||
raw.push_str(password);
|
||||
format!(
|
||||
"Basic {}",
|
||||
base64::engine::general_purpose::STANDARD.encode(raw.as_bytes())
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use vandelay::logging::Logger;
|
||||
use vandelay::sync::CommonConfig;
|
||||
|
||||
pub fn tmp_archive(tag: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!(
|
||||
"vandelay-container-{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
|
||||
}
|
||||
|
||||
pub fn common(archive: &Path) -> CommonConfig {
|
||||
CommonConfig {
|
||||
archive: archive.to_path_buf(),
|
||||
threads: 2,
|
||||
dry_run: false,
|
||||
max_retries: 3,
|
||||
allow_invalid_certs: true,
|
||||
logger: Logger::from_flags(false, 0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_archive(archive: &Path) -> Connection {
|
||||
Connection::open(archive).expect("open archive")
|
||||
}
|
||||
|
||||
pub fn count(conn: &Connection, table: &str) -> i64 {
|
||||
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn blob_bytes(conn: &Connection, blob_id: i64) -> Vec<u8> {
|
||||
conn.query_row("SELECT data FROM blobs WHERE id = ?1", [blob_id], |r| {
|
||||
r.get::<_, Vec<u8>>(0)
|
||||
})
|
||||
.expect("blob fetch")
|
||||
}
|
||||
|
||||
pub fn mailbox_path(conn: &Connection, mailbox_id: i64, sep: char) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
let mut cur = Some(mailbox_id);
|
||||
while let Some(id) = cur {
|
||||
let (name, parent): (String, Option<i64>) = conn
|
||||
.query_row(
|
||||
"SELECT name, parent_id FROM mailboxes WHERE id = ?1",
|
||||
[id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.expect("mailbox path");
|
||||
parts.push(name);
|
||||
cur = parent;
|
||||
}
|
||||
parts.reverse();
|
||||
parts.join(&sep.to_string())
|
||||
}
|
||||
|
||||
pub fn file_path(conn: &Connection, node_id: i64) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
let mut cur = Some(node_id);
|
||||
while let Some(id) = cur {
|
||||
let (name, parent): (String, Option<i64>) = conn
|
||||
.query_row(
|
||||
"SELECT name, parent_id FROM file_nodes WHERE id = ?1",
|
||||
[id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.expect("file path");
|
||||
parts.push(name);
|
||||
cur = parent;
|
||||
}
|
||||
parts.reverse();
|
||||
parts.join("/")
|
||||
}
|
||||
|
||||
pub fn emails_in_mailbox(conn: &Connection, mailbox_id: i64) -> i64 {
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM emails
|
||||
WHERE EXISTS (SELECT 1 FROM json_each(emails.mailbox_ids) j WHERE j.value = ?1)",
|
||||
[mailbox_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn email_rows_for_blob(conn: &Connection, blob_id: i64) -> i64 {
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM emails WHERE blob_id = ?1",
|
||||
[blob_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn blob_id_for_hash(conn: &Connection, hash: &[u8]) -> Option<i64> {
|
||||
conn.query_row("SELECT id FROM blobs WHERE hash = ?1", [hash], |r| r.get(0))
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn mailbox_id_by_name(conn: &Connection, name: &str) -> Option<i64> {
|
||||
conn.query_row("SELECT id FROM mailboxes WHERE name = ?1", [name], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn mailbox_id_by_path(conn: &Connection, path: &str, sep: char) -> Option<i64> {
|
||||
let mut parent: Option<i64> = None;
|
||||
for segment in path.split(sep) {
|
||||
let id: i64 = match parent {
|
||||
Some(p) => conn
|
||||
.query_row(
|
||||
"SELECT id FROM mailboxes WHERE parent_id = ?1 AND name = ?2",
|
||||
rusqlite::params![p, segment],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()?,
|
||||
None => conn
|
||||
.query_row(
|
||||
"SELECT id FROM mailboxes WHERE parent_id IS NULL AND name = ?1",
|
||||
[segment],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()?,
|
||||
};
|
||||
parent = Some(id);
|
||||
}
|
||||
parent
|
||||
}
|
||||
|
||||
pub fn file_node_id_by_path(conn: &Connection, segments: &[&str]) -> Option<i64> {
|
||||
let mut parent: Option<i64> = None;
|
||||
for segment in segments {
|
||||
let id: i64 = match parent {
|
||||
Some(p) => conn
|
||||
.query_row(
|
||||
"SELECT id FROM file_nodes WHERE parent_id = ?1 AND name = ?2",
|
||||
rusqlite::params![p, segment],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()?,
|
||||
None => conn
|
||||
.query_row(
|
||||
"SELECT id FROM file_nodes WHERE parent_id IS NULL AND name = ?1",
|
||||
[segment],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()?,
|
||||
};
|
||||
parent = Some(id);
|
||||
}
|
||||
parent
|
||||
}
|
||||
|
||||
pub fn collection_names(conn: &Connection, table: &str) -> HashSet<String> {
|
||||
let mut stmt = conn
|
||||
.prepare(&format!("SELECT name FROM {table}"))
|
||||
.expect("prepare");
|
||||
stmt.query_map([], |r| r.get::<_, String>(0))
|
||||
.expect("query")
|
||||
.filter_map(|r| r.ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn collection_id_by_name(conn: &Connection, table: &str, name: &str) -> Option<i64> {
|
||||
conn.query_row(
|
||||
&format!("SELECT id FROM {table} WHERE name = ?1"),
|
||||
[name],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn calendar_event_count(conn: &Connection, calendar_id: i64) -> i64 {
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM calendar_events
|
||||
WHERE EXISTS (SELECT 1 FROM json_each(calendar_events.calendar_ids) j WHERE j.value = ?1)",
|
||||
[calendar_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn contact_card_count(conn: &Connection, address_book_id: i64) -> i64 {
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM contact_cards
|
||||
WHERE EXISTS (SELECT 1 FROM json_each(contact_cards.address_book_ids) j WHERE j.value = ?1)",
|
||||
[address_book_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn all_event_uids(conn: &Connection) -> HashSet<String> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT json_extract(data, '$.uid') FROM calendar_events
|
||||
WHERE json_extract(data, '$.uid') IS NOT NULL",
|
||||
)
|
||||
.expect("prepare");
|
||||
stmt.query_map([], |r| r.get::<_, String>(0))
|
||||
.expect("query")
|
||||
.filter_map(|r| r.ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn all_contact_uids(conn: &Connection) -> HashSet<String> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT uid FROM contact_cards")
|
||||
.expect("prepare");
|
||||
stmt.query_map([], |r| r.get::<_, String>(0))
|
||||
.expect("query")
|
||||
.filter_map(|r| r.ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn keywords_for_blob(conn: &Connection, blob_id: i64) -> Vec<String> {
|
||||
let json: String = conn
|
||||
.query_row(
|
||||
"SELECT keywords FROM emails WHERE blob_id = ?1 LIMIT 1",
|
||||
[blob_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.expect("keywords fetch");
|
||||
serde_json::from_str::<Vec<String>>(&json).expect("keywords json")
|
||||
}
|
||||
|
||||
pub fn event_data_by_uid(conn: &Connection, uid: &str) -> Option<serde_json::Value> {
|
||||
let raw: String = conn
|
||||
.query_row(
|
||||
"SELECT data FROM calendar_events WHERE json_extract(data,'$.uid') = ?1",
|
||||
[uid],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()?;
|
||||
serde_json::from_str(&raw).ok()
|
||||
}
|
||||
|
||||
pub fn contact_data_by_uid(conn: &Connection, uid: &str) -> Option<serde_json::Value> {
|
||||
let raw: String = conn
|
||||
.query_row(
|
||||
"SELECT data FROM contact_cards WHERE uid = ?1",
|
||||
[uid],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()?;
|
||||
serde_json::from_str(&raw).ok()
|
||||
}
|
||||
|
||||
pub fn unfold_text(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
for line in text.lines() {
|
||||
let l = line.trim_end_matches('\r');
|
||||
if l.starts_with(' ') || l.starts_with('\t') {
|
||||
out.push_str(&l[1..]);
|
||||
} else {
|
||||
if !out.is_empty() {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(l);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn extract_property(text: &str, name: &str) -> Option<String> {
|
||||
component_top_level_properties(text, name)
|
||||
.into_iter()
|
||||
.next()
|
||||
}
|
||||
|
||||
pub fn extract_property_all(text: &str, name: &str) -> Vec<String> {
|
||||
component_top_level_properties(text, name)
|
||||
}
|
||||
|
||||
fn component_top_level_properties(text: &str, name: &str) -> Vec<String> {
|
||||
let value_colon = format!("{name}:");
|
||||
let value_semi = format!("{name};");
|
||||
let mut out = Vec::new();
|
||||
let mut depth: i32 = 0;
|
||||
let mut item_depth: Option<i32> = None;
|
||||
for line in unfold_text(text).lines() {
|
||||
let l = line.trim_end_matches('\r');
|
||||
if l.starts_with("BEGIN:") {
|
||||
depth += 1;
|
||||
if matches!(l, "BEGIN:VEVENT" | "BEGIN:VTODO" | "BEGIN:VCARD") {
|
||||
item_depth = Some(depth);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if l.starts_with("END:") {
|
||||
if Some(depth) == item_depth {
|
||||
item_depth = None;
|
||||
}
|
||||
depth -= 1;
|
||||
continue;
|
||||
}
|
||||
if item_depth.is_some_and(|d| d == depth) {
|
||||
if let Some(rest) = l.strip_prefix(&value_colon) {
|
||||
out.push(rest.to_owned());
|
||||
} else if l.starts_with(&value_semi)
|
||||
&& let Some(colon) = l.find(':')
|
||||
{
|
||||
out.push(l[colon + 1..].to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn has_line_prefix(text: &str, prefix: &str) -> bool {
|
||||
let unfolded = unfold_text(text);
|
||||
unfolded.lines().any(|l| l.starts_with(prefix))
|
||||
}
|
||||
|
||||
pub fn json_contains_string(value: &serde_json::Value, needle: &str) -> bool {
|
||||
let serialised = value.to_string();
|
||||
serialised.contains(needle)
|
||||
}
|
||||
|
||||
pub fn assert_message_round_trip(conn: &Connection, raw: &[u8], target_mailbox: &str, label: &str) {
|
||||
let hash = blake3::hash(raw);
|
||||
let blob_id = blob_id_for_hash(conn, hash.as_bytes())
|
||||
.unwrap_or_else(|| panic!("{label}: blob for seeded message missing in archive"));
|
||||
let stored = blob_bytes(conn, blob_id);
|
||||
assert_eq!(
|
||||
stored, raw,
|
||||
"{label}: stored blob bytes differ from seeded raw"
|
||||
);
|
||||
let mailbox_id = mailbox_id_by_path(conn, target_mailbox, '/')
|
||||
.unwrap_or_else(|| panic!("{label}: target mailbox {target_mailbox} missing in archive"));
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM emails
|
||||
WHERE blob_id = ?1
|
||||
AND EXISTS (SELECT 1 FROM json_each(mailbox_ids) j WHERE j.value = ?2)",
|
||||
rusqlite::params![blob_id, mailbox_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
count >= 1,
|
||||
"{label}: blob {blob_id} not linked to mailbox {target_mailbox} (id {mailbox_id})"
|
||||
);
|
||||
}
|
||||
|
||||
pub fn assert_event_round_trip(conn: &Connection, source: &[u8], uid: &str, label: &str) {
|
||||
let text = std::str::from_utf8(source).expect("seeded ical bytes utf-8");
|
||||
let data = event_data_by_uid(conn, uid)
|
||||
.unwrap_or_else(|| panic!("{label}: event uid {uid} missing in archive"));
|
||||
let obj = data.as_object().expect("event data is object");
|
||||
|
||||
let stored_uid = obj.get("uid").and_then(|v| v.as_str()).unwrap_or("");
|
||||
assert_eq!(
|
||||
stored_uid, uid,
|
||||
"{label}: event uid mismatch in stored data"
|
||||
);
|
||||
|
||||
let summary = extract_property(text, "SUMMARY").unwrap_or_default();
|
||||
if !summary.is_empty() {
|
||||
let title = obj.get("title").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let expected = unescape_text(&summary);
|
||||
assert!(
|
||||
title == expected || title.contains(&expected),
|
||||
"{label}: event {uid} title mismatch: stored={title:?} expected to contain={expected:?}"
|
||||
);
|
||||
}
|
||||
|
||||
if extract_property(text, "DESCRIPTION").is_some() {
|
||||
let descr = obj
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
assert!(
|
||||
!descr.is_empty(),
|
||||
"{label}: event {uid} description was seeded but stored data has no description"
|
||||
);
|
||||
}
|
||||
|
||||
let start = obj.get("start").and_then(|v| v.as_str()).unwrap_or("");
|
||||
assert!(
|
||||
!start.is_empty(),
|
||||
"{label}: event {uid} stored data.start is empty"
|
||||
);
|
||||
|
||||
if has_line_prefix(text, "RRULE:") || has_line_prefix(text, "RRULE;") {
|
||||
let has_rule = obj
|
||||
.get("recurrenceRules")
|
||||
.map(|v| !v.as_array().is_some_and(|a| a.is_empty()))
|
||||
.unwrap_or(false)
|
||||
|| obj
|
||||
.get("recurrenceRule")
|
||||
.map(|v| {
|
||||
!v.as_object().is_some_and(|o| o.is_empty())
|
||||
&& !v.as_array().is_some_and(|a| a.is_empty())
|
||||
})
|
||||
.unwrap_or(false);
|
||||
assert!(
|
||||
has_rule,
|
||||
"{label}: event {uid} seeded RRULE but no recurrenceRule/recurrenceRules in stored data: {obj:?}"
|
||||
);
|
||||
}
|
||||
|
||||
if has_line_prefix(text, "BEGIN:VALARM") {
|
||||
let alerts = obj
|
||||
.get("alerts")
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
!alerts.is_empty(),
|
||||
"{label}: event {uid} seeded VALARM but alerts empty"
|
||||
);
|
||||
}
|
||||
|
||||
if has_line_prefix(text, "ORGANIZER:")
|
||||
|| has_line_prefix(text, "ORGANIZER;")
|
||||
|| has_line_prefix(text, "ATTENDEE:")
|
||||
|| has_line_prefix(text, "ATTENDEE;")
|
||||
{
|
||||
let parts = obj
|
||||
.get("participants")
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
!parts.is_empty(),
|
||||
"{label}: event {uid} seeded ORGANIZER/ATTENDEE but participants empty"
|
||||
);
|
||||
for addr in extract_property_all(text, "ATTENDEE") {
|
||||
if let Some(idx) = addr.find("mailto:")
|
||||
&& let Some(end) = addr[idx + 7..].find([',', ';'])
|
||||
{
|
||||
let email = &addr[idx + 7..idx + 7 + end];
|
||||
assert!(
|
||||
json_contains_string(&data, email),
|
||||
"{label}: event {uid} attendee {email} not present in stored JSON"
|
||||
);
|
||||
} else if let Some(idx) = addr.find("mailto:") {
|
||||
let email = &addr[idx + 7..];
|
||||
assert!(
|
||||
json_contains_string(&data, email),
|
||||
"{label}: event {uid} attendee {email} not present in stored JSON"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(location) = extract_property(text, "LOCATION") {
|
||||
let cleaned = unescape_text(&location);
|
||||
let probe = cleaned.split(',').next().unwrap_or(&cleaned).trim();
|
||||
if !probe.is_empty() {
|
||||
assert!(
|
||||
json_contains_string(&data, probe),
|
||||
"{label}: event {uid} location fragment {probe:?} not present in stored JSON"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(cats) = extract_property(text, "CATEGORIES") {
|
||||
let keywords = obj
|
||||
.get("keywords")
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
!keywords.is_empty(),
|
||||
"{label}: event {uid} seeded CATEGORIES={cats} but keywords empty"
|
||||
);
|
||||
for cat in cats.split(',') {
|
||||
let cat = cat.trim();
|
||||
if cat.is_empty() {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
keywords.contains_key(cat),
|
||||
"{label}: event {uid} category {cat} not in keywords {keywords:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assert_contact_round_trip(conn: &Connection, source: &[u8], uid: &str, label: &str) {
|
||||
let text = std::str::from_utf8(source).expect("seeded vcard bytes utf-8");
|
||||
let data = contact_data_by_uid(conn, uid)
|
||||
.unwrap_or_else(|| panic!("{label}: contact uid {uid} missing in archive"));
|
||||
|
||||
if let Some(fn_value) = extract_property(text, "FN") {
|
||||
let cleaned = unescape_text(&fn_value);
|
||||
assert!(
|
||||
json_contains_string(&data, &cleaned),
|
||||
"{label}: contact {uid} FN {cleaned:?} not present in stored JSContact"
|
||||
);
|
||||
}
|
||||
|
||||
for email in extract_property_all(text, "EMAIL") {
|
||||
assert!(
|
||||
json_contains_string(&data, &email),
|
||||
"{label}: contact {uid} EMAIL {email:?} not present in stored JSContact"
|
||||
);
|
||||
}
|
||||
|
||||
for tel in extract_property_all(text, "TEL") {
|
||||
assert!(
|
||||
json_contains_string(&data, &tel),
|
||||
"{label}: contact {uid} TEL {tel:?} not present in stored JSContact"
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(org) = extract_property(text, "ORG") {
|
||||
let primary = org.split(';').next().unwrap_or(&org).trim();
|
||||
if !primary.is_empty() {
|
||||
assert!(
|
||||
json_contains_string(&data, primary),
|
||||
"{label}: contact {uid} ORG {primary:?} not present in stored JSContact"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(nick) = extract_property(text, "NICKNAME") {
|
||||
assert!(
|
||||
json_contains_string(&data, &nick),
|
||||
"{label}: contact {uid} NICKNAME {nick:?} not present"
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(bday) = extract_property(text, "BDAY") {
|
||||
let iso = format_vcard_date_as_iso(&bday);
|
||||
let present_raw = json_contains_string(&data, &bday);
|
||||
let present_iso = iso
|
||||
.as_deref()
|
||||
.map(|s| json_contains_string(&data, s))
|
||||
.unwrap_or(false);
|
||||
let obj = data.as_object().expect("contact data is object");
|
||||
let has_anniversaries = obj
|
||||
.get("anniversaries")
|
||||
.map(|v| !v.as_object().is_some_and(|o| o.is_empty()))
|
||||
.unwrap_or(false);
|
||||
assert!(
|
||||
present_raw || present_iso || has_anniversaries,
|
||||
"{label}: contact {uid} BDAY {bday:?} not present in stored JSContact (no anniversaries either)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn format_vcard_date_as_iso(bday: &str) -> Option<String> {
|
||||
let trimmed = bday.trim();
|
||||
if trimmed.len() == 8 && trimmed.bytes().all(|b| b.is_ascii_digit()) {
|
||||
Some(format!(
|
||||
"{}-{}-{}",
|
||||
&trimmed[..4],
|
||||
&trimmed[4..6],
|
||||
&trimmed[6..8]
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn unescape_text(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut chars = s.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\\'
|
||||
&& let Some(&next) = chars.peek()
|
||||
{
|
||||
match next {
|
||||
',' | ';' | '\\' => {
|
||||
out.push(next);
|
||||
chars.next();
|
||||
continue;
|
||||
}
|
||||
'n' | 'N' => {
|
||||
out.push('\n');
|
||||
chars.next();
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn cleanup(archive: &Path) {
|
||||
let _ = std::fs::remove_file(archive);
|
||||
let mut wal = archive.as_os_str().to_owned();
|
||||
wal.push("-wal");
|
||||
let _ = std::fs::remove_file(Path::new(&wal));
|
||||
let mut shm = archive.as_os_str().to_owned();
|
||||
shm.push("-shm");
|
||||
let _ = std::fs::remove_file(Path::new(&shm));
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as B64;
|
||||
use testcontainers::core::{IntoContainerPort, WaitFor};
|
||||
use testcontainers::runners::{SyncBuilder, SyncRunner};
|
||||
use testcontainers::{Container, GenericBuildableImage, GenericImage, ImageExt};
|
||||
|
||||
use super::dav_client::DavSeed;
|
||||
use super::error::{ContainerError, ContainerResult};
|
||||
use super::layouts::{self, FileSpec};
|
||||
use super::{Account, Endpoint};
|
||||
|
||||
const HTTP_PORT: u16 = 80;
|
||||
|
||||
const DOCKERFILE: &str = r#"FROM debian:bookworm-20260518-slim
|
||||
|
||||
RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
apache2 apache2-utils ca-certificates && \
|
||||
a2enmod dav dav_fs auth_basic authn_file && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY apache.conf /etc/apache2/sites-available/000-default.conf
|
||||
COPY htpasswd /etc/apache2/htpasswd
|
||||
|
||||
RUN mkdir -p /var/dav && \
|
||||
chown -R www-data:www-data /var/dav
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["apachectl", "-D", "FOREGROUND"]
|
||||
"#;
|
||||
|
||||
const APACHE_CONF: &str = r#"DavLockDB /tmp/DavLock
|
||||
|
||||
<VirtualHost *:80>
|
||||
DocumentRoot /var/dav
|
||||
ErrorLog /dev/stderr
|
||||
CustomLog /dev/stdout combined
|
||||
|
||||
<Directory /var/dav>
|
||||
Dav On
|
||||
AuthType Basic
|
||||
AuthName "WebDAV"
|
||||
AuthUserFile /etc/apache2/htpasswd
|
||||
Require valid-user
|
||||
Options Indexes FollowSymLinks
|
||||
</Directory>
|
||||
</VirtualHost>
|
||||
"#;
|
||||
|
||||
fn htpasswd_for_test() -> String {
|
||||
let mut out = String::new();
|
||||
for u in layouts::accounts() {
|
||||
out.push_str(u);
|
||||
out.push(':');
|
||||
out.push_str("{SHA}");
|
||||
out.push_str(&sha1_b64(layouts::PASSWORD.as_bytes()));
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn sha1_b64(bytes: &[u8]) -> String {
|
||||
let digest = simple_sha1(bytes);
|
||||
B64.encode(digest)
|
||||
}
|
||||
|
||||
fn simple_sha1(bytes: &[u8]) -> [u8; 20] {
|
||||
let mut h0: u32 = 0x6745_2301;
|
||||
let mut h1: u32 = 0xEFCD_AB89;
|
||||
let mut h2: u32 = 0x98BA_DCFE;
|
||||
let mut h3: u32 = 0x1032_5476;
|
||||
let mut h4: u32 = 0xC3D2_E1F0;
|
||||
|
||||
let bit_len = (bytes.len() as u64).wrapping_mul(8);
|
||||
let mut msg = bytes.to_vec();
|
||||
msg.push(0x80);
|
||||
while msg.len() % 64 != 56 {
|
||||
msg.push(0);
|
||||
}
|
||||
msg.extend_from_slice(&bit_len.to_be_bytes());
|
||||
|
||||
for chunk in msg.chunks(64) {
|
||||
let mut w = [0u32; 80];
|
||||
for (i, word) in w.iter_mut().take(16).enumerate() {
|
||||
*word = u32::from_be_bytes(chunk[i * 4..i * 4 + 4].try_into().unwrap());
|
||||
}
|
||||
for i in 16..80 {
|
||||
w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
|
||||
}
|
||||
let (mut a, mut b, mut c, mut d, mut e) = (h0, h1, h2, h3, h4);
|
||||
for (i, &wi) in w.iter().enumerate() {
|
||||
let (f, k): (u32, u32) = match i {
|
||||
0..=19 => ((b & c) | ((!b) & d), 0x5A82_7999),
|
||||
20..=39 => (b ^ c ^ d, 0x6ED9_EBA1),
|
||||
40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1B_BCDC),
|
||||
_ => (b ^ c ^ d, 0xCA62_C1D6),
|
||||
};
|
||||
let temp = a
|
||||
.rotate_left(5)
|
||||
.wrapping_add(f)
|
||||
.wrapping_add(e)
|
||||
.wrapping_add(k)
|
||||
.wrapping_add(wi);
|
||||
e = d;
|
||||
d = c;
|
||||
c = b.rotate_left(30);
|
||||
b = a;
|
||||
a = temp;
|
||||
}
|
||||
h0 = h0.wrapping_add(a);
|
||||
h1 = h1.wrapping_add(b);
|
||||
h2 = h2.wrapping_add(c);
|
||||
h3 = h3.wrapping_add(d);
|
||||
h4 = h4.wrapping_add(e);
|
||||
}
|
||||
let mut out = [0u8; 20];
|
||||
out[..4].copy_from_slice(&h0.to_be_bytes());
|
||||
out[4..8].copy_from_slice(&h1.to_be_bytes());
|
||||
out[8..12].copy_from_slice(&h2.to_be_bytes());
|
||||
out[12..16].copy_from_slice(&h3.to_be_bytes());
|
||||
out[16..20].copy_from_slice(&h4.to_be_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
pub struct WebDav {
|
||||
container: Container<GenericImage>,
|
||||
pub http: Endpoint,
|
||||
pub accounts: Vec<Account>,
|
||||
}
|
||||
|
||||
impl WebDav {
|
||||
pub fn start() -> ContainerResult<Self> {
|
||||
let image: GenericImage = GenericBuildableImage::new("vandelay-webdav", "test")
|
||||
.with_dockerfile_string(DOCKERFILE.to_owned())
|
||||
.with_data(APACHE_CONF.as_bytes().to_vec(), "apache.conf")
|
||||
.with_data(htpasswd_for_test().into_bytes(), "htpasswd")
|
||||
.build_image()
|
||||
.map_err(|e| ContainerError::Seed(format!("webdav build: {e}")))?;
|
||||
|
||||
let request = image
|
||||
.with_exposed_port(HTTP_PORT.tcp())
|
||||
.with_wait_for(WaitFor::message_on_stderr("AH00558"))
|
||||
.with_startup_timeout(Duration::from_secs(120));
|
||||
|
||||
let container = request.start()?;
|
||||
let host = container.get_host()?.to_string();
|
||||
let http = Endpoint::new(host, container.get_host_port_ipv4(HTTP_PORT.tcp())?);
|
||||
|
||||
let accounts: Vec<Account> = layouts::accounts()
|
||||
.iter()
|
||||
.map(|name| Account {
|
||||
username: (*name).to_owned(),
|
||||
password: layouts::PASSWORD.to_owned(),
|
||||
layout: layouts::layout_for(name),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Self {
|
||||
container,
|
||||
http,
|
||||
accounts,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> String {
|
||||
self.http.http_base()
|
||||
}
|
||||
|
||||
pub fn account_url(&self, account: &Account) -> String {
|
||||
format!("{}/{}/", self.base_url(), account.username)
|
||||
}
|
||||
|
||||
pub fn seed_all(&self) -> ContainerResult<Vec<AccountSeed>> {
|
||||
let mut out = Vec::new();
|
||||
for acct in &self.accounts {
|
||||
let m = self.seed_account(acct)?;
|
||||
out.push(m);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn seed_account(&self, account: &Account) -> ContainerResult<AccountSeed> {
|
||||
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
|
||||
let root = format!("/{}/", account.username);
|
||||
client.mkcol(&root, None)?;
|
||||
let mut seed = AccountSeed::new(account.username.clone());
|
||||
|
||||
let specs = account.layout.files;
|
||||
for spec in specs {
|
||||
let path = build_path(&root, specs, spec.key)
|
||||
.ok_or_else(|| ContainerError::Seed(format!("missing file key: {}", spec.key)))?;
|
||||
if spec.directory {
|
||||
client.mkcol(&path, None)?;
|
||||
seed.directories += 1;
|
||||
} else {
|
||||
let payload = synth_payload(spec.name);
|
||||
client.put(&path, "application/octet-stream", payload.as_bytes())?;
|
||||
seed.files.push(SeededFile {
|
||||
key: spec.key.to_owned(),
|
||||
name: spec.name.to_owned(),
|
||||
href: path,
|
||||
payload: payload.into_bytes(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(seed)
|
||||
}
|
||||
|
||||
pub fn delete_resource(&self, account: &Account, href: &str) -> ContainerResult<()> {
|
||||
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
|
||||
client.delete(href)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_file(
|
||||
&self,
|
||||
account: &Account,
|
||||
parent_segments: &[&str],
|
||||
name: &str,
|
||||
payload: &[u8],
|
||||
) -> ContainerResult<String> {
|
||||
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
|
||||
let mut href = format!("/{}/", account.username);
|
||||
for seg in parent_segments {
|
||||
href.push_str(seg);
|
||||
href.push('/');
|
||||
}
|
||||
href.push_str(name);
|
||||
client.put(&href, "application/octet-stream", payload)?;
|
||||
Ok(href)
|
||||
}
|
||||
|
||||
pub fn verify_seed(&self) -> ContainerResult<()> {
|
||||
for acct in &self.accounts {
|
||||
let client = DavSeed::new(self.base_url(), &acct.username, &acct.password);
|
||||
let body = client.propfind(&format!("/{}/", acct.username), 1)?;
|
||||
if !body.contains("multistatus") {
|
||||
return Err(ContainerError::Protocol(format!(
|
||||
"webdav propfind for {} returned no multistatus",
|
||||
acct.username
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop(self) -> ContainerResult<()> {
|
||||
self.container.stop()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_path(root: &str, specs: &[FileSpec], key: &str) -> Option<String> {
|
||||
let mut chain: Vec<&str> = Vec::new();
|
||||
let mut cur = key;
|
||||
let mut leaf_dir = false;
|
||||
loop {
|
||||
let spec = specs.iter().find(|s| s.key == cur)?;
|
||||
chain.push(spec.name);
|
||||
if chain.len() == 1 {
|
||||
leaf_dir = spec.directory;
|
||||
}
|
||||
match spec.parent {
|
||||
Some(p) => cur = p,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
chain.reverse();
|
||||
let mut path = String::from(root);
|
||||
for (i, name) in chain.iter().enumerate() {
|
||||
path.push_str(name);
|
||||
let is_last = i + 1 == chain.len();
|
||||
if !is_last || leaf_dir {
|
||||
path.push('/');
|
||||
}
|
||||
}
|
||||
Some(path)
|
||||
}
|
||||
|
||||
pub fn synth_payload(name: &str) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str(name);
|
||||
out.push('\n');
|
||||
for i in 0..16 {
|
||||
out.push_str(&format!("line {i} for {name}\n"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SeededFile {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub href: String,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccountSeed {
|
||||
pub username: String,
|
||||
pub directories: usize,
|
||||
pub files: Vec<SeededFile>,
|
||||
}
|
||||
|
||||
impl AccountSeed {
|
||||
fn new(username: String) -> Self {
|
||||
Self {
|
||||
username,
|
||||
directories: 0,
|
||||
files: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
mod integration;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use integration::Account;
|
||||
use integration::baikal::{AccountSeed, Baikal};
|
||||
use integration::validate::{
|
||||
all_contact_uids, all_event_uids, assert_contact_round_trip, assert_event_round_trip,
|
||||
calendar_event_count, cleanup, collection_id_by_name, collection_names, common,
|
||||
contact_card_count, count, open_archive, tmp_archive,
|
||||
};
|
||||
|
||||
use rusqlite::Connection;
|
||||
use serde_json::Value;
|
||||
use vandelay::error::Error;
|
||||
use vandelay::sync::import_dav;
|
||||
use vandelay::sync::import_dav::{DavAuth, DavImportConfig, DavKindArg};
|
||||
|
||||
fn dav_config(kind: DavKindArg, account: &Account, dav_root: &str) -> DavImportConfig {
|
||||
let path = match kind {
|
||||
DavKindArg::Caldav => format!("{dav_root}/calendars/{}/", account.username),
|
||||
DavKindArg::Carddav => format!("{dav_root}/addressbooks/{}/", account.username),
|
||||
DavKindArg::Webdav => format!("{dav_root}/"),
|
||||
};
|
||||
DavImportConfig {
|
||||
kind,
|
||||
url: path,
|
||||
auth: DavAuth::Basic {
|
||||
user: account.username.clone(),
|
||||
password: account.password.clone(),
|
||||
},
|
||||
allow_cleartext: true,
|
||||
dav_connections: 2,
|
||||
multiget_batch: 25,
|
||||
allow_source_change: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn dav_config_via_discovery(
|
||||
kind: DavKindArg,
|
||||
account: &Account,
|
||||
dav_root: &str,
|
||||
) -> DavImportConfig {
|
||||
DavImportConfig {
|
||||
kind,
|
||||
url: format!("{dav_root}/"),
|
||||
auth: DavAuth::Basic {
|
||||
user: account.username.clone(),
|
||||
password: account.password.clone(),
|
||||
},
|
||||
allow_cleartext: true,
|
||||
dav_connections: 2,
|
||||
multiget_batch: 25,
|
||||
allow_source_change: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn baikal_starts_seeds_and_imports() {
|
||||
let b = Baikal::start().expect("baikal start");
|
||||
let per_account = b.seed_all().expect("baikal seed");
|
||||
assert_eq!(
|
||||
per_account.len(),
|
||||
b.accounts.len(),
|
||||
"seed should return stats for every account"
|
||||
);
|
||||
b.verify_seed(&per_account).expect("baikal verify");
|
||||
|
||||
let dav_root = b.dav_root();
|
||||
|
||||
for seed in &per_account {
|
||||
let account = b
|
||||
.accounts
|
||||
.iter()
|
||||
.find(|a| a.username == seed.username)
|
||||
.expect("account lookup");
|
||||
assert!(
|
||||
!seed.calendars.is_empty(),
|
||||
"{}: expected calendars seeded",
|
||||
seed.username
|
||||
);
|
||||
assert!(
|
||||
!seed.address_books.is_empty(),
|
||||
"{}: expected address books seeded",
|
||||
seed.username
|
||||
);
|
||||
assert!(
|
||||
seed.total_events() > 0,
|
||||
"{}: expected events seeded",
|
||||
seed.username
|
||||
);
|
||||
assert!(
|
||||
seed.total_contacts() > 0,
|
||||
"{}: expected contacts seeded",
|
||||
seed.username
|
||||
);
|
||||
|
||||
let cal_archive = tmp_archive(&format!("baikal-cal-{}", seed.username));
|
||||
let summary = import_dav::run(
|
||||
common(&cal_archive),
|
||||
dav_config(DavKindArg::Caldav, account, &dav_root),
|
||||
)
|
||||
.expect("caldav import");
|
||||
assert!(
|
||||
!summary.any_failed(),
|
||||
"{}: first caldav import had failures: {summary:?}",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&cal_archive);
|
||||
assert_event_collection_exact(&conn, seed);
|
||||
assert_event_uids_round_trip(&conn, seed);
|
||||
assert_event_data_well_formed(&conn);
|
||||
for plan in &seed.calendars {
|
||||
for item in &plan.items {
|
||||
assert_event_round_trip(
|
||||
&conn,
|
||||
&item.source,
|
||||
&item.uid,
|
||||
&format!("{}/{}", seed.username, plan.display_name),
|
||||
);
|
||||
}
|
||||
}
|
||||
let cal_count = count(&conn, "calendars") as usize;
|
||||
let ev_count = count(&conn, "calendar_events") as usize;
|
||||
let blob_count = count(&conn, "blobs") as usize;
|
||||
drop(conn);
|
||||
|
||||
let summary2 = import_dav::run(
|
||||
common(&cal_archive),
|
||||
dav_config(DavKindArg::Caldav, account, &dav_root),
|
||||
)
|
||||
.expect("idempotent caldav re-import");
|
||||
assert!(
|
||||
!summary2.any_failed(),
|
||||
"{}: idempotent caldav re-import had failures",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&cal_archive);
|
||||
assert_eq!(
|
||||
count(&conn, "calendars") as usize,
|
||||
cal_count,
|
||||
"{}: idempotent re-import changed calendar count",
|
||||
seed.username
|
||||
);
|
||||
assert_eq!(
|
||||
count(&conn, "calendar_events") as usize,
|
||||
ev_count,
|
||||
"{}: idempotent re-import changed event count",
|
||||
seed.username
|
||||
);
|
||||
assert_eq!(
|
||||
count(&conn, "blobs") as usize,
|
||||
blob_count,
|
||||
"{}: idempotent re-import changed blob count",
|
||||
seed.username
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
let card_archive = tmp_archive(&format!("baikal-card-{}", seed.username));
|
||||
let summary = import_dav::run(
|
||||
common(&card_archive),
|
||||
dav_config(DavKindArg::Carddav, account, &dav_root),
|
||||
)
|
||||
.expect("carddav import");
|
||||
assert!(
|
||||
!summary.any_failed(),
|
||||
"{}: first carddav import had failures: {summary:?}",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&card_archive);
|
||||
assert_contact_collection_exact(&conn, seed);
|
||||
assert_contact_uids_round_trip(&conn, seed);
|
||||
assert_contact_data_well_formed(&conn);
|
||||
for plan in &seed.address_books {
|
||||
for item in &plan.items {
|
||||
assert_contact_round_trip(
|
||||
&conn,
|
||||
&item.source,
|
||||
&item.uid,
|
||||
&format!("{}/{}", seed.username, plan.display_name),
|
||||
);
|
||||
}
|
||||
}
|
||||
let book_count = count(&conn, "address_books") as usize;
|
||||
let card_count = count(&conn, "contact_cards") as usize;
|
||||
drop(conn);
|
||||
|
||||
let summary2 = import_dav::run(
|
||||
common(&card_archive),
|
||||
dav_config(DavKindArg::Carddav, account, &dav_root),
|
||||
)
|
||||
.expect("idempotent carddav re-import");
|
||||
assert!(
|
||||
!summary2.any_failed(),
|
||||
"{}: idempotent carddav re-import had failures",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&card_archive);
|
||||
assert_eq!(
|
||||
count(&conn, "address_books") as usize,
|
||||
book_count,
|
||||
"{}: idempotent re-import changed address book count",
|
||||
seed.username
|
||||
);
|
||||
assert_eq!(
|
||||
count(&conn, "contact_cards") as usize,
|
||||
card_count,
|
||||
"{}: idempotent re-import changed contact count",
|
||||
seed.username
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
let vanish_href = seed.calendars[0]
|
||||
.items
|
||||
.first()
|
||||
.map(|i| i.href.clone())
|
||||
.expect("at least one event seeded");
|
||||
let vanish_uid = seed.calendars[0].items.first().unwrap().uid.clone();
|
||||
b.delete_item(account, &vanish_href)
|
||||
.expect("baikal delete vanished item");
|
||||
let summary_after_vanish = import_dav::run(
|
||||
common(&cal_archive),
|
||||
dav_config(DavKindArg::Caldav, account, &dav_root),
|
||||
)
|
||||
.expect("caldav re-import after vanish");
|
||||
assert!(
|
||||
!summary_after_vanish.any_failed(),
|
||||
"{}: re-import after vanish had failures",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&cal_archive);
|
||||
assert_eq!(
|
||||
count(&conn, "calendar_events") as usize,
|
||||
ev_count - 1,
|
||||
"{}: vanished event was not pruned",
|
||||
seed.username
|
||||
);
|
||||
let uids_now = all_event_uids(&conn);
|
||||
assert!(
|
||||
!uids_now.contains(&vanish_uid),
|
||||
"{}: vanished event uid {vanish_uid} still in archive",
|
||||
seed.username
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
let icals = integration::data::load_icals().expect("load icals");
|
||||
let (_added_event_href, added_event_uid) = b
|
||||
.add_event(account, 0, &format!("bk-{}", seed.username), &icals)
|
||||
.expect("baikal add event");
|
||||
let summary_after_add_event = import_dav::run(
|
||||
common(&cal_archive),
|
||||
dav_config(DavKindArg::Caldav, account, &dav_root),
|
||||
)
|
||||
.expect("caldav re-import after add");
|
||||
assert!(
|
||||
!summary_after_add_event.any_failed(),
|
||||
"{}: re-import after add-event had failures",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&cal_archive);
|
||||
let after_add_events = count(&conn, "calendar_events") as usize;
|
||||
assert_eq!(
|
||||
after_add_events, ev_count,
|
||||
"{}: add-then-import should restore count to baseline",
|
||||
seed.username
|
||||
);
|
||||
let uids_now = all_event_uids(&conn);
|
||||
assert!(
|
||||
uids_now.contains(&added_event_uid),
|
||||
"{}: added event uid {added_event_uid} not found in archive",
|
||||
seed.username
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
let vcards = integration::data::load_vcards().expect("load vcards");
|
||||
let (_added_card_href, added_card_uid) = b
|
||||
.add_contact(account, 0, &format!("bk-{}", seed.username), &vcards)
|
||||
.expect("baikal add contact");
|
||||
let summary_after_add_contact = import_dav::run(
|
||||
common(&card_archive),
|
||||
dav_config(DavKindArg::Carddav, account, &dav_root),
|
||||
)
|
||||
.expect("carddav re-import after add");
|
||||
assert!(
|
||||
!summary_after_add_contact.any_failed(),
|
||||
"{}: re-import after add-contact had failures",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&card_archive);
|
||||
let after_add_contacts = count(&conn, "contact_cards") as usize;
|
||||
assert_eq!(
|
||||
after_add_contacts,
|
||||
card_count + 1,
|
||||
"{}: add-then-import should grow contact count by 1",
|
||||
seed.username
|
||||
);
|
||||
let imported_contact_uids = all_contact_uids(&conn);
|
||||
assert!(
|
||||
imported_contact_uids.contains(&added_card_uid),
|
||||
"{}: added contact uid {added_card_uid} not found",
|
||||
seed.username
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
let discovery_archive = tmp_archive(&format!("baikal-cal-discovery-{}", seed.username));
|
||||
let discovery_summary = import_dav::run(
|
||||
common(&discovery_archive),
|
||||
dav_config_via_discovery(DavKindArg::Caldav, account, &dav_root),
|
||||
)
|
||||
.expect("caldav import via discovery from dav root");
|
||||
assert!(
|
||||
!discovery_summary.any_failed(),
|
||||
"{}: caldav import via discovery had failures: {discovery_summary:?}",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&discovery_archive);
|
||||
let discovered_events = count(&conn, "calendar_events") as usize;
|
||||
assert_eq!(
|
||||
discovered_events, ev_count,
|
||||
"{}: discovery-based import event count mismatch (server holds {ev_count} after vanish+add)",
|
||||
seed.username
|
||||
);
|
||||
drop(conn);
|
||||
cleanup(&discovery_archive);
|
||||
|
||||
cleanup(&cal_archive);
|
||||
cleanup(&card_archive);
|
||||
}
|
||||
|
||||
let primary = &b.accounts[0];
|
||||
let other = &b.accounts[1];
|
||||
let shared_archive = tmp_archive("baikal-source-change");
|
||||
import_dav::run(
|
||||
common(&shared_archive),
|
||||
dav_config(DavKindArg::Caldav, primary, &dav_root),
|
||||
)
|
||||
.expect("seed archive with primary user");
|
||||
let err = import_dav::run(
|
||||
common(&shared_archive),
|
||||
dav_config(DavKindArg::Caldav, other, &dav_root),
|
||||
)
|
||||
.expect_err("expected source-change abort");
|
||||
assert!(
|
||||
matches!(err, Error::SourceChange(_)),
|
||||
"expected SourceChange, got {err:?}"
|
||||
);
|
||||
cleanup(&shared_archive);
|
||||
|
||||
b.stop().expect("baikal stop");
|
||||
}
|
||||
|
||||
fn assert_event_collection_exact(conn: &Connection, seed: &AccountSeed) {
|
||||
let names = collection_names(conn, "calendars");
|
||||
let expected: HashSet<String> = seed
|
||||
.calendars
|
||||
.iter()
|
||||
.map(|c| c.display_name.clone())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names, expected,
|
||||
"{}: calendar displayname set mismatch",
|
||||
seed.username
|
||||
);
|
||||
for plan in &seed.calendars {
|
||||
let cal_id = collection_id_by_name(conn, "calendars", &plan.display_name)
|
||||
.unwrap_or_else(|| panic!("calendar {} missing", plan.display_name));
|
||||
let got = calendar_event_count(conn, cal_id);
|
||||
assert_eq!(
|
||||
got as usize,
|
||||
plan.items.len(),
|
||||
"{}: calendar {} event count {got} != seeded {}",
|
||||
seed.username,
|
||||
plan.display_name,
|
||||
plan.items.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_contact_collection_exact(conn: &Connection, seed: &AccountSeed) {
|
||||
let names = collection_names(conn, "address_books");
|
||||
let expected: HashSet<String> = seed
|
||||
.address_books
|
||||
.iter()
|
||||
.map(|c| c.display_name.clone())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names, expected,
|
||||
"{}: address book displayname set mismatch",
|
||||
seed.username
|
||||
);
|
||||
for plan in &seed.address_books {
|
||||
let book_id = collection_id_by_name(conn, "address_books", &plan.display_name)
|
||||
.unwrap_or_else(|| panic!("address book {} missing", plan.display_name));
|
||||
let got = contact_card_count(conn, book_id);
|
||||
assert_eq!(
|
||||
got as usize,
|
||||
plan.items.len(),
|
||||
"{}: book {} contact count {got} != seeded {}",
|
||||
seed.username,
|
||||
plan.display_name,
|
||||
plan.items.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_event_uids_round_trip(conn: &Connection, seed: &AccountSeed) {
|
||||
let imported = all_event_uids(conn);
|
||||
let expected = seed.event_uids();
|
||||
let missing: Vec<_> = expected.difference(&imported).collect();
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"{}: event uids missing from archive: {missing:?}",
|
||||
seed.username
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_contact_uids_round_trip(conn: &Connection, seed: &AccountSeed) {
|
||||
let imported = all_contact_uids(conn);
|
||||
let expected = seed.contact_uids();
|
||||
let missing: Vec<_> = expected.difference(&imported).collect();
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"{}: contact uids missing from archive: {missing:?}",
|
||||
seed.username
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_event_data_well_formed(conn: &Connection) {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT data, data_type FROM calendar_events")
|
||||
.expect("prepare");
|
||||
let rows: Vec<(String, String)> = stmt
|
||||
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
|
||||
.expect("query")
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert!(!rows.is_empty(), "no calendar_events rows");
|
||||
for (raw, data_type) in rows {
|
||||
assert_eq!(
|
||||
data_type, "Event",
|
||||
"all VEVENT seeds must land as Event, got data_type={data_type}, raw={raw}"
|
||||
);
|
||||
let v: Value = serde_json::from_str(&raw)
|
||||
.unwrap_or_else(|e| panic!("calendar_events.data invalid JSON: {e}; raw={raw}"));
|
||||
let obj = v.as_object().expect("event data is object");
|
||||
let kind = obj
|
||||
.get("@type")
|
||||
.or_else(|| obj.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
assert_eq!(kind, "Event", "JSCalendar @type must be Event, got {kind}");
|
||||
assert!(obj.contains_key("uid"), "event JSON missing uid: {obj:?}");
|
||||
assert!(
|
||||
obj.contains_key("start"),
|
||||
"event JSON missing start: {obj:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_contact_data_well_formed(conn: &Connection) {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT uid, data FROM contact_cards")
|
||||
.expect("prepare");
|
||||
let rows: Vec<(String, String)> = stmt
|
||||
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
|
||||
.expect("query")
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert!(!rows.is_empty(), "no contact_cards rows");
|
||||
for (uid, raw) in rows {
|
||||
assert!(!uid.is_empty(), "contact uid empty");
|
||||
let v: Value = serde_json::from_str(&raw)
|
||||
.unwrap_or_else(|e| panic!("contact_cards.data invalid JSON: {e}; raw={raw}"));
|
||||
let obj = v.as_object().expect("contact data is object");
|
||||
let kind = obj
|
||||
.get("@type")
|
||||
.or_else(|| obj.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
assert_eq!(kind, "Card", "JSContact @type must be Card, got {kind}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
mod integration;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use integration::cyrus::{AccountSeed, Cyrus, flag_probe_message};
|
||||
use integration::validate::{
|
||||
assert_message_round_trip, blob_bytes, blob_id_for_hash, cleanup, common, count,
|
||||
email_rows_for_blob, emails_in_mailbox, keywords_for_blob, mailbox_id_by_name,
|
||||
mailbox_id_by_path, mailbox_path, open_archive, tmp_archive,
|
||||
};
|
||||
use integration::{Account, Endpoint};
|
||||
|
||||
use vandelay::error::Error;
|
||||
use vandelay::sync::import_imap;
|
||||
use vandelay::sync::import_imap::{ImapAuth, ImapImportConfig};
|
||||
|
||||
fn imap_config(account: &Account, imap: &Endpoint) -> ImapImportConfig {
|
||||
ImapImportConfig {
|
||||
url: format!("imap://{}:{}", imap.host, imap.port),
|
||||
auth: ImapAuth::Basic {
|
||||
user: account.username.clone(),
|
||||
password: account.password.clone(),
|
||||
},
|
||||
allow_cleartext: true,
|
||||
compress: false,
|
||||
include: Vec::new(),
|
||||
exclude: Vec::new(),
|
||||
exclude_special: Vec::new(),
|
||||
folder: Vec::new(),
|
||||
subscribed_only: false,
|
||||
automap: true,
|
||||
include_deleted: false,
|
||||
fetch_batch: 64,
|
||||
imap_connections: 2,
|
||||
allow_source_change: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn cyrus_starts_seeds_and_imports() {
|
||||
let c = Cyrus::start().expect("cyrus start");
|
||||
let seeds = c.seed_all().expect("cyrus seed");
|
||||
c.verify_seed(&seeds).expect("cyrus verify");
|
||||
|
||||
for seed in &seeds {
|
||||
let account = c
|
||||
.accounts
|
||||
.iter()
|
||||
.find(|a| a.username == seed.username)
|
||||
.expect("account");
|
||||
assert!(
|
||||
!seed.paths.is_empty(),
|
||||
"{}: expected mailboxes seeded",
|
||||
seed.username
|
||||
);
|
||||
assert!(
|
||||
seed.total_appends > 0,
|
||||
"{}: expected emails appended",
|
||||
seed.username
|
||||
);
|
||||
|
||||
let archive = tmp_archive(&format!("cyrus-{}", seed.username));
|
||||
let summary =
|
||||
import_imap::run(common(&archive), imap_config(account, &c.imap)).expect("imap import");
|
||||
assert!(
|
||||
!summary.any_failed(),
|
||||
"cyrus import failed for {}: {summary:?}",
|
||||
seed.username
|
||||
);
|
||||
|
||||
let conn = open_archive(&archive);
|
||||
let mailbox_count = count(&conn, "mailboxes") as usize;
|
||||
let email_count = count(&conn, "emails") as usize;
|
||||
let blob_count = count(&conn, "blobs") as usize;
|
||||
let expected_mailboxes = account.layout.mailboxes.len() + 1;
|
||||
assert_eq!(
|
||||
mailbox_count, expected_mailboxes,
|
||||
"{}: imported {mailbox_count} mailboxes, expected exactly {expected_mailboxes}",
|
||||
seed.username
|
||||
);
|
||||
assert_eq!(
|
||||
email_count, seed.total_appends,
|
||||
"{}: every seeded email must land in archive (appended {}, imported {email_count})",
|
||||
seed.username, seed.total_appends
|
||||
);
|
||||
assert!(blob_count > 0, "{}: blobs interned", seed.username);
|
||||
|
||||
let bad_match: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM emails WHERE message_match IS NULL OR message_match = ''",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
bad_match, 0,
|
||||
"{}: every imported email must have a non-empty message_match",
|
||||
seed.username
|
||||
);
|
||||
|
||||
if let Some(target) = &seed.flagged_target {
|
||||
let probe = flag_probe_message();
|
||||
let probe_hash = blake3::hash(&probe);
|
||||
let blob_id = blob_id_for_hash(&conn, probe_hash.as_bytes()).expect("flag probe blob");
|
||||
let stored = blob_bytes(&conn, blob_id);
|
||||
assert_eq!(
|
||||
stored, probe,
|
||||
"{}: flag probe blob bytes mismatch",
|
||||
seed.username
|
||||
);
|
||||
let keywords = keywords_for_blob(&conn, blob_id);
|
||||
let kw_set: HashSet<_> = keywords.iter().map(String::as_str).collect();
|
||||
assert!(
|
||||
kw_set.contains("$seen"),
|
||||
"{}: flag probe missing $seen in keywords {keywords:?}",
|
||||
seed.username
|
||||
);
|
||||
assert!(
|
||||
kw_set.contains("$flagged"),
|
||||
"{}: flag probe missing $flagged in keywords {keywords:?}",
|
||||
seed.username
|
||||
);
|
||||
let _ = target;
|
||||
}
|
||||
|
||||
if let Some(_target) = &seed.dedup_target
|
||||
&& let Some(first) = seeds
|
||||
.iter()
|
||||
.find(|s| s.username == seed.username)
|
||||
.and_then(|_| account.layout.mailboxes.first())
|
||||
{
|
||||
let _ = first;
|
||||
let total_dup: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM emails e
|
||||
WHERE EXISTS (SELECT 1 FROM emails e2
|
||||
WHERE e2.blob_id = e.blob_id AND e2.id <> e.id)",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
total_dup >= 2,
|
||||
"{}: dedup append should produce at least two rows sharing a blob",
|
||||
seed.username
|
||||
);
|
||||
}
|
||||
|
||||
for path in &seed.paths {
|
||||
let id = mailbox_id_by_path(&conn, path, '/').unwrap_or_else(|| {
|
||||
panic!("{}: mailbox path {path} missing in archive", seed.username)
|
||||
});
|
||||
assert_eq!(mailbox_path(&conn, id, '/'), *path);
|
||||
let expected = seed.histogram.get(path).copied().unwrap_or(0);
|
||||
let got = emails_in_mailbox(&conn, id) as usize;
|
||||
assert_eq!(
|
||||
got, expected,
|
||||
"{}: mailbox {path} email count {got} != appended {expected}",
|
||||
seed.username
|
||||
);
|
||||
}
|
||||
for append in &seed.appends {
|
||||
assert_message_round_trip(
|
||||
&conn,
|
||||
&append.raw,
|
||||
&append.target,
|
||||
&format!("{}/append/{:?}", seed.username, append.tag),
|
||||
);
|
||||
}
|
||||
let inbox_role: Option<String> = conn
|
||||
.query_row("SELECT role FROM mailboxes WHERE name = 'INBOX'", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
inbox_role,
|
||||
Some("inbox".to_owned()),
|
||||
"{}: INBOX role tagged",
|
||||
seed.username
|
||||
);
|
||||
let inbox_id = mailbox_id_by_name(&conn, "INBOX").expect("INBOX");
|
||||
let inbox_seeded = seed.histogram.get("INBOX").copied().unwrap_or(0);
|
||||
assert_eq!(
|
||||
emails_in_mailbox(&conn, inbox_id) as usize,
|
||||
inbox_seeded,
|
||||
"{}: INBOX email count mismatch",
|
||||
seed.username
|
||||
);
|
||||
let probe_blob = blob_id_for_hash(&conn, blake3::hash(&flag_probe_message()).as_bytes());
|
||||
if let Some(blob_id) = probe_blob {
|
||||
let rows = email_rows_for_blob(&conn, blob_id);
|
||||
assert!(rows >= 1, "{}: flag probe missing", seed.username);
|
||||
}
|
||||
|
||||
drop(conn);
|
||||
|
||||
let summary2 = import_imap::run(common(&archive), imap_config(account, &c.imap))
|
||||
.expect("idempotent imap re-import");
|
||||
assert!(
|
||||
!summary2.any_failed(),
|
||||
"{}: idempotent re-import had failures: {summary2:?}",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&archive);
|
||||
let email_count2 = count(&conn, "emails") as usize;
|
||||
let blob_count2 = count(&conn, "blobs") as usize;
|
||||
assert_eq!(
|
||||
email_count2, email_count,
|
||||
"{}: idempotent re-import added emails",
|
||||
seed.username
|
||||
);
|
||||
assert_eq!(
|
||||
blob_count2, blob_count,
|
||||
"{}: idempotent re-import added blobs",
|
||||
seed.username
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
c.delete_first_inbox_message(account)
|
||||
.expect("expunge first INBOX message");
|
||||
let summary_after = import_imap::run(common(&archive), imap_config(account, &c.imap))
|
||||
.expect("re-import after expunge");
|
||||
assert!(
|
||||
!summary_after.any_failed(),
|
||||
"{}: re-import after expunge had failures",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&archive);
|
||||
let email_count3 = count(&conn, "emails") as usize;
|
||||
assert_eq!(
|
||||
email_count3,
|
||||
email_count - 1,
|
||||
"{}: expunged message was not pruned",
|
||||
seed.username
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
let (added_raw, added_mid) = c
|
||||
.append_new_message(account, "INBOX", "post-import")
|
||||
.expect("append new INBOX message");
|
||||
let summary_after_append =
|
||||
import_imap::run(common(&archive), imap_config(account, &c.imap))
|
||||
.expect("re-import after append");
|
||||
assert!(
|
||||
!summary_after_append.any_failed(),
|
||||
"{}: re-import after append had failures",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&archive);
|
||||
let email_count_after_append = count(&conn, "emails") as usize;
|
||||
assert_eq!(
|
||||
email_count_after_append, email_count,
|
||||
"{}: append-after-expunge should restore email count to baseline",
|
||||
seed.username
|
||||
);
|
||||
let added_hash = blake3::hash(&added_raw);
|
||||
let added_blob =
|
||||
blob_id_for_hash(&conn, added_hash.as_bytes()).expect("added message blob present");
|
||||
let mid_normalised = added_mid
|
||||
.trim_matches(|c| c == '<' || c == '>')
|
||||
.to_ascii_lowercase();
|
||||
let mid_found: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM emails
|
||||
WHERE EXISTS (SELECT 1 FROM json_each(json_extract(message_match,'$.m')) j
|
||||
WHERE j.value = ?1)",
|
||||
[mid_normalised.as_str()],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
mid_found >= 1,
|
||||
"{}: added Message-ID {added_mid} not found in message_match",
|
||||
seed.username
|
||||
);
|
||||
let added_inbox_id = mailbox_id_by_name(&conn, "INBOX").expect("INBOX id");
|
||||
let added_in_inbox: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS (SELECT 1 FROM emails
|
||||
WHERE blob_id = ?1
|
||||
AND EXISTS (SELECT 1 FROM json_each(mailbox_ids) j
|
||||
WHERE j.value = ?2))",
|
||||
rusqlite::params![added_blob, added_inbox_id],
|
||||
|r| r.get::<_, i64>(0).map(|n| n != 0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
added_in_inbox,
|
||||
"{}: added message blob not linked to INBOX",
|
||||
seed.username
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
cleanup(&archive);
|
||||
}
|
||||
|
||||
let primary = &c.accounts[0];
|
||||
let other = &c.accounts[1];
|
||||
let shared_archive = tmp_archive("cyrus-source-change");
|
||||
import_imap::run(common(&shared_archive), imap_config(primary, &c.imap))
|
||||
.expect("seed archive with primary user");
|
||||
let err = import_imap::run(common(&shared_archive), imap_config(other, &c.imap))
|
||||
.expect_err("expected source-change abort");
|
||||
assert!(
|
||||
matches!(err, Error::SourceChange(_)),
|
||||
"expected SourceChange, got {err:?}"
|
||||
);
|
||||
cleanup(&shared_archive);
|
||||
|
||||
c.stop().expect("cyrus stop");
|
||||
}
|
||||
|
||||
fn _unused(_: &AccountSeed) {}
|
||||
@@ -0,0 +1,516 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
mod integration;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use integration::Account;
|
||||
use integration::dovecot::{AccountSeed, Dovecot, flag_probe_message, shared_message_id_probe};
|
||||
use integration::validate::{
|
||||
assert_message_round_trip, blob_bytes, blob_id_for_hash, cleanup, common, count,
|
||||
email_rows_for_blob, emails_in_mailbox, keywords_for_blob, mailbox_id_by_name,
|
||||
mailbox_id_by_path, mailbox_path, open_archive, tmp_archive,
|
||||
};
|
||||
|
||||
use rusqlite::Connection;
|
||||
use vandelay::error::Error;
|
||||
use vandelay::sync::import_imap::{ImapAuth, ImapImportConfig};
|
||||
use vandelay::sync::import_managesieve::{ManageSieveAuth, ManageSieveImportConfig};
|
||||
use vandelay::sync::{import_imap, import_managesieve};
|
||||
|
||||
fn imap_config(account: &Account, imap: &integration::Endpoint) -> ImapImportConfig {
|
||||
ImapImportConfig {
|
||||
url: format!("imap://{}:{}", imap.host, imap.port),
|
||||
auth: ImapAuth::Basic {
|
||||
user: account.username.clone(),
|
||||
password: account.password.clone(),
|
||||
},
|
||||
allow_cleartext: false,
|
||||
compress: false,
|
||||
include: Vec::new(),
|
||||
exclude: Vec::new(),
|
||||
exclude_special: Vec::new(),
|
||||
folder: Vec::new(),
|
||||
subscribed_only: false,
|
||||
automap: true,
|
||||
include_deleted: false,
|
||||
fetch_batch: 64,
|
||||
imap_connections: 2,
|
||||
allow_source_change: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn sieve_config(account: &Account, sieve: &integration::Endpoint) -> ManageSieveImportConfig {
|
||||
ManageSieveImportConfig {
|
||||
url: format!("sieve://{}:{}", sieve.host, sieve.port),
|
||||
auth: ManageSieveAuth::Basic {
|
||||
user: account.username.clone(),
|
||||
password: account.password.clone(),
|
||||
},
|
||||
allow_cleartext: true,
|
||||
allow_source_change: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn dovecot_starts_seeds_and_imports() {
|
||||
let d = Dovecot::start().expect("dovecot start");
|
||||
let seeds = d.seed_all().expect("dovecot seed");
|
||||
assert_eq!(
|
||||
seeds.len(),
|
||||
d.accounts.len(),
|
||||
"seed should return stats for every account"
|
||||
);
|
||||
d.verify_seed(&seeds).expect("dovecot verify");
|
||||
|
||||
for seed in &seeds {
|
||||
let account = d
|
||||
.accounts
|
||||
.iter()
|
||||
.find(|a| a.username == seed.username)
|
||||
.expect("account");
|
||||
assert!(
|
||||
!seed.mailbox.paths.is_empty(),
|
||||
"{}: expected mailboxes seeded",
|
||||
seed.username
|
||||
);
|
||||
assert!(
|
||||
seed.mailbox.total_appends > 0,
|
||||
"{}: expected emails appended",
|
||||
seed.username
|
||||
);
|
||||
|
||||
let archive = tmp_archive(&format!("dovecot-{}", seed.username));
|
||||
let summary =
|
||||
import_imap::run(common(&archive), imap_config(account, &d.imap)).expect("imap import");
|
||||
assert!(
|
||||
!summary.any_failed(),
|
||||
"imap import had failures for {}: {summary:?}",
|
||||
seed.username
|
||||
);
|
||||
|
||||
let conn = open_archive(&archive);
|
||||
let mailbox_count = count(&conn, "mailboxes") as usize;
|
||||
let email_count = count(&conn, "emails") as usize;
|
||||
let blob_count = count(&conn, "blobs") as usize;
|
||||
let expected_mailboxes = account.layout.mailboxes.len() + 1;
|
||||
assert_eq!(
|
||||
mailbox_count, expected_mailboxes,
|
||||
"{}: imported {mailbox_count} mailboxes, expected exactly {expected_mailboxes}",
|
||||
seed.username
|
||||
);
|
||||
assert_eq!(
|
||||
email_count, seed.mailbox.total_appends,
|
||||
"{}: every seeded email must land in archive (appended {}, imported {email_count})",
|
||||
seed.username, seed.mailbox.total_appends
|
||||
);
|
||||
assert!(blob_count > 0, "{}: blobs interned", seed.username);
|
||||
|
||||
let bad_match: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM emails WHERE message_match IS NULL OR message_match = ''",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
bad_match, 0,
|
||||
"{}: every imported email must have a non-empty message_match",
|
||||
seed.username
|
||||
);
|
||||
|
||||
let nomid_target = seed
|
||||
.mailbox
|
||||
.extras
|
||||
.nomid_target
|
||||
.clone()
|
||||
.expect("nomid target");
|
||||
let nomid_hash = blake3::hash(&integration::dovecot::no_message_id_probe())
|
||||
.as_bytes()
|
||||
.to_vec();
|
||||
let nomid_matches: Vec<String> = {
|
||||
let mut nomid_stmt = conn
|
||||
.prepare(
|
||||
"SELECT message_match FROM emails
|
||||
WHERE blob_id = (SELECT id FROM blobs WHERE hash = ?1)",
|
||||
)
|
||||
.expect("prepare nomid");
|
||||
nomid_stmt
|
||||
.query_map([nomid_hash.as_slice()], |r| r.get::<_, String>(0))
|
||||
.expect("query nomid")
|
||||
.filter_map(|r| r.ok())
|
||||
.collect()
|
||||
};
|
||||
assert!(
|
||||
!nomid_matches.is_empty(),
|
||||
"{}: no-Message-ID probe missing in archive (target {nomid_target})",
|
||||
seed.username
|
||||
);
|
||||
for m in &nomid_matches {
|
||||
let parsed: serde_json::Value = serde_json::from_str(m).expect("message_match json");
|
||||
let mids = parsed
|
||||
.get("m")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
mids.is_empty(),
|
||||
"{}: no-Message-ID probe should have empty Message-ID set, got {m}",
|
||||
seed.username
|
||||
);
|
||||
let fb = parsed.get("f").and_then(|v| v.as_str()).unwrap_or("");
|
||||
assert_eq!(
|
||||
fb.len(),
|
||||
64,
|
||||
"{}: no-Message-ID probe must carry a BLAKE3 fallback hex, got {m}",
|
||||
seed.username
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(target) = &seed.mailbox.extras.flagged_target {
|
||||
let probe = flag_probe_message();
|
||||
let probe_hash = blake3::hash(&probe);
|
||||
let blob_id = blob_id_for_hash(&conn, probe_hash.as_bytes()).expect("flag probe blob");
|
||||
let stored = blob_bytes(&conn, blob_id);
|
||||
assert_eq!(
|
||||
stored, probe,
|
||||
"{}: flag probe blob bytes mismatch",
|
||||
seed.username
|
||||
);
|
||||
let keywords = keywords_for_blob(&conn, blob_id);
|
||||
let kw_set: HashSet<_> = keywords.iter().map(String::as_str).collect();
|
||||
assert!(
|
||||
kw_set.contains("$seen"),
|
||||
"{}: flag probe missing $seen in keywords {keywords:?}",
|
||||
seed.username
|
||||
);
|
||||
assert!(
|
||||
kw_set.contains("$flagged"),
|
||||
"{}: flag probe missing $flagged in keywords {keywords:?}",
|
||||
seed.username
|
||||
);
|
||||
let _ = target;
|
||||
}
|
||||
|
||||
if let Some((a, b)) = &seed.mailbox.extras.mid_dedup_targets {
|
||||
let body = shared_message_id_probe();
|
||||
let hash = blake3::hash(&body);
|
||||
let blob_id = blob_id_for_hash(&conn, hash.as_bytes()).expect("shared-mid probe blob");
|
||||
let rows_for_blob = email_rows_for_blob(&conn, blob_id);
|
||||
assert_eq!(
|
||||
rows_for_blob, 2,
|
||||
"{}: shared-MID probe should produce 2 email rows for the dedup blob, got {rows_for_blob}",
|
||||
seed.username
|
||||
);
|
||||
let matches: Vec<String> = {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT message_match FROM emails WHERE blob_id = ?1")
|
||||
.expect("prepare matches");
|
||||
stmt.query_map([blob_id], |r| r.get::<_, String>(0))
|
||||
.expect("query matches")
|
||||
.filter_map(|r| r.ok())
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(matches.len(), 2, "{}: expected 2 matches", seed.username);
|
||||
assert_eq!(
|
||||
matches[0], matches[1],
|
||||
"{}: shared-MID emails must have identical message_match, got {matches:?}",
|
||||
seed.username
|
||||
);
|
||||
let _ = (a, b);
|
||||
}
|
||||
|
||||
if let Some(target) = &seed.mailbox.extras.dedup_target {
|
||||
let _ = target;
|
||||
}
|
||||
|
||||
for path in &seed.mailbox.paths {
|
||||
let id = mailbox_id_by_path(&conn, path, '/').unwrap_or_else(|| {
|
||||
panic!("{}: mailbox path {path} missing in archive", seed.username)
|
||||
});
|
||||
assert_eq!(mailbox_path(&conn, id, '/'), *path);
|
||||
let expected = seed.mailbox.histogram.get(path).copied().unwrap_or(0);
|
||||
let got = emails_in_mailbox(&conn, id) as usize;
|
||||
assert_eq!(
|
||||
got, expected,
|
||||
"{}: mailbox {path} email count {got} != appended {expected}",
|
||||
seed.username
|
||||
);
|
||||
}
|
||||
for append in &seed.mailbox.appends {
|
||||
assert_message_round_trip(
|
||||
&conn,
|
||||
&append.raw,
|
||||
&append.target,
|
||||
&format!("{}/append/{:?}", seed.username, append.tag),
|
||||
);
|
||||
}
|
||||
let inbox_role: Option<String> = conn
|
||||
.query_row("SELECT role FROM mailboxes WHERE name = 'INBOX'", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
inbox_role,
|
||||
Some("inbox".to_owned()),
|
||||
"{}: INBOX role tagged",
|
||||
seed.username
|
||||
);
|
||||
let inbox_id = mailbox_id_by_name(&conn, "INBOX").expect("INBOX");
|
||||
let inbox_seeded = seed.mailbox.histogram.get("INBOX").copied().unwrap_or(0);
|
||||
assert_eq!(
|
||||
emails_in_mailbox(&conn, inbox_id) as usize,
|
||||
inbox_seeded,
|
||||
"{}: INBOX email count mismatch",
|
||||
seed.username
|
||||
);
|
||||
assert_received_at_rfc3339_ish(&conn);
|
||||
|
||||
drop(conn);
|
||||
|
||||
let summary2 = import_imap::run(common(&archive), imap_config(account, &d.imap))
|
||||
.expect("idempotent imap re-import");
|
||||
assert!(
|
||||
!summary2.any_failed(),
|
||||
"{}: idempotent re-import had failures: {summary2:?}",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&archive);
|
||||
let email_count2 = count(&conn, "emails") as usize;
|
||||
let mailbox_count2 = count(&conn, "mailboxes") as usize;
|
||||
let blob_count2 = count(&conn, "blobs") as usize;
|
||||
assert_eq!(
|
||||
email_count2, email_count,
|
||||
"{}: idempotent re-import added emails",
|
||||
seed.username
|
||||
);
|
||||
assert_eq!(
|
||||
mailbox_count2, mailbox_count,
|
||||
"{}: idempotent re-import added mailboxes",
|
||||
seed.username
|
||||
);
|
||||
assert_eq!(
|
||||
blob_count2, blob_count,
|
||||
"{}: idempotent re-import added blobs",
|
||||
seed.username
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
d.delete_first_inbox_message(account)
|
||||
.expect("expunge first INBOX message");
|
||||
let summary_after = import_imap::run(common(&archive), imap_config(account, &d.imap))
|
||||
.expect("re-import after expunge");
|
||||
assert!(
|
||||
!summary_after.any_failed(),
|
||||
"{}: re-import after expunge had failures",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&archive);
|
||||
let email_count3 = count(&conn, "emails") as usize;
|
||||
assert_eq!(
|
||||
email_count3,
|
||||
email_count - 1,
|
||||
"{}: expunged message was not pruned",
|
||||
seed.username
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
let (added_raw, added_mid) = d
|
||||
.append_new_message(account, "INBOX", "post-import")
|
||||
.expect("append new INBOX message");
|
||||
let summary_after_append =
|
||||
import_imap::run(common(&archive), imap_config(account, &d.imap))
|
||||
.expect("re-import after append");
|
||||
assert!(
|
||||
!summary_after_append.any_failed(),
|
||||
"{}: re-import after append had failures",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&archive);
|
||||
let email_count_after_append = count(&conn, "emails") as usize;
|
||||
assert_eq!(
|
||||
email_count_after_append, email_count,
|
||||
"{}: append-after-expunge should restore email count to baseline",
|
||||
seed.username
|
||||
);
|
||||
let added_hash = blake3::hash(&added_raw);
|
||||
let added_blob = integration::validate::blob_id_for_hash(&conn, added_hash.as_bytes())
|
||||
.expect("added message blob present");
|
||||
let stored = integration::validate::blob_bytes(&conn, added_blob);
|
||||
assert_eq!(
|
||||
stored, added_raw,
|
||||
"{}: added message blob bytes mismatch",
|
||||
seed.username
|
||||
);
|
||||
let mid_normalised = added_mid
|
||||
.trim_matches(|c| c == '<' || c == '>')
|
||||
.to_ascii_lowercase();
|
||||
let mid_found: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM emails
|
||||
WHERE EXISTS (SELECT 1 FROM json_each(json_extract(message_match,'$.m')) j
|
||||
WHERE j.value = ?1)",
|
||||
[mid_normalised.as_str()],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
mid_found >= 1,
|
||||
"{}: added Message-ID {added_mid} not found in message_match",
|
||||
seed.username
|
||||
);
|
||||
let added_inbox_id = mailbox_id_by_name(&conn, "INBOX").expect("INBOX id");
|
||||
let added_in_inbox: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS (SELECT 1 FROM emails
|
||||
WHERE blob_id = ?1
|
||||
AND EXISTS (SELECT 1 FROM json_each(mailbox_ids) j
|
||||
WHERE j.value = ?2))",
|
||||
rusqlite::params![added_blob, added_inbox_id],
|
||||
|r| r.get::<_, i64>(0).map(|n| n != 0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
added_in_inbox,
|
||||
"{}: added message blob not linked to INBOX",
|
||||
seed.username
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
if !account.layout.sieve_scripts.is_empty() {
|
||||
let sieve_archive = tmp_archive(&format!("dovecot-sieve-{}", seed.username));
|
||||
let summary =
|
||||
import_managesieve::run(common(&sieve_archive), sieve_config(account, &d.sieve))
|
||||
.expect("sieve import");
|
||||
assert!(
|
||||
!summary.any_failed(),
|
||||
"sieve import had failures for {}: {summary:?}",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&sieve_archive);
|
||||
let script_count = count(&conn, "sieve_scripts") as usize;
|
||||
assert_eq!(
|
||||
script_count,
|
||||
account.layout.sieve_scripts.len(),
|
||||
"{}: sieve scripts count mismatch",
|
||||
seed.username
|
||||
);
|
||||
let active_seed = account
|
||||
.layout
|
||||
.sieve_scripts
|
||||
.iter()
|
||||
.filter(|s| s.active)
|
||||
.count();
|
||||
let active_imported: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM sieve_scripts WHERE is_active = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
active_imported as usize, active_seed,
|
||||
"{}: active sieve script count mismatch",
|
||||
seed.username
|
||||
);
|
||||
|
||||
for seeded in account.layout.sieve_scripts {
|
||||
let blob_id: i64 = conn
|
||||
.query_row(
|
||||
"SELECT blob_id FROM sieve_scripts WHERE name = ?1",
|
||||
[seeded.name],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
let body = blob_bytes(&conn, blob_id);
|
||||
assert_eq!(
|
||||
body,
|
||||
seeded.body.as_bytes(),
|
||||
"{}: sieve script {} body round-trip",
|
||||
seed.username,
|
||||
seeded.name
|
||||
);
|
||||
let is_active: i64 = conn
|
||||
.query_row(
|
||||
"SELECT is_active FROM sieve_scripts WHERE name = ?1",
|
||||
[seeded.name],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
is_active == 1,
|
||||
seeded.active,
|
||||
"{}: sieve script {} active flag mismatch",
|
||||
seed.username,
|
||||
seeded.name
|
||||
);
|
||||
}
|
||||
let actives: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM sieve_scripts WHERE is_active = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
actives <= 1,
|
||||
"{}: at most one sieve script may be active",
|
||||
seed.username
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
let summary2 =
|
||||
import_managesieve::run(common(&sieve_archive), sieve_config(account, &d.sieve))
|
||||
.expect("idempotent sieve re-import");
|
||||
assert!(
|
||||
!summary2.any_failed(),
|
||||
"{}: idempotent sieve re-import had failures",
|
||||
seed.username
|
||||
);
|
||||
|
||||
cleanup(&sieve_archive);
|
||||
}
|
||||
|
||||
cleanup(&archive);
|
||||
}
|
||||
|
||||
let primary = &d.accounts[0];
|
||||
let other = &d.accounts[1];
|
||||
let shared_archive = tmp_archive("dovecot-source-change");
|
||||
import_imap::run(common(&shared_archive), imap_config(primary, &d.imap))
|
||||
.expect("seed archive with primary user");
|
||||
let err = import_imap::run(common(&shared_archive), imap_config(other, &d.imap))
|
||||
.expect_err("expected source-change abort");
|
||||
assert!(
|
||||
matches!(err, Error::SourceChange(_)),
|
||||
"expected SourceChange, got {err:?}"
|
||||
);
|
||||
cleanup(&shared_archive);
|
||||
|
||||
d.stop().expect("dovecot stop");
|
||||
}
|
||||
|
||||
fn assert_received_at_rfc3339_ish(conn: &Connection) {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT received_at FROM emails LIMIT 50")
|
||||
.expect("prepare");
|
||||
let rows: Vec<String> = stmt
|
||||
.query_map([], |r| r.get::<_, String>(0))
|
||||
.expect("query")
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert!(!rows.is_empty(), "no emails to check received_at");
|
||||
for r in rows {
|
||||
assert!(
|
||||
r.contains('T') && (r.ends_with('Z') || r.contains('+') || r.contains('-')),
|
||||
"received_at does not look like RFC 3339: {r}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn _unused(_: &AccountSeed) {}
|
||||
@@ -0,0 +1,459 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
mod integration;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use integration::radicale::{AccountSeed, Radicale};
|
||||
use integration::validate::{
|
||||
all_contact_uids, all_event_uids, assert_contact_round_trip, assert_event_round_trip,
|
||||
calendar_event_count, cleanup, collection_id_by_name, collection_names, common,
|
||||
contact_card_count, count, open_archive, tmp_archive,
|
||||
};
|
||||
use integration::{Account, Endpoint};
|
||||
|
||||
use rusqlite::Connection;
|
||||
use serde_json::Value;
|
||||
use vandelay::error::Error;
|
||||
use vandelay::sync::import_dav;
|
||||
use vandelay::sync::import_dav::{DavAuth, DavImportConfig, DavKindArg};
|
||||
|
||||
fn dav_config(kind: DavKindArg, account: &Account, http: &Endpoint) -> DavImportConfig {
|
||||
DavImportConfig {
|
||||
kind,
|
||||
url: format!("http://{}:{}/{}/", http.host, http.port, account.username),
|
||||
auth: DavAuth::Basic {
|
||||
user: account.username.clone(),
|
||||
password: account.password.clone(),
|
||||
},
|
||||
allow_cleartext: true,
|
||||
dav_connections: 2,
|
||||
multiget_batch: 25,
|
||||
allow_source_change: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn radicale_starts_seeds_and_imports() {
|
||||
let r = Radicale::start().expect("radicale start");
|
||||
let per_account = r.seed_all().expect("radicale seed");
|
||||
assert_eq!(
|
||||
per_account.len(),
|
||||
r.accounts.len(),
|
||||
"seed should return stats for every account"
|
||||
);
|
||||
r.verify_seed(&per_account).expect("radicale verify");
|
||||
|
||||
for seed in &per_account {
|
||||
let account = r
|
||||
.accounts
|
||||
.iter()
|
||||
.find(|a| a.username == seed.username)
|
||||
.expect("account lookup");
|
||||
assert!(
|
||||
!seed.calendars.is_empty(),
|
||||
"{}: expected calendars seeded",
|
||||
seed.username
|
||||
);
|
||||
assert!(
|
||||
!seed.address_books.is_empty(),
|
||||
"{}: expected address books seeded",
|
||||
seed.username
|
||||
);
|
||||
assert!(
|
||||
seed.total_events() > 0,
|
||||
"{}: expected events seeded",
|
||||
seed.username
|
||||
);
|
||||
assert!(
|
||||
seed.total_contacts() > 0,
|
||||
"{}: expected contacts seeded",
|
||||
seed.username
|
||||
);
|
||||
|
||||
let cal_archive = tmp_archive(&format!("radicale-cal-{}", seed.username));
|
||||
let summary = import_dav::run(
|
||||
common(&cal_archive),
|
||||
dav_config(DavKindArg::Caldav, account, &r.endpoint),
|
||||
)
|
||||
.expect("caldav import");
|
||||
assert!(
|
||||
!summary.any_failed(),
|
||||
"{}: first caldav import had failures: {summary:?}",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&cal_archive);
|
||||
assert_event_collection_exact(&conn, seed);
|
||||
assert_event_uids_round_trip(&conn, seed);
|
||||
assert_event_data_well_formed(&conn);
|
||||
for plan in &seed.calendars {
|
||||
for item in &plan.items {
|
||||
assert_event_round_trip(
|
||||
&conn,
|
||||
&item.source,
|
||||
&item.uid,
|
||||
&format!("{}/{}", seed.username, plan.display_name),
|
||||
);
|
||||
}
|
||||
}
|
||||
let cal_count = count(&conn, "calendars") as usize;
|
||||
let ev_count = count(&conn, "calendar_events") as usize;
|
||||
let blob_count = count(&conn, "blobs") as usize;
|
||||
drop(conn);
|
||||
|
||||
let summary2 = import_dav::run(
|
||||
common(&cal_archive),
|
||||
dav_config(DavKindArg::Caldav, account, &r.endpoint),
|
||||
)
|
||||
.expect("idempotent caldav re-import");
|
||||
assert!(
|
||||
!summary2.any_failed(),
|
||||
"{}: idempotent caldav re-import had failures",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&cal_archive);
|
||||
assert_eq!(
|
||||
count(&conn, "calendars") as usize,
|
||||
cal_count,
|
||||
"{}: idempotent re-import changed calendar count",
|
||||
seed.username
|
||||
);
|
||||
assert_eq!(
|
||||
count(&conn, "calendar_events") as usize,
|
||||
ev_count,
|
||||
"{}: idempotent re-import changed event count",
|
||||
seed.username
|
||||
);
|
||||
assert_eq!(
|
||||
count(&conn, "blobs") as usize,
|
||||
blob_count,
|
||||
"{}: idempotent re-import changed blob count",
|
||||
seed.username
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
let card_archive = tmp_archive(&format!("radicale-card-{}", seed.username));
|
||||
let summary = import_dav::run(
|
||||
common(&card_archive),
|
||||
dav_config(DavKindArg::Carddav, account, &r.endpoint),
|
||||
)
|
||||
.expect("carddav import");
|
||||
assert!(
|
||||
!summary.any_failed(),
|
||||
"{}: first carddav import had failures: {summary:?}",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&card_archive);
|
||||
assert_contact_collection_exact(&conn, seed);
|
||||
assert_contact_uids_round_trip(&conn, seed);
|
||||
assert_contact_data_well_formed(&conn);
|
||||
for plan in &seed.address_books {
|
||||
for item in &plan.items {
|
||||
assert_contact_round_trip(
|
||||
&conn,
|
||||
&item.source,
|
||||
&item.uid,
|
||||
&format!("{}/{}", seed.username, plan.display_name),
|
||||
);
|
||||
}
|
||||
}
|
||||
let book_count = count(&conn, "address_books") as usize;
|
||||
let card_count = count(&conn, "contact_cards") as usize;
|
||||
drop(conn);
|
||||
|
||||
let summary2 = import_dav::run(
|
||||
common(&card_archive),
|
||||
dav_config(DavKindArg::Carddav, account, &r.endpoint),
|
||||
)
|
||||
.expect("idempotent carddav re-import");
|
||||
assert!(
|
||||
!summary2.any_failed(),
|
||||
"{}: idempotent carddav re-import had failures",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&card_archive);
|
||||
assert_eq!(
|
||||
count(&conn, "address_books") as usize,
|
||||
book_count,
|
||||
"{}: idempotent re-import changed address book count",
|
||||
seed.username
|
||||
);
|
||||
assert_eq!(
|
||||
count(&conn, "contact_cards") as usize,
|
||||
card_count,
|
||||
"{}: idempotent re-import changed contact count",
|
||||
seed.username
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
let vanish_target_href = seed.calendars[0]
|
||||
.items
|
||||
.first()
|
||||
.map(|i| i.href.clone())
|
||||
.expect("at least one event seeded");
|
||||
let vanish_uid = seed.calendars[0]
|
||||
.items
|
||||
.first()
|
||||
.map(|i| i.uid.clone())
|
||||
.expect("uid");
|
||||
r.delete_item(account, &vanish_target_href)
|
||||
.expect("radicale delete vanished item");
|
||||
let summary_after_vanish = import_dav::run(
|
||||
common(&cal_archive),
|
||||
dav_config(DavKindArg::Caldav, account, &r.endpoint),
|
||||
)
|
||||
.expect("caldav re-import after vanish");
|
||||
assert!(
|
||||
!summary_after_vanish.any_failed(),
|
||||
"{}: re-import after vanish had failures",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&cal_archive);
|
||||
let after_vanish_events = count(&conn, "calendar_events") as usize;
|
||||
assert_eq!(
|
||||
after_vanish_events,
|
||||
ev_count - 1,
|
||||
"{}: vanished event was not pruned from archive",
|
||||
seed.username
|
||||
);
|
||||
let uids_now = all_event_uids(&conn);
|
||||
assert!(
|
||||
!uids_now.contains(&vanish_uid),
|
||||
"{}: vanished event uid {vanish_uid} still in archive",
|
||||
seed.username
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
let icals = integration::data::load_icals().expect("load icals");
|
||||
let (_added_event_href, added_event_uid) = r
|
||||
.add_event(account, 0, &format!("rad-{}", seed.username), &icals)
|
||||
.expect("radicale add event");
|
||||
let summary_after_add_event = import_dav::run(
|
||||
common(&cal_archive),
|
||||
dav_config(DavKindArg::Caldav, account, &r.endpoint),
|
||||
)
|
||||
.expect("caldav re-import after add");
|
||||
assert!(
|
||||
!summary_after_add_event.any_failed(),
|
||||
"{}: re-import after add-event had failures",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&cal_archive);
|
||||
let after_add_events = count(&conn, "calendar_events") as usize;
|
||||
assert_eq!(
|
||||
after_add_events,
|
||||
ev_count,
|
||||
"{}: add-then-import should restore count to baseline (was {} after vanish, expected {ev_count})",
|
||||
seed.username,
|
||||
ev_count - 1
|
||||
);
|
||||
let uids_now = all_event_uids(&conn);
|
||||
assert!(
|
||||
uids_now.contains(&added_event_uid),
|
||||
"{}: added event uid {added_event_uid} not found in archive after re-import",
|
||||
seed.username
|
||||
);
|
||||
let added_cal_id =
|
||||
collection_id_by_name(&conn, "calendars", &seed.calendars[0].display_name)
|
||||
.expect("calendar id");
|
||||
let new_calendar_count = calendar_event_count(&conn, added_cal_id);
|
||||
assert_eq!(
|
||||
new_calendar_count as usize,
|
||||
seed.calendars[0].items.len(),
|
||||
"{}: target calendar event count after add+vanish should match original",
|
||||
seed.username
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
let vcards = integration::data::load_vcards().expect("load vcards");
|
||||
let (_added_card_href, added_card_uid) = r
|
||||
.add_contact(account, 0, &format!("rad-{}", seed.username), &vcards)
|
||||
.expect("radicale add contact");
|
||||
let summary_after_add_contact = import_dav::run(
|
||||
common(&card_archive),
|
||||
dav_config(DavKindArg::Carddav, account, &r.endpoint),
|
||||
)
|
||||
.expect("carddav re-import after add");
|
||||
assert!(
|
||||
!summary_after_add_contact.any_failed(),
|
||||
"{}: re-import after add-contact had failures",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&card_archive);
|
||||
let after_add_contacts = count(&conn, "contact_cards") as usize;
|
||||
assert_eq!(
|
||||
after_add_contacts,
|
||||
card_count + 1,
|
||||
"{}: add-then-import should grow contact count by 1",
|
||||
seed.username
|
||||
);
|
||||
let imported_contact_uids = all_contact_uids(&conn);
|
||||
assert!(
|
||||
imported_contact_uids.contains(&added_card_uid),
|
||||
"{}: added contact uid {added_card_uid} not found",
|
||||
seed.username
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
cleanup(&cal_archive);
|
||||
cleanup(&card_archive);
|
||||
}
|
||||
|
||||
let primary = &r.accounts[0];
|
||||
let other = &r.accounts[1];
|
||||
let shared_archive = tmp_archive("radicale-source-change");
|
||||
import_dav::run(
|
||||
common(&shared_archive),
|
||||
dav_config(DavKindArg::Caldav, primary, &r.endpoint),
|
||||
)
|
||||
.expect("seed archive with primary user");
|
||||
let err = import_dav::run(
|
||||
common(&shared_archive),
|
||||
dav_config(DavKindArg::Caldav, other, &r.endpoint),
|
||||
)
|
||||
.expect_err("expected source-change abort when pointing at second user");
|
||||
assert!(
|
||||
matches!(err, Error::SourceChange(_)),
|
||||
"expected SourceChange, got {err:?}"
|
||||
);
|
||||
cleanup(&shared_archive);
|
||||
|
||||
r.stop().expect("radicale stop");
|
||||
}
|
||||
|
||||
fn assert_event_collection_exact(conn: &Connection, seed: &AccountSeed) {
|
||||
let names = collection_names(conn, "calendars");
|
||||
let expected: HashSet<String> = seed
|
||||
.calendars
|
||||
.iter()
|
||||
.map(|c| c.display_name.clone())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names, expected,
|
||||
"{}: calendar displayname set mismatch",
|
||||
seed.username
|
||||
);
|
||||
for plan in &seed.calendars {
|
||||
let cal_id = collection_id_by_name(conn, "calendars", &plan.display_name)
|
||||
.unwrap_or_else(|| panic!("calendar {} missing", plan.display_name));
|
||||
let got = calendar_event_count(conn, cal_id);
|
||||
assert_eq!(
|
||||
got as usize,
|
||||
plan.items.len(),
|
||||
"{}: calendar {} event count {got} != seeded {}",
|
||||
seed.username,
|
||||
plan.display_name,
|
||||
plan.items.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_contact_collection_exact(conn: &Connection, seed: &AccountSeed) {
|
||||
let names = collection_names(conn, "address_books");
|
||||
let expected: HashSet<String> = seed
|
||||
.address_books
|
||||
.iter()
|
||||
.map(|c| c.display_name.clone())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names, expected,
|
||||
"{}: address book displayname set mismatch",
|
||||
seed.username
|
||||
);
|
||||
for plan in &seed.address_books {
|
||||
let book_id = collection_id_by_name(conn, "address_books", &plan.display_name)
|
||||
.unwrap_or_else(|| panic!("address book {} missing", plan.display_name));
|
||||
let got = contact_card_count(conn, book_id);
|
||||
assert_eq!(
|
||||
got as usize,
|
||||
plan.items.len(),
|
||||
"{}: book {} contact count {got} != seeded {}",
|
||||
seed.username,
|
||||
plan.display_name,
|
||||
plan.items.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_event_uids_round_trip(conn: &Connection, seed: &AccountSeed) {
|
||||
let imported = all_event_uids(conn);
|
||||
let expected = seed.event_uids();
|
||||
let missing: Vec<_> = expected.difference(&imported).collect();
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"{}: event uids missing from archive: {missing:?}",
|
||||
seed.username
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_contact_uids_round_trip(conn: &Connection, seed: &AccountSeed) {
|
||||
let imported = all_contact_uids(conn);
|
||||
let expected = seed.contact_uids();
|
||||
let missing: Vec<_> = expected.difference(&imported).collect();
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"{}: contact uids missing from archive: {missing:?}",
|
||||
seed.username
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_event_data_well_formed(conn: &Connection) {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT data, data_type FROM calendar_events")
|
||||
.expect("prepare");
|
||||
let rows: Vec<(String, String)> = stmt
|
||||
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
|
||||
.expect("query")
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert!(!rows.is_empty(), "no calendar_events rows");
|
||||
for (raw, data_type) in rows {
|
||||
assert_eq!(
|
||||
data_type, "Event",
|
||||
"all VEVENT seeds must land as Event, got data_type={data_type}, raw={raw}"
|
||||
);
|
||||
let v: Value = serde_json::from_str(&raw)
|
||||
.unwrap_or_else(|e| panic!("calendar_events.data invalid JSON: {e}; raw={raw}"));
|
||||
let obj = v.as_object().expect("event data is object");
|
||||
let kind = obj
|
||||
.get("@type")
|
||||
.or_else(|| obj.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
assert_eq!(kind, "Event", "JSCalendar @type must be Event, got {kind}");
|
||||
assert!(obj.contains_key("uid"), "event JSON missing uid: {obj:?}");
|
||||
assert!(
|
||||
obj.contains_key("start"),
|
||||
"event JSON missing start: {obj:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_contact_data_well_formed(conn: &Connection) {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT uid, data FROM contact_cards")
|
||||
.expect("prepare");
|
||||
let rows: Vec<(String, String)> = stmt
|
||||
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
|
||||
.expect("query")
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert!(!rows.is_empty(), "no contact_cards rows");
|
||||
for (uid, raw) in rows {
|
||||
assert!(!uid.is_empty(), "contact uid empty");
|
||||
let v: Value = serde_json::from_str(&raw)
|
||||
.unwrap_or_else(|e| panic!("contact_cards.data invalid JSON: {e}; raw={raw}"));
|
||||
let obj = v.as_object().expect("contact data is object");
|
||||
let kind = obj
|
||||
.get("@type")
|
||||
.or_else(|| obj.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
assert_eq!(kind, "Card", "JSContact @type must be Card, got {kind}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
mod integration;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use integration::layouts::FileSpec;
|
||||
use integration::validate::{
|
||||
blob_bytes, cleanup, common, count, file_node_id_by_path, file_path, open_archive, tmp_archive,
|
||||
};
|
||||
use integration::webdav::{AccountSeed, WebDav};
|
||||
use integration::{Account, Endpoint};
|
||||
|
||||
use vandelay::error::Error;
|
||||
use vandelay::sync::import_dav;
|
||||
use vandelay::sync::import_dav::{DavAuth, DavImportConfig, DavKindArg};
|
||||
|
||||
fn webdav_config(account: &Account, http: &Endpoint) -> DavImportConfig {
|
||||
DavImportConfig {
|
||||
kind: DavKindArg::Webdav,
|
||||
url: format!("http://{}:{}/{}/", http.host, http.port, account.username),
|
||||
auth: DavAuth::Basic {
|
||||
user: account.username.clone(),
|
||||
password: account.password.clone(),
|
||||
},
|
||||
allow_cleartext: true,
|
||||
dav_connections: 2,
|
||||
multiget_batch: 25,
|
||||
allow_source_change: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn webdav_starts_seeds_and_imports() {
|
||||
let w = WebDav::start().expect("webdav start");
|
||||
let seeds = w.seed_all().expect("webdav seed");
|
||||
assert_eq!(
|
||||
seeds.len(),
|
||||
w.accounts.len(),
|
||||
"seed should return stats for every account"
|
||||
);
|
||||
w.verify_seed().expect("webdav verify");
|
||||
for seed in &seeds {
|
||||
assert!(
|
||||
seed.directories > 0,
|
||||
"{}: expected directories seeded",
|
||||
seed.username
|
||||
);
|
||||
assert!(
|
||||
!seed.files.is_empty(),
|
||||
"{}: expected files seeded",
|
||||
seed.username
|
||||
);
|
||||
}
|
||||
|
||||
for seed in &seeds {
|
||||
let account = w
|
||||
.accounts
|
||||
.iter()
|
||||
.find(|a| a.username == seed.username)
|
||||
.expect("account lookup");
|
||||
let archive = tmp_archive(&format!("webdav-{}", seed.username));
|
||||
let summary = import_dav::run(common(&archive), webdav_config(account, &w.http))
|
||||
.expect("webdav import");
|
||||
assert!(
|
||||
!summary.any_failed(),
|
||||
"{}: webdav import had failures: {summary:?}",
|
||||
seed.username
|
||||
);
|
||||
|
||||
let conn = open_archive(&archive);
|
||||
let nodes = count(&conn, "file_nodes") as usize;
|
||||
let dirs: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM file_nodes WHERE node_type = 'directory'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
let files: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM file_nodes WHERE node_type = 'file'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
let expected_dirs = account.layout.files.iter().filter(|s| s.directory).count() + 1;
|
||||
let expected_files = account.layout.files.len() - (expected_dirs - 1);
|
||||
|
||||
assert_eq!(
|
||||
files as usize, expected_files,
|
||||
"{}: every seeded file must land in archive (seeded {expected_files}, imported {files})",
|
||||
seed.username
|
||||
);
|
||||
assert_eq!(
|
||||
dirs as usize, expected_dirs,
|
||||
"{}: directory count mismatch (seeded layout dirs + account root = {expected_dirs}, imported {dirs})",
|
||||
seed.username
|
||||
);
|
||||
assert_eq!(
|
||||
nodes,
|
||||
account.layout.files.len() + 1,
|
||||
"{}: total file_nodes mismatch (layout + account root)",
|
||||
seed.username
|
||||
);
|
||||
|
||||
for spec in account.layout.files {
|
||||
let segments = layout_segments(account.layout.files, spec.key);
|
||||
let mut probes: Vec<Vec<&str>> = Vec::new();
|
||||
probes.push(segments.clone());
|
||||
probes.push({
|
||||
let mut s = vec![account.username.as_str()];
|
||||
s.extend(segments.iter().copied());
|
||||
s
|
||||
});
|
||||
let node_id = probes
|
||||
.iter()
|
||||
.find_map(|p| file_node_id_by_path(&conn, p))
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"{}: file_node at layout path {segments:?} missing in archive",
|
||||
seed.username
|
||||
)
|
||||
});
|
||||
let reconstructed = file_path(&conn, node_id);
|
||||
let expected_relative = segments.join("/");
|
||||
assert!(
|
||||
reconstructed.ends_with(&expected_relative),
|
||||
"{}: path '{reconstructed}' does not end with seeded layout path '{expected_relative}'",
|
||||
seed.username
|
||||
);
|
||||
let (blob_id, node_type, media_type, created, modified): (
|
||||
Option<i64>,
|
||||
String,
|
||||
Option<String>,
|
||||
String,
|
||||
Option<String>,
|
||||
) = conn
|
||||
.query_row(
|
||||
"SELECT blob_id, node_type, media_type, created, modified
|
||||
FROM file_nodes WHERE id = ?1",
|
||||
[node_id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
|
||||
)
|
||||
.unwrap();
|
||||
let expected_type = if spec.directory { "directory" } else { "file" };
|
||||
assert_eq!(
|
||||
node_type, expected_type,
|
||||
"{}: {} node_type mismatch",
|
||||
seed.username, spec.name
|
||||
);
|
||||
assert!(
|
||||
!created.is_empty(),
|
||||
"{}: {} missing created timestamp",
|
||||
seed.username,
|
||||
spec.name
|
||||
);
|
||||
if !spec.directory {
|
||||
let blob_id = blob_id.expect("file blob_id");
|
||||
let actual = blob_bytes(&conn, blob_id);
|
||||
let expected = integration::webdav::synth_payload(spec.name).into_bytes();
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"{}: file {} content round-tripped incorrectly",
|
||||
seed.username, spec.name
|
||||
);
|
||||
let mt = media_type.unwrap_or_default();
|
||||
assert!(
|
||||
!mt.is_empty(),
|
||||
"{}: file {} missing media_type",
|
||||
seed.username,
|
||||
spec.name
|
||||
);
|
||||
let m = modified.unwrap_or_default();
|
||||
assert!(
|
||||
!m.is_empty(),
|
||||
"{}: file {} missing modified timestamp",
|
||||
seed.username,
|
||||
spec.name
|
||||
);
|
||||
let (blob_size, blob_hash): (i64, Vec<u8>) = conn
|
||||
.query_row(
|
||||
"SELECT length(data), hash FROM blobs WHERE id = ?1",
|
||||
[blob_id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
blob_size as usize,
|
||||
expected.len(),
|
||||
"{}: file {} blob length mismatch",
|
||||
seed.username,
|
||||
spec.name
|
||||
);
|
||||
assert_eq!(
|
||||
blob_hash,
|
||||
blake3::hash(&expected).as_bytes().to_vec(),
|
||||
"{}: file {} blob.hash mismatch",
|
||||
seed.username,
|
||||
spec.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let payload_groups = duplicate_payload_groups(account.layout.files);
|
||||
for (name, occurrences) in payload_groups {
|
||||
if occurrences < 2 {
|
||||
continue;
|
||||
}
|
||||
let payload = integration::webdav::synth_payload(&name).into_bytes();
|
||||
let hash = blake3::hash(&payload);
|
||||
let blob_id: i64 = conn
|
||||
.query_row(
|
||||
"SELECT id FROM blobs WHERE hash = ?1",
|
||||
[hash.as_bytes()],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"{}: duplicate-named payload {} missing single blob: {e}",
|
||||
seed.username, name
|
||||
)
|
||||
});
|
||||
let refs: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM file_nodes WHERE blob_id = ?1",
|
||||
[blob_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
refs as usize, occurrences,
|
||||
"{}: blob {blob_id} for duplicate payload {name} should be referenced {occurrences} times, got {refs}",
|
||||
seed.username
|
||||
);
|
||||
}
|
||||
|
||||
drop(conn);
|
||||
|
||||
let summary2 = import_dav::run(common(&archive), webdav_config(account, &w.http))
|
||||
.expect("idempotent webdav re-import");
|
||||
assert!(
|
||||
!summary2.any_failed(),
|
||||
"{}: idempotent re-import had failures: {summary2:?}",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&archive);
|
||||
let nodes2 = count(&conn, "file_nodes") as usize;
|
||||
let blobs1 = count(&conn, "blobs") as usize;
|
||||
assert_eq!(
|
||||
nodes2, nodes,
|
||||
"{}: idempotent re-import changed file_nodes count",
|
||||
seed.username
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
let vanish = seed.files.first().expect("at least one file seeded");
|
||||
w.delete_resource(account, &vanish.href)
|
||||
.expect("webdav delete file");
|
||||
let summary_after = import_dav::run(common(&archive), webdav_config(account, &w.http))
|
||||
.expect("re-import after delete");
|
||||
assert!(
|
||||
!summary_after.any_failed(),
|
||||
"{}: re-import after delete had failures",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&archive);
|
||||
let files_after: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM file_nodes WHERE node_type = 'file'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
files_after as usize,
|
||||
expected_files - 1,
|
||||
"{}: vanished file not pruned",
|
||||
seed.username
|
||||
);
|
||||
let blobs2 = count(&conn, "blobs") as usize;
|
||||
assert!(
|
||||
blobs2 <= blobs1,
|
||||
"{}: blob count after delete grew unexpectedly ({blobs1} -> {blobs2})",
|
||||
seed.username
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
let new_payload = format!(
|
||||
"added-{} content for vandelay webdav add-after-import probe\n",
|
||||
seed.username
|
||||
);
|
||||
let new_payload_bytes = new_payload.as_bytes().to_vec();
|
||||
let added_name = format!("added-{}.bin", seed.username);
|
||||
let _added_href = w
|
||||
.add_file(account, &[], &added_name, &new_payload_bytes)
|
||||
.expect("webdav add file");
|
||||
let summary_after_add = import_dav::run(common(&archive), webdav_config(account, &w.http))
|
||||
.expect("re-import after add");
|
||||
assert!(
|
||||
!summary_after_add.any_failed(),
|
||||
"{}: re-import after add had failures",
|
||||
seed.username
|
||||
);
|
||||
let conn = open_archive(&archive);
|
||||
let files_after_add: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM file_nodes WHERE node_type = 'file'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
files_after_add as usize, expected_files,
|
||||
"{}: add-after-delete should restore baseline file count",
|
||||
seed.username
|
||||
);
|
||||
let added_hash = blake3::hash(&new_payload_bytes);
|
||||
let added_blob: i64 = conn
|
||||
.query_row(
|
||||
"SELECT id FROM blobs WHERE hash = ?1",
|
||||
[added_hash.as_bytes()],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.expect("added file blob should be present");
|
||||
let refs: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM file_nodes WHERE blob_id = ?1",
|
||||
[added_blob],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
refs, 1,
|
||||
"{}: added file should be referenced by exactly one file_node",
|
||||
seed.username
|
||||
);
|
||||
let added_node_id: i64 = conn
|
||||
.query_row(
|
||||
"SELECT id FROM file_nodes WHERE name = ?1",
|
||||
[&added_name],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.expect("added file_node by name");
|
||||
let rebuilt = file_path(&conn, added_node_id);
|
||||
assert!(
|
||||
rebuilt.ends_with(&added_name),
|
||||
"{}: reconstructed path for added node should end with {added_name}, got {rebuilt}",
|
||||
seed.username
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
cleanup(&archive);
|
||||
}
|
||||
|
||||
let primary = &w.accounts[0];
|
||||
let other = &w.accounts[1];
|
||||
let shared_archive = tmp_archive("webdav-source-change");
|
||||
import_dav::run(common(&shared_archive), webdav_config(primary, &w.http))
|
||||
.expect("seed archive with primary user");
|
||||
let err = import_dav::run(common(&shared_archive), webdav_config(other, &w.http))
|
||||
.expect_err("expected source-change abort");
|
||||
assert!(
|
||||
matches!(err, Error::SourceChange(_)),
|
||||
"expected SourceChange, got {err:?}"
|
||||
);
|
||||
cleanup(&shared_archive);
|
||||
|
||||
w.stop().expect("webdav stop");
|
||||
}
|
||||
|
||||
fn layout_segments<'a>(specs: &'a [FileSpec], key: &str) -> Vec<&'a str> {
|
||||
let mut parts: Vec<&'a str> = Vec::new();
|
||||
let mut cur = key;
|
||||
loop {
|
||||
let spec = specs.iter().find(|s| s.key == cur).expect("layout key");
|
||||
parts.push(spec.name);
|
||||
match spec.parent {
|
||||
Some(p) => cur = p,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
parts.reverse();
|
||||
parts
|
||||
}
|
||||
|
||||
fn duplicate_payload_groups(specs: &[FileSpec]) -> Vec<(String, usize)> {
|
||||
let mut seen: Vec<(String, usize)> = Vec::new();
|
||||
let mut names: HashSet<String> = HashSet::new();
|
||||
for spec in specs.iter().filter(|s| !s.directory) {
|
||||
names.insert(spec.name.to_owned());
|
||||
}
|
||||
for name in names {
|
||||
let occurrences = specs
|
||||
.iter()
|
||||
.filter(|s| !s.directory && s.name == name)
|
||||
.count();
|
||||
seen.push((name, occurrences));
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
fn _unused_seed_marker(_: &AccountSeed) {}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
mod integration;
|
||||
mod seeder;
|
||||
|
||||
use integration::stalwart::shared as shared_stalwart;
|
||||
use vandelay::jmap::account::{self, AccountSelector};
|
||||
use vandelay::jmap::http::{Auth, HttpClient, RetryPolicy};
|
||||
use vandelay::jmap::session::Session;
|
||||
|
||||
fn admin_client() -> HttpClient {
|
||||
HttpClient::new(
|
||||
Auth::Basic {
|
||||
user: seeder::ADMIN_USER.into(),
|
||||
password: seeder::ADMIN_PASSWORD.into(),
|
||||
},
|
||||
RetryPolicy::new(5),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn session_discovery_and_admin_principal_resolution() {
|
||||
let stalwart = shared_stalwart();
|
||||
let fx = seeder::provision(stalwart.base_url()).expect("provision");
|
||||
let client = admin_client();
|
||||
|
||||
let session =
|
||||
Session::discover(&client, &fx.base_url).expect("session discovery via .well-known");
|
||||
assert!(
|
||||
session.api_url.starts_with("https://") || session.api_url.starts_with("http://"),
|
||||
"apiUrl should be absolute: {}",
|
||||
session.api_url
|
||||
);
|
||||
let limits = session.core_limits().expect("core limits present");
|
||||
assert!(limits.max_objects_in_get >= 1);
|
||||
assert!(limits.max_concurrent_requests >= 1);
|
||||
assert!(
|
||||
!session.accounts.is_empty(),
|
||||
"authenticated admin session must enumerate accounts"
|
||||
);
|
||||
|
||||
assert_eq!(fx.domain, seeder::DOMAIN);
|
||||
assert!(
|
||||
!fx.domain_id.is_empty(),
|
||||
"seeder should have ensured a domain id"
|
||||
);
|
||||
assert_eq!(
|
||||
fx.admin_login,
|
||||
(
|
||||
seeder::ADMIN_USER.to_owned(),
|
||||
seeder::ADMIN_PASSWORD.to_owned()
|
||||
)
|
||||
);
|
||||
|
||||
let target = fx.account("test1").expect("test1 seeded");
|
||||
assert!(
|
||||
!target.admin_role,
|
||||
"test1 must be a regular user, not admin"
|
||||
);
|
||||
let seeded = target.seeded.as_ref().expect("test1 seed stats");
|
||||
assert!(seeded.emails > 0, "test1 should be seeded with emails");
|
||||
assert!(
|
||||
seeded.mailboxes_created >= 7,
|
||||
"test1 layout has at least 7 mailboxes"
|
||||
);
|
||||
assert!(
|
||||
seeded.file_nodes >= 9,
|
||||
"test1 layout has at least 9 file nodes"
|
||||
);
|
||||
assert!(
|
||||
seeded.contacts > 0,
|
||||
"test1 should be seeded with at least one contact"
|
||||
);
|
||||
assert!(
|
||||
seeded.events > 0,
|
||||
"test1 should be seeded with at least one event"
|
||||
);
|
||||
assert!(
|
||||
seeded.address_books > 0,
|
||||
"test1 layout requests an extra address book"
|
||||
);
|
||||
assert!(
|
||||
seeded.calendars > 0,
|
||||
"test1 layout requests an extra calendar"
|
||||
);
|
||||
assert!(seeded.identity, "test1 layout requests a custom identity");
|
||||
assert_eq!(
|
||||
seeded.sieve_active,
|
||||
Some(true),
|
||||
"test1 layout activates a sieve script"
|
||||
);
|
||||
|
||||
let resolved = account::resolve(
|
||||
&AccountSelector::Name(target.email.clone()),
|
||||
&session,
|
||||
&client,
|
||||
)
|
||||
.expect("admin principal resolution");
|
||||
assert_eq!(
|
||||
resolved, target.account_id,
|
||||
"{} must resolve to the seeded account id {}",
|
||||
target.email, target.account_id
|
||||
);
|
||||
|
||||
seeder::teardown(stalwart.base_url()).expect("teardown");
|
||||
}
|
||||
+1216
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,585 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use mockito::Matcher;
|
||||
use vandelay::exchange_ews::EwsClient;
|
||||
use vandelay::exchange_ews::autodiscover::{DiscoverySource, discover};
|
||||
use vandelay::exchange_ews::error::EwsError;
|
||||
use vandelay::exchange_ews::parse::{
|
||||
EnvelopeKind, parse_find_folder_response, parse_find_item_response,
|
||||
parse_get_attachment_inline, parse_response_messages, parse_sync_folder_items_response,
|
||||
read_envelope_summary,
|
||||
};
|
||||
use vandelay::exchange_ews::types::{FolderId, ItemId, ResponseCode};
|
||||
use vandelay::exchange_ews::xml::{
|
||||
FolderRef, ItemShape, Traversal, find_folder_body, find_item_body, get_attachment_body,
|
||||
get_item_body, sync_folder_items_body,
|
||||
};
|
||||
use vandelay::jmap::http::{Auth, RetryPolicy};
|
||||
|
||||
const TXT_XML: &str = "text/xml; charset=utf-8";
|
||||
const APP_JSON: &str = "application/json";
|
||||
|
||||
fn client(retries: u32) -> EwsClient {
|
||||
EwsClient::new(
|
||||
Auth::Bearer {
|
||||
token: "t".to_owned(),
|
||||
},
|
||||
RetryPolicy::new(retries),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
const NS: &str = " xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\" \
|
||||
xmlns:t=\"http://schemas.microsoft.com/exchange/services/2006/types\" \
|
||||
xmlns:m=\"http://schemas.microsoft.com/exchange/services/2006/messages\"";
|
||||
|
||||
fn envelope(inner: &str) -> String {
|
||||
format!(
|
||||
"<soap:Envelope{NS}><soap:Header><t:ServerVersionInfo MajorVersion=\"15\" MinorVersion=\"1\"/></soap:Header><soap:Body>{inner}</soap:Body></soap:Envelope>"
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autodiscover_v2_returns_global_endpoint() {
|
||||
let mut server = mockito::Server::new();
|
||||
let _m = server
|
||||
.mock("GET", "/autodiscover/autodiscover.json")
|
||||
.match_query(Matcher::AllOf(vec![
|
||||
Matcher::UrlEncoded("Email".into(), "[email protected]".into()),
|
||||
Matcher::UrlEncoded("Protocol".into(), "Ews".into()),
|
||||
]))
|
||||
.with_status(200)
|
||||
.with_header("content-type", APP_JSON)
|
||||
.with_body(r#"{"Protocol":"Ews","Url":"https://outlook.office365.com/EWS/Exchange.asmx"}"#)
|
||||
.create();
|
||||
let _ = server;
|
||||
let url = "https://outlook.office365.com/EWS/Exchange.asmx";
|
||||
assert!(vandelay::exchange_ews::autodiscover::is_fully_qualified_ews_url(url));
|
||||
let r = discover(Some(url), None, None, false).unwrap();
|
||||
assert_eq!(r.source, DiscoverySource::SuppliedUrl);
|
||||
assert_eq!(r.ews_url, url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_folder_pagination_and_classification() {
|
||||
let body = format!(
|
||||
"<m:FindFolderResponse{NS}><m:ResponseMessages><m:FindFolderResponseMessage ResponseClass=\"Success\">\
|
||||
<m:ResponseCode>NoError</m:ResponseCode>\
|
||||
<m:RootFolder TotalItemsInView=\"3\" IncludesLastItemInRange=\"true\">\
|
||||
<t:Folders>\
|
||||
<t:Folder><t:FolderId Id=\"FMAIL\" ChangeKey=\"C\"/><t:ParentFolderId Id=\"ROOT\"/>\
|
||||
<t:FolderClass>IPF.Note</t:FolderClass><t:DisplayName>Inbox</t:DisplayName></t:Folder>\
|
||||
<t:CalendarFolder><t:FolderId Id=\"FCAL\"/><t:ParentFolderId Id=\"ROOT\"/>\
|
||||
<t:FolderClass>IPF.Appointment</t:FolderClass><t:DisplayName>Calendar</t:DisplayName></t:CalendarFolder>\
|
||||
<t:ContactsFolder><t:FolderId Id=\"FCON\"/><t:ParentFolderId Id=\"ROOT\"/>\
|
||||
<t:FolderClass>IPF.Contact</t:FolderClass><t:DisplayName>Contacts</t:DisplayName></t:ContactsFolder>\
|
||||
</t:Folders></m:RootFolder></m:FindFolderResponseMessage></m:ResponseMessages></m:FindFolderResponse>"
|
||||
);
|
||||
let parsed = parse_find_folder_response(envelope(&body).as_bytes()).unwrap();
|
||||
assert_eq!(parsed.folders.len(), 3);
|
||||
let inbox = &parsed.folders[0];
|
||||
assert_eq!(inbox.folder_id.id, "FMAIL");
|
||||
assert_eq!(inbox.folder_class, "IPF.Note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_item_offset_loop_terminates_on_includes_last_true() {
|
||||
let mut server = mockito::Server::new();
|
||||
let url = format!("{}/EWS/Exchange.asmx", server.url());
|
||||
|
||||
let page1 = envelope(&format!(
|
||||
"<m:FindItemResponse{NS}><m:ResponseMessages><m:FindItemResponseMessage ResponseClass=\"Success\">\
|
||||
<m:ResponseCode>NoError</m:ResponseCode>\
|
||||
<m:RootFolder TotalItemsInView=\"4\" IncludesLastItemInRange=\"false\">\
|
||||
<t:Items><t:Message><t:ItemId Id=\"A\" ChangeKey=\"1\"/></t:Message>\
|
||||
<t:Message><t:ItemId Id=\"B\" ChangeKey=\"1\"/></t:Message></t:Items></m:RootFolder>\
|
||||
</m:FindItemResponseMessage></m:ResponseMessages></m:FindItemResponse>"
|
||||
));
|
||||
let page2 = envelope(&format!(
|
||||
"<m:FindItemResponse{NS}><m:ResponseMessages><m:FindItemResponseMessage ResponseClass=\"Success\">\
|
||||
<m:ResponseCode>NoError</m:ResponseCode>\
|
||||
<m:RootFolder TotalItemsInView=\"4\" IncludesLastItemInRange=\"true\">\
|
||||
<t:Items><t:Message><t:ItemId Id=\"C\" ChangeKey=\"1\"/></t:Message>\
|
||||
<t:Message><t:ItemId Id=\"D\" ChangeKey=\"1\"/></t:Message></t:Items></m:RootFolder>\
|
||||
</m:FindItemResponseMessage></m:ResponseMessages></m:FindItemResponse>"
|
||||
));
|
||||
|
||||
let _m1 = server
|
||||
.mock("POST", "/EWS/Exchange.asmx")
|
||||
.match_body(Matcher::Regex("Offset=\"0\"".into()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", TXT_XML)
|
||||
.with_body(page1)
|
||||
.create();
|
||||
let _m2 = server
|
||||
.mock("POST", "/EWS/Exchange.asmx")
|
||||
.match_body(Matcher::Regex("Offset=\"2\"".into()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", TXT_XML)
|
||||
.with_body(page2)
|
||||
.create();
|
||||
|
||||
let c = client(0);
|
||||
let folder = FolderId::new("FID", "FCK");
|
||||
let body0 = find_item_body(FolderRef::Concrete(&folder), Traversal::Shallow, 0, 2);
|
||||
let r0 = c.call(&url, "FindItem", &body0).unwrap();
|
||||
let parsed0 = parse_find_item_response(&r0.body).unwrap();
|
||||
assert_eq!(parsed0.items.len(), 2);
|
||||
assert!(parsed0.more);
|
||||
|
||||
let body1 = find_item_body(FolderRef::Concrete(&folder), Traversal::Shallow, 2, 2);
|
||||
let r1 = c.call(&url, "FindItem", &body1).unwrap();
|
||||
let parsed1 = parse_find_item_response(&r1.body).unwrap();
|
||||
assert_eq!(parsed1.items.len(), 2);
|
||||
assert!(!parsed1.more);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_item_mixed_success_and_per_item_error() {
|
||||
let body = envelope(&format!(
|
||||
"<m:GetItemResponse{NS}><m:ResponseMessages>\
|
||||
<m:GetItemResponseMessage ResponseClass=\"Success\">\
|
||||
<m:ResponseCode>NoError</m:ResponseCode>\
|
||||
<m:Items><t:Message><t:ItemId Id=\"OK1\" ChangeKey=\"K\"/>\
|
||||
<t:Subject>S</t:Subject>\
|
||||
<t:MimeContent CharacterSet=\"UTF-8\">SGVsbG8=</t:MimeContent>\
|
||||
</t:Message></m:Items></m:GetItemResponseMessage>\
|
||||
<m:GetItemResponseMessage ResponseClass=\"Error\">\
|
||||
<m:ResponseCode>ErrorItemNotFound</m:ResponseCode>\
|
||||
<m:MessageText>not found</m:MessageText>\
|
||||
</m:GetItemResponseMessage>\
|
||||
</m:ResponseMessages></m:GetItemResponse>"
|
||||
));
|
||||
let r = parse_response_messages(body.as_bytes(), b"GetItemResponseMessage").unwrap();
|
||||
assert_eq!(r.len(), 2);
|
||||
assert!(r[0].success);
|
||||
assert!(r[0].inner_xml.contains("SGVsbG8="));
|
||||
assert!(!r[1].success);
|
||||
assert!(matches!(r[1].response_code, ResponseCode::ItemNotFound));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_busy_with_back_off_triggers_retry_and_eventually_succeeds() {
|
||||
let mut server = mockito::Server::new();
|
||||
let url = format!("{}/EWS/Exchange.asmx", server.url());
|
||||
let busy_body = format!(
|
||||
"<soap:Envelope{NS}><soap:Header><t:ServerVersionInfo MajorVersion=\"15\" MinorVersion=\"1\"/></soap:Header>\
|
||||
<soap:Body><soap:Fault><faultcode>soap:Server</faultcode><faultstring>busy</faultstring><detail>\
|
||||
<ResponseCode xmlns=\"http://schemas.microsoft.com/exchange/services/2006/types\">ErrorServerBusy</ResponseCode>\
|
||||
<t:MessageXml><t:Value Name=\"BackOffMilliseconds\">200</t:Value></t:MessageXml>\
|
||||
</detail></soap:Fault></soap:Body></soap:Envelope>"
|
||||
);
|
||||
let ok_body = envelope(&format!(
|
||||
"<m:FindFolderResponse{NS}><m:ResponseMessages><m:FindFolderResponseMessage ResponseClass=\"Success\">\
|
||||
<m:ResponseCode>NoError</m:ResponseCode>\
|
||||
<m:RootFolder TotalItemsInView=\"0\" IncludesLastItemInRange=\"true\"><t:Folders/></m:RootFolder>\
|
||||
</m:FindFolderResponseMessage></m:ResponseMessages></m:FindFolderResponse>"
|
||||
));
|
||||
|
||||
let _m1 = server
|
||||
.mock("POST", "/EWS/Exchange.asmx")
|
||||
.with_status(200)
|
||||
.with_header("content-type", TXT_XML)
|
||||
.with_body(busy_body)
|
||||
.expect(1)
|
||||
.create();
|
||||
let _m2 = server
|
||||
.mock("POST", "/EWS/Exchange.asmx")
|
||||
.with_status(200)
|
||||
.with_header("content-type", TXT_XML)
|
||||
.with_body(ok_body)
|
||||
.expect_at_least(1)
|
||||
.create();
|
||||
|
||||
let c = client(3);
|
||||
let body = find_folder_body(
|
||||
FolderRef::Distinguished(
|
||||
vandelay::exchange_ews::types::DistinguishedFolderId::MsgFolderRoot,
|
||||
),
|
||||
Traversal::Deep,
|
||||
);
|
||||
let r = c
|
||||
.call(&url, "FindFolder", &body)
|
||||
.expect("call should retry past busy fault");
|
||||
let parsed = parse_find_folder_response(&r.body).unwrap();
|
||||
assert!(parsed.folders.is_empty());
|
||||
assert!(c.retries_observed() >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_401_surfaces_as_auth_error() {
|
||||
let mut server = mockito::Server::new();
|
||||
let url = format!("{}/EWS/Exchange.asmx", server.url());
|
||||
let _m = server
|
||||
.mock("POST", "/EWS/Exchange.asmx")
|
||||
.with_status(401)
|
||||
.with_header("www-authenticate", "Bearer error=\"invalid_token\"")
|
||||
.with_body("unauthorized")
|
||||
.create();
|
||||
let c = client(0);
|
||||
let body = find_folder_body(
|
||||
FolderRef::Distinguished(
|
||||
vandelay::exchange_ews::types::DistinguishedFolderId::MsgFolderRoot,
|
||||
),
|
||||
Traversal::Deep,
|
||||
);
|
||||
let err = c.call(&url, "FindFolder", &body).unwrap_err();
|
||||
assert!(matches!(err, EwsError::Auth(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mime_content_round_trips_through_base64_decode() {
|
||||
let original = b"From: alice@x\r\nSubject: hi\r\n\r\nbody";
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(original);
|
||||
let body = envelope(&format!(
|
||||
"<m:GetItemResponse{NS}><m:ResponseMessages>\
|
||||
<m:GetItemResponseMessage ResponseClass=\"Success\">\
|
||||
<m:ResponseCode>NoError</m:ResponseCode>\
|
||||
<m:Items><t:Message><t:ItemId Id=\"X\" ChangeKey=\"K\"/>\
|
||||
<t:Subject>hi</t:Subject>\
|
||||
<t:MimeContent CharacterSet=\"UTF-8\">{encoded}</t:MimeContent>\
|
||||
</t:Message></m:Items></m:GetItemResponseMessage>\
|
||||
</m:ResponseMessages></m:GetItemResponse>"
|
||||
));
|
||||
let r = parse_response_messages(body.as_bytes(), b"GetItemResponseMessage").unwrap();
|
||||
let item = vandelay::exchange_ews::parse::parse_message_item(&r[0].inner_xml).unwrap();
|
||||
let s = item.mime_content.unwrap();
|
||||
let cleaned: String = s.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(cleaned.as_bytes())
|
||||
.unwrap();
|
||||
assert_eq!(bytes, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_attachment_inline_decodes_photo_blob() {
|
||||
let body = envelope(&format!(
|
||||
"<m:GetAttachmentResponse{NS}><m:ResponseMessages>\
|
||||
<m:GetAttachmentResponseMessage ResponseClass=\"Success\">\
|
||||
<m:ResponseCode>NoError</m:ResponseCode>\
|
||||
<m:Attachments>\
|
||||
<t:FileAttachment><t:AttachmentId Id=\"A1\"/><t:Name>p.png</t:Name>\
|
||||
<t:ContentType>image/png</t:ContentType>\
|
||||
<t:IsContactPhoto>true</t:IsContactPhoto>\
|
||||
<t:Content>iVBORw0KGgo=</t:Content></t:FileAttachment>\
|
||||
</m:Attachments></m:GetAttachmentResponseMessage></m:ResponseMessages></m:GetAttachmentResponse>"
|
||||
));
|
||||
let items = parse_get_attachment_inline(body.as_bytes()).unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert!(items[0].is_contact_photo);
|
||||
assert_eq!(items[0].content_base64, "iVBORw0KGgo=");
|
||||
assert_eq!(items[0].content_type.as_deref(), Some("image/png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_item_master_inlines_modified_and_deleted_occurrences() {
|
||||
let body = "<vandelay-inner xmlns:t=\"http://schemas.microsoft.com/exchange/services/2006/types\">\
|
||||
<t:CalendarItem>\
|
||||
<t:ItemId Id=\"M1\" ChangeKey=\"K1\"/>\
|
||||
<t:Subject>Daily</t:Subject>\
|
||||
<t:UID>uid-1</t:UID>\
|
||||
<t:Start>2025-06-15T14:00:00Z</t:Start>\
|
||||
<t:End>2025-06-15T15:00:00Z</t:End>\
|
||||
<t:CalendarItemType>RecurringMaster</t:CalendarItemType>\
|
||||
<t:Recurrence>\
|
||||
<t:DailyRecurrence><t:Interval>1</t:Interval></t:DailyRecurrence>\
|
||||
<t:NumberedRecurrence><t:StartDate>2025-06-15</t:StartDate><t:NumberOfOccurrences>3</t:NumberOfOccurrences></t:NumberedRecurrence>\
|
||||
</t:Recurrence>\
|
||||
<t:ModifiedOccurrences>\
|
||||
<t:Occurrence><t:ItemId Id=\"OCC1\"/><t:Start>2025-06-16T15:00:00Z</t:Start><t:End>2025-06-16T16:30:00Z</t:End><t:OriginalStart>2025-06-16T14:00:00Z</t:OriginalStart></t:Occurrence>\
|
||||
</t:ModifiedOccurrences>\
|
||||
<t:DeletedOccurrences>\
|
||||
<t:DeletedOccurrence><t:Start>2025-06-17T14:00:00Z</t:Start></t:DeletedOccurrence>\
|
||||
</t:DeletedOccurrences>\
|
||||
</t:CalendarItem></vandelay-inner>";
|
||||
let item = vandelay::exchange_ews::parse::parse_calendar_item(body).unwrap();
|
||||
assert_eq!(item.id.id, "M1");
|
||||
assert_eq!(item.uid.as_deref(), Some("uid-1"));
|
||||
assert_eq!(item.modified_occurrences.len(), 1);
|
||||
assert_eq!(
|
||||
item.modified_occurrences[0].item_id.id, "OCC1",
|
||||
"occurrence ItemId must populate the occurrence, not overwrite the master"
|
||||
);
|
||||
assert_eq!(item.deleted_occurrences.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_folder_items_creates_updates_deletes_round_trip() {
|
||||
let body = envelope(&format!(
|
||||
"<m:SyncFolderItemsResponse{NS}><m:ResponseMessages><m:SyncFolderItemsResponseMessage ResponseClass=\"Success\">\
|
||||
<m:ResponseCode>NoError</m:ResponseCode>\
|
||||
<m:SyncState>STATE-1</m:SyncState>\
|
||||
<m:IncludesLastItemInRange>true</m:IncludesLastItemInRange>\
|
||||
<m:Changes>\
|
||||
<t:Create><t:Message><t:ItemId Id=\"N1\" ChangeKey=\"A\"/></t:Message></t:Create>\
|
||||
<t:Create><t:Message><t:ItemId Id=\"N2\" ChangeKey=\"A\"/></t:Message></t:Create>\
|
||||
<t:Update><t:Message><t:ItemId Id=\"U1\" ChangeKey=\"B\"/></t:Message></t:Update>\
|
||||
<t:Delete><t:ItemId Id=\"D1\"/></t:Delete>\
|
||||
<t:ReadFlagChange><t:ItemId Id=\"R1\"/><t:IsRead>true</t:IsRead></t:ReadFlagChange>\
|
||||
</m:Changes></m:SyncFolderItemsResponseMessage></m:ResponseMessages></m:SyncFolderItemsResponse>"
|
||||
));
|
||||
let parsed = parse_sync_folder_items_response(body.as_bytes()).unwrap();
|
||||
assert_eq!(parsed.sync_state, "STATE-1");
|
||||
assert!(!parsed.more);
|
||||
assert_eq!(parsed.changes.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_sync_state_data_fault_is_surfaced() {
|
||||
let body = format!(
|
||||
"<soap:Envelope{NS}><soap:Header><t:ServerVersionInfo MajorVersion=\"15\" MinorVersion=\"1\"/></soap:Header>\
|
||||
<soap:Body><soap:Fault><faultcode>soap:Server</faultcode><faultstring>bad state</faultstring><detail>\
|
||||
<ResponseCode xmlns=\"http://schemas.microsoft.com/exchange/services/2006/types\">ErrorInvalidSyncStateData</ResponseCode>\
|
||||
</detail></soap:Fault></soap:Body></soap:Envelope>"
|
||||
);
|
||||
let env = read_envelope_summary(body.as_bytes()).unwrap();
|
||||
match env {
|
||||
EnvelopeKind::Fault { fault, .. } => {
|
||||
assert!(matches!(
|
||||
fault.response_code,
|
||||
ResponseCode::InvalidSyncStateData
|
||||
));
|
||||
}
|
||||
_ => panic!("expected fault"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coordinator_diff_classifies_new_vanished_changed_unchanged() {
|
||||
use vandelay::db::exchange_ews_ids::ItemRow;
|
||||
use vandelay::sync::import_exchange_ews::items::{EnumeratedItem, diff};
|
||||
|
||||
let server = vec![
|
||||
EnumeratedItem {
|
||||
element: "Message".into(),
|
||||
id: ItemId::new("A", "ck-1"),
|
||||
},
|
||||
EnumeratedItem {
|
||||
element: "Message".into(),
|
||||
id: ItemId::new("B", "ck-2"),
|
||||
},
|
||||
EnumeratedItem {
|
||||
element: "Message".into(),
|
||||
id: ItemId::new("C", "ck-1"),
|
||||
},
|
||||
];
|
||||
let local = vec![
|
||||
ItemRow {
|
||||
item_id: "A".into(),
|
||||
change_key: "ck-1".into(),
|
||||
local_id: 1,
|
||||
},
|
||||
ItemRow {
|
||||
item_id: "B".into(),
|
||||
change_key: "ck-1".into(),
|
||||
local_id: 2,
|
||||
},
|
||||
ItemRow {
|
||||
item_id: "Z".into(),
|
||||
change_key: "ck-9".into(),
|
||||
local_id: 99,
|
||||
},
|
||||
];
|
||||
let plan = diff(&server, &local);
|
||||
assert_eq!(plan.new.len(), 1);
|
||||
assert_eq!(plan.new[0].id, "C");
|
||||
assert_eq!(plan.present_unchanged.len(), 1);
|
||||
assert_eq!(plan.present_changed.len(), 1);
|
||||
assert_eq!(plan.present_changed[0].0.id, "B");
|
||||
assert_eq!(plan.vanished.len(), 1);
|
||||
assert_eq!(plan.vanished[0].0, "Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_change_protection_refuses_different_account() {
|
||||
use rusqlite::Connection;
|
||||
use vandelay::db;
|
||||
use vandelay::db::sources::SourceKey;
|
||||
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
let conn = Connection::open(tmp.path()).unwrap();
|
||||
db::init::apply_schema(&conn).unwrap();
|
||||
let key = SourceKey {
|
||||
kind: "exchange_ews".into(),
|
||||
session_url: "https://outlook.office365.com/EWS/Exchange.asmx".into(),
|
||||
account_id: "[email protected]".into(),
|
||||
};
|
||||
db::sources::upsert_source(&conn, &key, None, "[email protected]").unwrap();
|
||||
let conflict = db::sources::conflicting_source(
|
||||
&conn,
|
||||
"exchange_ews",
|
||||
"https://outlook.office365.com/EWS/Exchange.asmx",
|
||||
"[email protected]",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(conflict.is_some(), "different account_id must conflict");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mailbox_kinds_are_three_separate_sources() {
|
||||
use rusqlite::Connection;
|
||||
use vandelay::db;
|
||||
use vandelay::db::sources::SourceKey;
|
||||
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
let conn = Connection::open(tmp.path()).unwrap();
|
||||
db::init::apply_schema(&conn).unwrap();
|
||||
let primary = SourceKey {
|
||||
kind: "exchange_ews".into(),
|
||||
session_url: "https://x/EWS/Exchange.asmx".into(),
|
||||
account_id: "[email protected]".into(),
|
||||
};
|
||||
let archive = SourceKey {
|
||||
kind: "exchange_ews".into(),
|
||||
session_url: "https://x/EWS/Exchange.asmx".into(),
|
||||
account_id: "[email protected]#archive".into(),
|
||||
};
|
||||
let public = SourceKey {
|
||||
kind: "exchange_ews".into(),
|
||||
session_url: "https://x/EWS/Exchange.asmx".into(),
|
||||
account_id: "[email protected]".into(),
|
||||
};
|
||||
let p = db::sources::upsert_source(&conn, &primary, None, "[email protected]").unwrap();
|
||||
let a = db::sources::upsert_source(&conn, &archive, None, "[email protected]").unwrap();
|
||||
let q = db::sources::upsert_source(&conn, &public, None, "[email protected]").unwrap();
|
||||
assert_ne!(p, a);
|
||||
assert_ne!(p, q);
|
||||
assert_ne!(a, q);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_item_batches_chunk_the_id_list() {
|
||||
|
||||
let ids: Vec<ItemId> = (0..7).map(|i| ItemId::new(format!("I{i}"), "K")).collect();
|
||||
let chunks: Vec<&[ItemId]> = ids.chunks(3).collect();
|
||||
assert_eq!(chunks.len(), 3);
|
||||
for chunk in &chunks {
|
||||
let body = get_item_body(ItemShape::Message, chunk);
|
||||
assert!(body.contains("<m:GetItem>"));
|
||||
for id in *chunk {
|
||||
assert!(body.contains(&format!("Id=\"{}\"", id.id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_folder_items_request_body_carries_state_and_max() {
|
||||
let folder = FolderId::new("FID", "FCK");
|
||||
let body = sync_folder_items_body(&folder, "OPAQUE", 512);
|
||||
assert!(body.contains("<m:SyncState>OPAQUE</m:SyncState>"));
|
||||
assert!(body.contains("<m:MaxChangesReturned>512</m:MaxChangesReturned>"));
|
||||
assert!(body.contains("<m:SyncScope>NormalItems</m:SyncScope>"));
|
||||
assert!(body.contains("<m:SyncFolderId><t:FolderId Id=\"FID\" ChangeKey=\"FCK\"/>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_attachment_body_carries_inline_request() {
|
||||
let body = get_attachment_body(&["A1", "A2"]);
|
||||
assert!(body.contains("<t:AttachmentId Id=\"A1\"/>"));
|
||||
assert!(body.contains("<t:AttachmentId Id=\"A2\"/>"));
|
||||
assert!(body.contains("<t:IncludeMimeContent>true</t:IncludeMimeContent>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_500_with_server_busy_body_is_treated_as_fault_and_retried() {
|
||||
let mut server = mockito::Server::new();
|
||||
let url = format!("{}/EWS/Exchange.asmx", server.url());
|
||||
let busy_body = format!(
|
||||
"<soap:Envelope{NS}><soap:Body><soap:Fault>\
|
||||
<faultcode>soap:Server</faultcode><faultstring>busy</faultstring>\
|
||||
<detail><ResponseCode xmlns=\"http://schemas.microsoft.com/exchange/services/2006/types\">ErrorServerBusy</ResponseCode>\
|
||||
<t:MessageXml><t:Value Name=\"BackOffMilliseconds\">100</t:Value></t:MessageXml>\
|
||||
</detail></soap:Fault></soap:Body></soap:Envelope>"
|
||||
);
|
||||
let ok_body = envelope(&format!(
|
||||
"<m:FindFolderResponse{NS}><m:ResponseMessages><m:FindFolderResponseMessage ResponseClass=\"Success\">\
|
||||
<m:ResponseCode>NoError</m:ResponseCode>\
|
||||
<m:RootFolder TotalItemsInView=\"0\" IncludesLastItemInRange=\"true\"><t:Folders/></m:RootFolder>\
|
||||
</m:FindFolderResponseMessage></m:ResponseMessages></m:FindFolderResponse>"
|
||||
));
|
||||
let _m1 = server
|
||||
.mock("POST", "/EWS/Exchange.asmx")
|
||||
.with_status(200)
|
||||
.with_header("content-type", TXT_XML)
|
||||
.with_body(busy_body)
|
||||
.expect(1)
|
||||
.create();
|
||||
let _m2 = server
|
||||
.mock("POST", "/EWS/Exchange.asmx")
|
||||
.with_status(200)
|
||||
.with_header("content-type", TXT_XML)
|
||||
.with_body(ok_body)
|
||||
.create();
|
||||
|
||||
let c = client(3);
|
||||
let body = find_folder_body(
|
||||
FolderRef::Distinguished(
|
||||
vandelay::exchange_ews::types::DistinguishedFolderId::MsgFolderRoot,
|
||||
),
|
||||
Traversal::Deep,
|
||||
);
|
||||
let r = c.call(&url, "FindFolder", &body).expect("should retry");
|
||||
let parsed = parse_find_folder_response(&r.body).unwrap();
|
||||
assert!(parsed.folders.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_response_class_is_treated_as_success_in_mock() {
|
||||
let body = envelope(&format!(
|
||||
"<m:GetItemResponse{NS}><m:ResponseMessages>\
|
||||
<m:GetItemResponseMessage ResponseClass=\"Warning\">\
|
||||
<m:ResponseCode>NoError</m:ResponseCode>\
|
||||
<m:Items><t:Message><t:ItemId Id=\"W1\" ChangeKey=\"K\"/></t:Message></m:Items>\
|
||||
</m:GetItemResponseMessage></m:ResponseMessages></m:GetItemResponse>"
|
||||
));
|
||||
let r = parse_response_messages(body.as_bytes(), b"GetItemResponseMessage").unwrap();
|
||||
assert!(r[0].success);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_namespace_envelope_parses_via_resolved_names() {
|
||||
let body = "<Envelope xmlns=\"http://schemas.xmlsoap.org/soap/envelope/\" \
|
||||
xmlns:m=\"http://schemas.microsoft.com/exchange/services/2006/messages\" \
|
||||
xmlns:t=\"http://schemas.microsoft.com/exchange/services/2006/types\">\
|
||||
<Body>\
|
||||
<m:FindFolderResponse><m:ResponseMessages>\
|
||||
<m:FindFolderResponseMessage ResponseClass=\"Success\">\
|
||||
<m:ResponseCode>NoError</m:ResponseCode>\
|
||||
<m:RootFolder TotalItemsInView=\"1\" IncludesLastItemInRange=\"true\">\
|
||||
<t:Folders><t:Folder><t:FolderId Id=\"D1\"/><t:DisplayName>Inbox</t:DisplayName>\
|
||||
</t:Folder></t:Folders></m:RootFolder>\
|
||||
</m:FindFolderResponseMessage></m:ResponseMessages></m:FindFolderResponse>\
|
||||
</Body></Envelope>";
|
||||
let parsed = parse_find_folder_response(body.as_bytes()).unwrap();
|
||||
assert_eq!(parsed.folders.len(), 1);
|
||||
assert_eq!(parsed.folders[0].folder_id.id, "D1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_folder_messages_preserve_position_when_one_errors() {
|
||||
let body = envelope(&format!(
|
||||
"<m:GetFolderResponse{NS}><m:ResponseMessages>\
|
||||
<m:GetFolderResponseMessage ResponseClass=\"Success\">\
|
||||
<m:ResponseCode>NoError</m:ResponseCode>\
|
||||
<m:Folders><t:Folder><t:FolderId Id=\"FA\"/><t:DisplayName>Inbox</t:DisplayName></t:Folder></m:Folders>\
|
||||
</m:GetFolderResponseMessage>\
|
||||
<m:GetFolderResponseMessage ResponseClass=\"Error\">\
|
||||
<m:ResponseCode>ErrorAccessDenied</m:ResponseCode>\
|
||||
</m:GetFolderResponseMessage>\
|
||||
<m:GetFolderResponseMessage ResponseClass=\"Success\">\
|
||||
<m:ResponseCode>NoError</m:ResponseCode>\
|
||||
<m:Folders><t:Folder><t:FolderId Id=\"FC\"/><t:DisplayName>Drafts</t:DisplayName></t:Folder></m:Folders>\
|
||||
</m:GetFolderResponseMessage>\
|
||||
</m:ResponseMessages></m:GetFolderResponse>"
|
||||
));
|
||||
let msgs = parse_response_messages(body.as_bytes(), b"GetFolderResponseMessage").unwrap();
|
||||
assert_eq!(msgs.len(), 3, "all three messages must be present");
|
||||
assert!(msgs[0].success);
|
||||
assert!(!msgs[1].success);
|
||||
assert!(msgs[2].success);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+1596
File diff suppressed because it is too large
Load Diff
+1042
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,758 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rusqlite::Connection;
|
||||
use tempfile::TempDir;
|
||||
use vandelay::logging::Logger;
|
||||
use vandelay::sync::CommonConfig;
|
||||
use vandelay::sync::import_maildir::{MaildirImportConfig, run};
|
||||
|
||||
fn tmp_archive(tag: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!(
|
||||
"vandelay-mock-maildir-{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: 1,
|
||||
dry_run: false,
|
||||
max_retries: 1,
|
||||
allow_invalid_certs: false,
|
||||
logger: Logger::from_flags(true, 0),
|
||||
}
|
||||
}
|
||||
|
||||
fn base_cfg(root: &Path) -> MaildirImportConfig {
|
||||
MaildirImportConfig {
|
||||
maildir: root.to_path_buf(),
|
||||
include: Vec::new(),
|
||||
exclude: Vec::new(),
|
||||
folder: Vec::new(),
|
||||
automap: true,
|
||||
include_deleted: false,
|
||||
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 ensure_maildir(root: &Path) {
|
||||
for sub in ["cur", "new", "tmp"] {
|
||||
fs::create_dir_all(root.join(sub)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_subfolder(root: &Path, dotted: &str) -> PathBuf {
|
||||
let sub = root.join(dotted);
|
||||
for s in ["cur", "new", "tmp"] {
|
||||
fs::create_dir_all(sub.join(s)).unwrap();
|
||||
}
|
||||
sub
|
||||
}
|
||||
|
||||
fn write_message(folder: &Path, sub: &str, filename: &str, body: &[u8]) -> PathBuf {
|
||||
let dir = folder.join(sub);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join(filename);
|
||||
fs::write(&p, body).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
fn rfc5322(id: &str, subject: &str, body: &str) -> Vec<u8> {
|
||||
format!(
|
||||
"From: [email protected]\r\nTo: [email protected]\r\n\
|
||||
Subject: {subject}\r\nMessage-ID: <{id}@example.com>\r\n\
|
||||
Date: Mon, 12 May 2025 10:00:00 +0000\r\n\r\n{body}\r\n"
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn folder_mailbox_id(conn: &Connection, canonical: &str) -> Option<i64> {
|
||||
conn.query_row(
|
||||
"SELECT local_id FROM sync_id_maildir
|
||||
WHERE type_name = 'mailbox' AND folder = ?1 AND unique_id = ''",
|
||||
rusqlite::params![canonical],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn emails_for_folder(conn: &Connection, canonical: &str) -> Vec<(String, String)> {
|
||||
let mailbox_id = folder_mailbox_id(conn, canonical).expect("mailbox row");
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT b.hash, e.keywords FROM emails e
|
||||
JOIN blobs b ON b.id = e.blob_id
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM json_each(e.mailbox_ids) WHERE value = ?1
|
||||
)
|
||||
ORDER BY e.id",
|
||||
)
|
||||
.unwrap();
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params![mailbox_id], |r| {
|
||||
let hash: Vec<u8> = r.get(0)?;
|
||||
let kw: String = r.get(1)?;
|
||||
Ok((hex(&hash), kw))
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(s, "{b:02x}");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn dovecot_fixture(root: &Path) -> HashMap<&'static str, Vec<&'static str>> {
|
||||
ensure_maildir(root);
|
||||
|
||||
write_message(
|
||||
root,
|
||||
"cur",
|
||||
"1739471123.M001P00123V0000I0000aaaa.host01,S=128,W=131:2,S",
|
||||
&rfc5322("dovecot-inbox-1", "Welcome", "Welcome to Dovecot"),
|
||||
);
|
||||
write_message(
|
||||
root,
|
||||
"cur",
|
||||
"1739471124.M002P00123V0000I0000aaab.host01,S=140,W=143:2,RS",
|
||||
&rfc5322("dovecot-inbox-2", "Re: Welcome", "Reply body"),
|
||||
);
|
||||
write_message(
|
||||
root,
|
||||
"new",
|
||||
"1739471200.M003P00123V0000I0000aaac.host01,S=64,W=66",
|
||||
&rfc5322("dovecot-inbox-3", "Unread mail", "Body of the unread"),
|
||||
);
|
||||
|
||||
let sent = ensure_subfolder(root, ".Sent");
|
||||
write_message(
|
||||
&sent,
|
||||
"cur",
|
||||
"1739472000.M001P00123V0000I0000bbb1.host01,S=80,W=82:2,S",
|
||||
&rfc5322("dovecot-sent-1", "Sent to friend", "Hi friend"),
|
||||
);
|
||||
|
||||
let drafts = ensure_subfolder(root, ".Drafts");
|
||||
write_message(
|
||||
&drafts,
|
||||
"cur",
|
||||
"1739472100.M002P00123V0000I0000bbb2.host01,S=50,W=52:2,DS",
|
||||
&rfc5322("dovecot-draft-1", "Half-written", "Almost there"),
|
||||
);
|
||||
|
||||
let trash = ensure_subfolder(root, ".Trash");
|
||||
write_message(
|
||||
&trash,
|
||||
"cur",
|
||||
"1739472200.M003P00123V0000I0000bbb3.host01,S=30,W=32:2,ST",
|
||||
&rfc5322("dovecot-trash-1", "Junk", "trashed"),
|
||||
);
|
||||
|
||||
ensure_subfolder(root, ".Archive");
|
||||
let arch25 = ensure_subfolder(root, ".Archive.2025");
|
||||
write_message(
|
||||
&arch25,
|
||||
"cur",
|
||||
"1739473000.M001P00123V0000I0000cccc.host01,S=200,W=204:2,S",
|
||||
&rfc5322("dovecot-arch25-1", "Last year", "Old mail"),
|
||||
);
|
||||
write_message(
|
||||
&arch25,
|
||||
"cur",
|
||||
"1739473001.M002P00123V0000I0000cccd.host01,S=200,W=204:2,SF",
|
||||
&rfc5322("dovecot-arch25-2", "Important", "Flagged"),
|
||||
);
|
||||
|
||||
fs::write(root.join("dovecot.index.log"), b"index").unwrap();
|
||||
fs::write(root.join("dovecot-uidlist"), b"uidlist").unwrap();
|
||||
fs::create_dir_all(root.join(".dovecot.imap")).unwrap();
|
||||
|
||||
fs::write(root.join("cur/.uidvalidity"), b"sidecar").unwrap();
|
||||
|
||||
let mut expected: HashMap<&'static str, Vec<&'static str>> = HashMap::new();
|
||||
expected.insert("INBOX", vec![]);
|
||||
expected.insert("Sent", vec![]);
|
||||
expected.insert("Drafts", vec![]);
|
||||
expected.insert("Trash", vec![]);
|
||||
expected.insert("Archive", vec![]);
|
||||
expected.insert("Archive.2025", vec![]);
|
||||
expected
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dovecot_layout_imports_every_folder_with_correct_roles() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let expected = dovecot_fixture(td.path());
|
||||
let archive = tmp_archive("dovecot");
|
||||
let summary = run(common(&archive), base_cfg(td.path())).expect("import");
|
||||
assert!(!summary.any_failed(), "import failed: {summary:?}");
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
for name in expected.keys() {
|
||||
assert!(
|
||||
folder_mailbox_id(&conn, name).is_some(),
|
||||
"missing mailbox row for {name:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let inbox = emails_for_folder(&conn, "INBOX");
|
||||
assert_eq!(inbox.len(), 3, "3 INBOX messages expected");
|
||||
let seen_count = inbox.iter().filter(|(_, kw)| kw.contains("$seen")).count();
|
||||
assert_eq!(seen_count, 2, "two cur/ msgs are $seen");
|
||||
|
||||
let sent = emails_for_folder(&conn, "Sent");
|
||||
assert_eq!(sent.len(), 1);
|
||||
|
||||
let trash = emails_for_folder(&conn, "Trash");
|
||||
assert!(trash.is_empty(), "Trashed message skipped by default");
|
||||
|
||||
let arch_2025 = emails_for_folder(&conn, "Archive.2025");
|
||||
assert_eq!(arch_2025.len(), 2);
|
||||
let flagged = arch_2025.iter().any(|(_, kw)| kw.contains("$flagged"));
|
||||
assert!(flagged, "one Archive.2025 message has $flagged");
|
||||
|
||||
let archive_parent_id = folder_mailbox_id(&conn, "Archive").unwrap();
|
||||
let arch_2025_parent: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT parent_id FROM mailboxes WHERE name = '2025'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(arch_2025_parent, Some(archive_parent_id));
|
||||
|
||||
let _ = fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dovecot_include_deleted_keeps_trash_message() {
|
||||
let td = TempDir::new().unwrap();
|
||||
dovecot_fixture(td.path());
|
||||
let archive = tmp_archive("dovecot-include-deleted");
|
||||
let mut cfg = base_cfg(td.path());
|
||||
cfg.include_deleted = true;
|
||||
let summary = run(common(&archive), cfg).expect("import");
|
||||
assert!(!summary.any_failed());
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
let trash = emails_for_folder(&conn, "Trash");
|
||||
assert_eq!(trash.len(), 1);
|
||||
assert!(trash[0].1.contains("$deleted"));
|
||||
let _ = fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
fn courier_fixture(root: &Path) {
|
||||
ensure_maildir(root);
|
||||
write_message(
|
||||
root,
|
||||
"cur",
|
||||
"1234567890.12345_0.mail.example:2,S",
|
||||
&rfc5322("courier-1", "Hi", "Hello from Courier"),
|
||||
);
|
||||
write_message(
|
||||
root,
|
||||
"new",
|
||||
"1234567891.12345_1.mail.example",
|
||||
&rfc5322("courier-2", "Unread", "New body"),
|
||||
);
|
||||
|
||||
let sent = ensure_subfolder(root, ".Sent");
|
||||
write_message(
|
||||
&sent,
|
||||
"cur",
|
||||
"1234567900.12350_0.mail.example:2,S",
|
||||
&rfc5322("courier-sent-1", "Sent reply", "Bye"),
|
||||
);
|
||||
|
||||
let lists = ensure_subfolder(root, ".Lists.maildir-dev");
|
||||
write_message(
|
||||
&lists,
|
||||
"cur",
|
||||
"1234568000.12400_0.mail.example:2,RS",
|
||||
&rfc5322("courier-list-1", "[Maildir-dev] thread", "list body"),
|
||||
);
|
||||
|
||||
fs::write(root.join("courierimapsubscribed"), b"INBOX.Sent\n").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn courier_layout_with_orphan_parent_creates_ephemeral_lists_row() {
|
||||
let td = TempDir::new().unwrap();
|
||||
courier_fixture(td.path());
|
||||
let archive = tmp_archive("courier");
|
||||
let summary = run(common(&archive), base_cfg(td.path())).expect("import");
|
||||
assert!(!summary.any_failed(), "{summary:?}");
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert!(folder_mailbox_id(&conn, "INBOX").is_some());
|
||||
assert!(folder_mailbox_id(&conn, "Sent").is_some());
|
||||
let lists_id = folder_mailbox_id(&conn, "Lists").expect("ephemeral Lists row created");
|
||||
let leaf_id = folder_mailbox_id(&conn, "Lists.maildir-dev").expect("Lists.maildir-dev row");
|
||||
let leaf_parent: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT parent_id FROM mailboxes WHERE id = ?1",
|
||||
rusqlite::params![leaf_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(leaf_parent, Some(lists_id));
|
||||
|
||||
let role: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT role FROM mailboxes WHERE id = ?1",
|
||||
rusqlite::params![lists_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(role, None);
|
||||
|
||||
assert_eq!(emails_for_folder(&conn, "INBOX").len(), 2);
|
||||
assert_eq!(emails_for_folder(&conn, "Sent").len(), 1);
|
||||
assert_eq!(emails_for_folder(&conn, "Lists.maildir-dev").len(), 1);
|
||||
let _ = fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
fn qmail_fixture(root: &Path) {
|
||||
ensure_maildir(root);
|
||||
|
||||
write_message(
|
||||
root,
|
||||
"new",
|
||||
"1700000000.12345.box01",
|
||||
&rfc5322("qmail-1", "qmail one", "body 1"),
|
||||
);
|
||||
write_message(
|
||||
root,
|
||||
"new",
|
||||
"1700000001.12346.box01",
|
||||
&rfc5322("qmail-2", "qmail two", "body 2"),
|
||||
);
|
||||
|
||||
write_message(
|
||||
root,
|
||||
"cur",
|
||||
"1700000002.12347.box01:2,S",
|
||||
&rfc5322("qmail-3", "Seen", "body 3"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qmail_root_only_maildir_imports_inbox_only() {
|
||||
let td = TempDir::new().unwrap();
|
||||
qmail_fixture(td.path());
|
||||
let archive = tmp_archive("qmail");
|
||||
let summary = run(common(&archive), base_cfg(td.path())).expect("import");
|
||||
assert!(!summary.any_failed());
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
let mboxes = count(&conn, "mailboxes");
|
||||
assert_eq!(mboxes, 1, "INBOX only");
|
||||
let inbox = emails_for_folder(&conn, "INBOX");
|
||||
assert_eq!(inbox.len(), 3);
|
||||
let seen = inbox.iter().filter(|(_, kw)| kw.contains("$seen")).count();
|
||||
assert_eq!(seen, 1, "only the cur/ message has $seen");
|
||||
let _ = fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
fn cyrus_export_fixture(root: &Path) {
|
||||
|
||||
ensure_maildir(root);
|
||||
write_message(
|
||||
root,
|
||||
"cur",
|
||||
"1701000000.X1.cyrus-export:2,S",
|
||||
&rfc5322("cyrus-1", "Cyrus mail 1", "exported"),
|
||||
);
|
||||
write_message(
|
||||
root,
|
||||
"cur",
|
||||
"1701000001.X2.cyrus-export:2,",
|
||||
&rfc5322("cyrus-2", "Cyrus mail 2", "exported"),
|
||||
);
|
||||
|
||||
let sent = ensure_subfolder(root, ".Sent");
|
||||
write_message(
|
||||
&sent,
|
||||
"cur",
|
||||
"1701000100.X3.cyrus-export:2,S",
|
||||
&rfc5322("cyrus-sent-1", "Sent from Cyrus", "exported sent"),
|
||||
);
|
||||
|
||||
let arch = ensure_subfolder(root, ".Archive");
|
||||
write_message(
|
||||
&arch,
|
||||
"cur",
|
||||
"1701000200.X4.cyrus-export:2,S",
|
||||
&rfc5322("cyrus-arch-1", "Archived", "old"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyrus_style_export_imports_cleanly() {
|
||||
let td = TempDir::new().unwrap();
|
||||
cyrus_export_fixture(td.path());
|
||||
let archive = tmp_archive("cyrus");
|
||||
let summary = run(common(&archive), base_cfg(td.path())).expect("import");
|
||||
assert!(!summary.any_failed());
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert_eq!(emails_for_folder(&conn, "INBOX").len(), 2);
|
||||
assert_eq!(emails_for_folder(&conn, "Sent").len(), 1);
|
||||
assert_eq!(emails_for_folder(&conn, "Archive").len(), 1);
|
||||
let sent_role: Option<String> = conn
|
||||
.query_row("SELECT role FROM mailboxes WHERE name = 'Sent'", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(sent_role.as_deref(), Some("sent"));
|
||||
let _ = fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_filter_keeps_only_matching_folders() {
|
||||
let td = TempDir::new().unwrap();
|
||||
dovecot_fixture(td.path());
|
||||
let archive = tmp_archive("filter-include");
|
||||
let mut cfg = base_cfg(td.path());
|
||||
cfg.include = vec![regex::Regex::new(r"^(INBOX|Sent)$").unwrap()];
|
||||
let summary = run(common(&archive), cfg).expect("import");
|
||||
assert!(!summary.any_failed());
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert!(folder_mailbox_id(&conn, "INBOX").is_some());
|
||||
assert!(folder_mailbox_id(&conn, "Sent").is_some());
|
||||
assert!(folder_mailbox_id(&conn, "Trash").is_none());
|
||||
assert!(folder_mailbox_id(&conn, "Archive.2025").is_none());
|
||||
let _ = fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclude_filter_drops_matching_folders() {
|
||||
let td = TempDir::new().unwrap();
|
||||
dovecot_fixture(td.path());
|
||||
let archive = tmp_archive("filter-exclude");
|
||||
let mut cfg = base_cfg(td.path());
|
||||
cfg.exclude = vec![regex::Regex::new(r"^Trash$").unwrap()];
|
||||
let summary = run(common(&archive), cfg).expect("import");
|
||||
assert!(!summary.any_failed());
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert!(folder_mailbox_id(&conn, "Trash").is_none());
|
||||
assert!(folder_mailbox_id(&conn, "Sent").is_some());
|
||||
let _ = fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_folder_overrides_include_exclude() {
|
||||
let td = TempDir::new().unwrap();
|
||||
dovecot_fixture(td.path());
|
||||
let archive = tmp_archive("filter-folder");
|
||||
let mut cfg = base_cfg(td.path());
|
||||
cfg.folder = vec!["Sent".to_owned()];
|
||||
let summary = run(common(&archive), cfg).expect("import");
|
||||
assert!(!summary.any_failed());
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
let mbox_count = count(&conn, "mailboxes");
|
||||
assert_eq!(mbox_count, 1);
|
||||
assert!(folder_mailbox_id(&conn, "Sent").is_some());
|
||||
let _ = fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noautomap_drops_roles_except_inbox() {
|
||||
let td = TempDir::new().unwrap();
|
||||
dovecot_fixture(td.path());
|
||||
let archive = tmp_archive("noautomap");
|
||||
let mut cfg = base_cfg(td.path());
|
||||
cfg.automap = false;
|
||||
let summary = run(common(&archive), cfg).expect("import");
|
||||
assert!(!summary.any_failed());
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
let sent_role: Option<String> = conn
|
||||
.query_row("SELECT role FROM mailboxes WHERE name = 'Sent'", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(sent_role.is_none(), "automap off -> Sent has no role");
|
||||
let inbox_role: Option<String> = conn
|
||||
.query_row("SELECT role FROM mailboxes WHERE name = 'INBOX'", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(inbox_role.as_deref(), Some("inbox"));
|
||||
let _ = fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_reports_diff_without_writing_emails() {
|
||||
let td = TempDir::new().unwrap();
|
||||
dovecot_fixture(td.path());
|
||||
let archive = tmp_archive("dry-run");
|
||||
let mut common_cfg = common(&archive);
|
||||
common_cfg.dry_run = true;
|
||||
let summary = run(common_cfg, base_cfg(td.path())).expect("dryrun");
|
||||
let (mailbox_label, mailbox_counts) = &summary.per_type[0];
|
||||
let (email_label, email_counts) = &summary.per_type[1];
|
||||
assert_eq!(*mailbox_label, "mailbox");
|
||||
assert_eq!(*email_label, "email");
|
||||
assert!(mailbox_counts.created >= 6);
|
||||
assert!(email_counts.created >= 6);
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert_eq!(count(&conn, "emails"), 0, "dry-run wrote no emails");
|
||||
assert_eq!(count(&conn, "mailboxes"), 0, "dry-run wrote no mailboxes");
|
||||
let _ = fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_path_without_cur_subdir() {
|
||||
let td = TempDir::new().unwrap();
|
||||
fs::create_dir_all(td.path().join("new")).unwrap();
|
||||
let archive = tmp_archive("not-a-maildir");
|
||||
let err = run(common(&archive), base_cfg(td.path())).expect_err("should refuse");
|
||||
assert!(matches!(err, vandelay::error::Error::Usage(_)));
|
||||
let _ = fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_dovecot_layout_fs_tree() {
|
||||
|
||||
let td = TempDir::new().unwrap();
|
||||
ensure_maildir(td.path());
|
||||
for s in ["cur", "new", "tmp"] {
|
||||
fs::create_dir_all(td.path().join("Sent").join(s)).unwrap();
|
||||
}
|
||||
let archive = tmp_archive("layout-fs");
|
||||
let err = run(common(&archive), base_cfg(td.path())).expect_err("should refuse");
|
||||
match err {
|
||||
vandelay::error::Error::Usage(msg) => {
|
||||
assert!(msg.contains("Maildir++"), "msg was: {msg}");
|
||||
}
|
||||
other => panic!("expected Usage error, got {other:?}"),
|
||||
}
|
||||
let _ = fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_nonexistent_path() {
|
||||
let archive = tmp_archive("nonexistent");
|
||||
let mut cfg = base_cfg(Path::new("/definitely/not/a/real/maildir"));
|
||||
cfg.maildir = PathBuf::from("/definitely/not/a/real/maildir");
|
||||
let err = run(common(&archive), cfg).expect_err("should refuse");
|
||||
assert!(matches!(err, vandelay::error::Error::Usage(_)));
|
||||
let _ = fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_change_protection_refuses_second_path() {
|
||||
let td_a = TempDir::new().unwrap();
|
||||
dovecot_fixture(td_a.path());
|
||||
let td_b = TempDir::new().unwrap();
|
||||
qmail_fixture(td_b.path());
|
||||
|
||||
let archive = tmp_archive("source-change");
|
||||
run(common(&archive), base_cfg(td_a.path())).expect("first import");
|
||||
let err = run(common(&archive), base_cfg(td_b.path())).expect_err("second import refused");
|
||||
assert!(matches!(err, vandelay::error::Error::SourceChange(_)));
|
||||
let _ = fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_source_change_unlocks_second_path() {
|
||||
let td_a = TempDir::new().unwrap();
|
||||
dovecot_fixture(td_a.path());
|
||||
let td_b = TempDir::new().unwrap();
|
||||
qmail_fixture(td_b.path());
|
||||
|
||||
let archive = tmp_archive("allow-source-change");
|
||||
run(common(&archive), base_cfg(td_a.path())).expect("first import");
|
||||
let mut second = base_cfg(td_b.path());
|
||||
second.allow_source_change = true;
|
||||
run(common(&archive), second).expect("second import permitted");
|
||||
let _ = fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_and_include_are_mutually_exclusive() {
|
||||
let td = TempDir::new().unwrap();
|
||||
ensure_maildir(td.path());
|
||||
let archive = tmp_archive("mutex-folder-include");
|
||||
let mut cfg = base_cfg(td.path());
|
||||
cfg.folder = vec!["INBOX".to_owned()];
|
||||
cfg.include = vec![regex::Regex::new("^Sent$").unwrap()];
|
||||
let err = run(common(&archive), cfg).expect_err("mutex");
|
||||
match err {
|
||||
vandelay::error::Error::Usage(msg) => {
|
||||
assert!(
|
||||
msg.contains("--folder") && msg.contains("--include"),
|
||||
"msg was: {msg}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Usage, got {other:?}"),
|
||||
}
|
||||
let _ = fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_and_exclude_are_mutually_exclusive() {
|
||||
let td = TempDir::new().unwrap();
|
||||
ensure_maildir(td.path());
|
||||
let archive = tmp_archive("mutex-folder-exclude");
|
||||
let mut cfg = base_cfg(td.path());
|
||||
cfg.folder = vec!["INBOX".to_owned()];
|
||||
cfg.exclude = vec![regex::Regex::new("^Trash$").unwrap()];
|
||||
let err = run(common(&archive), cfg).expect_err("mutex");
|
||||
assert!(matches!(err, vandelay::error::Error::Usage(_)));
|
||||
let _ = fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_level_folder_delete_is_leaf_first() {
|
||||
let td = TempDir::new().unwrap();
|
||||
ensure_maildir(td.path());
|
||||
ensure_subfolder(td.path(), ".A");
|
||||
ensure_subfolder(td.path(), ".A.B");
|
||||
let leaf = ensure_subfolder(td.path(), ".A.B.C");
|
||||
write_message(
|
||||
&leaf,
|
||||
"cur",
|
||||
"1.M0.host:2,S",
|
||||
&rfc5322("3lvl-1", "x", "body"),
|
||||
);
|
||||
let archive = tmp_archive("three-level-delete");
|
||||
run(common(&archive), base_cfg(td.path())).expect("first import");
|
||||
|
||||
fs::remove_dir_all(td.path().join(".A.B.C")).unwrap();
|
||||
fs::remove_dir_all(td.path().join(".A.B")).unwrap();
|
||||
let summary = run(common(&archive), base_cfg(td.path())).expect("second import");
|
||||
assert!(!summary.any_failed(), "{summary:?}");
|
||||
let (_, mbox) = &summary.per_type[0];
|
||||
let (_, email) = &summary.per_type[1];
|
||||
assert_eq!(mbox.deleted, 2, "B and B.C deleted leaf-first");
|
||||
assert_eq!(email.deleted, 1);
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert!(folder_mailbox_id(&conn, "A").is_some());
|
||||
assert!(folder_mailbox_id(&conn, "A.B").is_none());
|
||||
assert!(folder_mailbox_id(&conn, "A.B.C").is_none());
|
||||
let _ = fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_unique_id_in_two_folders_produces_two_rows_one_blob() {
|
||||
let td = TempDir::new().unwrap();
|
||||
ensure_maildir(td.path());
|
||||
let sent = ensure_subfolder(td.path(), ".Sent");
|
||||
let body = rfc5322("dup-1", "shared", "shared body");
|
||||
write_message(td.path(), "cur", "1.M0.host:2,S", &body);
|
||||
write_message(&sent, "cur", "1.M0.host:2,S", &body);
|
||||
let archive = tmp_archive("cross-folder-dup");
|
||||
let summary = run(common(&archive), base_cfg(td.path())).expect("import");
|
||||
assert!(!summary.any_failed(), "{summary:?}");
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert_eq!(count(&conn, "blobs"), 1, "BLAKE3 dedup");
|
||||
assert_eq!(count(&conn, "emails"), 2, "one row per folder");
|
||||
let inbox = emails_for_folder(&conn, "INBOX");
|
||||
let sent_rows = emails_for_folder(&conn, "Sent");
|
||||
assert_eq!(inbox.len(), 1);
|
||||
assert_eq!(sent_rows.len(), 1);
|
||||
|
||||
assert_eq!(inbox[0].0, sent_rows[0].0);
|
||||
let _ = fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unreadable_file_is_counted_and_warned_not_aborted() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let td = TempDir::new().unwrap();
|
||||
ensure_maildir(td.path());
|
||||
write_message(
|
||||
td.path(),
|
||||
"cur",
|
||||
"1.M0.host:2,S",
|
||||
&rfc5322("ok-1", "ok", "ok"),
|
||||
);
|
||||
let bad = write_message(
|
||||
td.path(),
|
||||
"cur",
|
||||
"2.M0.host:2,S",
|
||||
&rfc5322("bad-1", "denied", "denied"),
|
||||
);
|
||||
let mut perms = fs::metadata(&bad).unwrap().permissions();
|
||||
perms.set_mode(0o000);
|
||||
fs::set_permissions(&bad, perms).unwrap();
|
||||
|
||||
let archive = tmp_archive("unreadable");
|
||||
let summary = run(common(&archive), base_cfg(td.path())).expect("not aborted");
|
||||
|
||||
let mut perms = fs::metadata(&bad).unwrap().permissions();
|
||||
perms.set_mode(0o600);
|
||||
fs::set_permissions(&bad, perms).unwrap();
|
||||
|
||||
let (_, email) = &summary.per_type[1];
|
||||
assert!(summary.any_failed(), "exit-5 worthy");
|
||||
assert_eq!(email.failed, 1, "the bad file is counted");
|
||||
assert_eq!(email.created, 1, "the readable file imports");
|
||||
let _ = fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointing_at_a_dot_subfolder_warns_but_imports() {
|
||||
|
||||
let parent = TempDir::new().unwrap();
|
||||
ensure_maildir(parent.path());
|
||||
let sub = ensure_subfolder(parent.path(), ".Sent");
|
||||
write_message(&sub, "cur", "1.M0.host:2,S", &rfc5322("sub-1", "x", "x"));
|
||||
|
||||
let archive = tmp_archive("dot-subfolder-as-root");
|
||||
let summary = run(common(&archive), base_cfg(&sub)).expect("import proceeds");
|
||||
assert!(!summary.any_failed(), "{summary:?}");
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
|
||||
assert!(folder_mailbox_id(&conn, "INBOX").is_some());
|
||||
assert_eq!(emails_for_folder(&conn, "INBOX").len(), 1);
|
||||
let _ = fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_message_yields_zero_message_match_but_imports() {
|
||||
|
||||
let td = TempDir::new().unwrap();
|
||||
ensure_maildir(td.path());
|
||||
write_message(td.path(), "cur", "1.M0.host:2,S", b"");
|
||||
write_message(
|
||||
td.path(),
|
||||
"cur",
|
||||
"2.M0.host:2,S",
|
||||
b"X-Garbage: yes\r\n\r\nno headers",
|
||||
);
|
||||
let archive = tmp_archive("malformed");
|
||||
let summary = run(common(&archive), base_cfg(td.path())).expect("import");
|
||||
assert!(!summary.any_failed(), "{summary:?}");
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
let inbox = emails_for_folder(&conn, "INBOX");
|
||||
assert_eq!(inbox.len(), 2);
|
||||
let _ = fs::remove_file(&archive);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+1431
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
mod integration;
|
||||
mod seeder;
|
||||
|
||||
use integration::stalwart::shared as shared_stalwart;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn provision_no_teardown() -> seeder::error::SeedResult<()> {
|
||||
let stalwart = shared_stalwart();
|
||||
let fx = seeder::provision(stalwart.base_url())?;
|
||||
println!(
|
||||
"provisioned domain={} (id={}) base_url={}",
|
||||
fx.domain, fx.domain_id, fx.base_url
|
||||
);
|
||||
println!("admin login: {} / {}", fx.admin_login.0, fx.admin_login.1);
|
||||
for local in seeder::SYNC_IN {
|
||||
if let Some(acc) = fx.account(local) {
|
||||
let seeded = acc
|
||||
.seeded
|
||||
.as_ref()
|
||||
.map(|s| {
|
||||
format!(
|
||||
"mailboxes_created={} emails={} contacts={} events={} \
|
||||
address_books={} calendars={} file_nodes={} \
|
||||
sieve_active={:?} identity={}",
|
||||
s.mailboxes_created,
|
||||
s.emails,
|
||||
s.contacts,
|
||||
s.events,
|
||||
s.address_books,
|
||||
s.calendars,
|
||||
s.file_nodes,
|
||||
s.sieve_active,
|
||||
s.identity
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| "(no seed stats)".to_owned());
|
||||
println!(
|
||||
"{}: email={} password={} account_id={} role={:?} | {seeded}",
|
||||
acc.localpart, acc.email, acc.password, acc.account_id, acc.admin_role
|
||||
);
|
||||
}
|
||||
}
|
||||
println!("(no teardown; container is torn down when the test binary exits)");
|
||||
let _references_teardown: fn(&str) -> seeder::error::SeedResult<()> = seeder::teardown;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
mod integration;
|
||||
mod seeder;
|
||||
|
||||
use integration::stalwart::shared as shared_stalwart;
|
||||
use seeder::jmap::Jmap;
|
||||
use seeder::seed::SeedStats;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const CORE: &str = "urn:ietf:params:jmap:core";
|
||||
const MAIL: &str = "urn:ietf:params:jmap:mail";
|
||||
const SIEVE: &str = "urn:ietf:params:jmap:sieve";
|
||||
const CONTACTS: &str = "urn:ietf:params:jmap:contacts";
|
||||
const CALENDARS: &str = "urn:ietf:params:jmap:calendars";
|
||||
const FILENODE: &str = "urn:ietf:params:jmap:filenode";
|
||||
const PRINCIPALS: &str = "urn:ietf:params:jmap:principals";
|
||||
|
||||
const DEFAULT_MAILBOXES: usize = 5;
|
||||
|
||||
fn list_len(v: &Value) -> usize {
|
||||
v.get("list")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
fn ids_len(v: &Value) -> usize {
|
||||
v.get("ids")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
fn bare() -> SeedStats {
|
||||
SeedStats {
|
||||
mailboxes_created: 0,
|
||||
emails: 0,
|
||||
contacts: 0,
|
||||
events: 0,
|
||||
sieve_active: None,
|
||||
identity: false,
|
||||
file_nodes: 0,
|
||||
address_books: 1,
|
||||
calendars: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_account(label: &str, user: &Jmap, account_id: &str, s: &SeedStats) {
|
||||
let mailboxes = user
|
||||
.call(
|
||||
&[CORE, MAIL],
|
||||
"Mailbox/get",
|
||||
account_id,
|
||||
json!({ "properties": ["id", "role"] }),
|
||||
)
|
||||
.expect("Mailbox/get");
|
||||
assert_eq!(
|
||||
list_len(&mailboxes),
|
||||
DEFAULT_MAILBOXES + s.mailboxes_created,
|
||||
"{label}: mailbox count"
|
||||
);
|
||||
|
||||
let emails = user
|
||||
.call(
|
||||
&[CORE, MAIL],
|
||||
"Email/query",
|
||||
account_id,
|
||||
json!({ "calculateTotal": true }),
|
||||
)
|
||||
.expect("Email/query");
|
||||
let email_total = emails
|
||||
.get("total")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|n| n as usize)
|
||||
.unwrap_or_else(|| ids_len(&emails));
|
||||
assert_eq!(email_total, s.emails, "{label}: email count");
|
||||
|
||||
let scripts = user
|
||||
.call(
|
||||
&[CORE, SIEVE],
|
||||
"SieveScript/get",
|
||||
account_id,
|
||||
json!({ "properties": ["id", "name", "isActive"] }),
|
||||
)
|
||||
.expect("SieveScript/get");
|
||||
match s.sieve_active {
|
||||
Some(active) => {
|
||||
let list = scripts
|
||||
.get("list")
|
||||
.and_then(Value::as_array)
|
||||
.expect("sieve list");
|
||||
assert_eq!(list.len(), 2, "{label}: sieve script count");
|
||||
let primary = list
|
||||
.iter()
|
||||
.find(|s| s.get("name").and_then(Value::as_str) == Some("vandelay-test-filter"))
|
||||
.expect("primary sieve script present");
|
||||
assert_eq!(
|
||||
primary.get("isActive").and_then(Value::as_bool),
|
||||
Some(active),
|
||||
"{label}: primary sieve isActive"
|
||||
);
|
||||
}
|
||||
None => assert_eq!(list_len(&scripts), 0, "{label}: expected no sieve scripts"),
|
||||
}
|
||||
|
||||
let identities = user
|
||||
.call(
|
||||
&[CORE, MAIL],
|
||||
"Identity/get",
|
||||
account_id,
|
||||
json!({ "properties": ["id", "name"] }),
|
||||
)
|
||||
.expect("Identity/get");
|
||||
let custom = identities
|
||||
.get("list")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter(|i| {
|
||||
i.get("name").and_then(Value::as_str) == Some(seeder::CUSTOM_IDENTITY_NAME)
|
||||
})
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
assert_eq!(
|
||||
custom,
|
||||
usize::from(s.identity),
|
||||
"{label}: custom identity presence"
|
||||
);
|
||||
|
||||
let files = user
|
||||
.call(
|
||||
&[CORE, FILENODE],
|
||||
"FileNode/query",
|
||||
account_id,
|
||||
json!({ "calculateTotal": true }),
|
||||
)
|
||||
.expect("FileNode/query");
|
||||
let file_total = files
|
||||
.get("total")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|n| n as usize)
|
||||
.unwrap_or_else(|| ids_len(&files));
|
||||
assert_eq!(file_total, s.file_nodes, "{label}: file node count");
|
||||
|
||||
let books = user
|
||||
.call(
|
||||
&[CORE, CONTACTS],
|
||||
"AddressBook/get",
|
||||
account_id,
|
||||
json!({ "properties": ["id"] }),
|
||||
)
|
||||
.expect("AddressBook/get");
|
||||
assert_eq!(
|
||||
list_len(&books),
|
||||
s.address_books,
|
||||
"{label}: address book count"
|
||||
);
|
||||
|
||||
let calendars = user
|
||||
.call(
|
||||
&[CORE, CALENDARS],
|
||||
"Calendar/get",
|
||||
account_id,
|
||||
json!({ "properties": ["id"] }),
|
||||
)
|
||||
.expect("Calendar/get");
|
||||
assert_eq!(list_len(&calendars), s.calendars, "{label}: calendar count");
|
||||
|
||||
let cards = user
|
||||
.call(
|
||||
&[CORE, CONTACTS],
|
||||
"ContactCard/get",
|
||||
account_id,
|
||||
json!({ "properties": ["id"] }),
|
||||
)
|
||||
.expect("ContactCard/get");
|
||||
assert_eq!(list_len(&cards), s.contacts, "{label}: contact card count");
|
||||
|
||||
let cal_events = user
|
||||
.call(
|
||||
&[CORE, CALENDARS],
|
||||
"CalendarEvent/query",
|
||||
account_id,
|
||||
json!({ "calculateTotal": true }),
|
||||
)
|
||||
.expect("CalendarEvent/query");
|
||||
let event_total = cal_events
|
||||
.get("total")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|n| n as usize)
|
||||
.unwrap_or_else(|| ids_len(&cal_events));
|
||||
assert_eq!(event_total, s.events, "{label}: calendar event count");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn provision_seed_verify_and_teardown() {
|
||||
let stalwart = shared_stalwart();
|
||||
let fixture = seeder::provision(stalwart.base_url()).expect("provision");
|
||||
|
||||
assert_eq!(fixture.domain, seeder::DOMAIN);
|
||||
assert!(!fixture.domain_id.is_empty());
|
||||
assert_eq!(
|
||||
fixture.accounts.len(),
|
||||
seeder::SYNC_IN.len() + seeder::SYNC_OUT.len() + 1
|
||||
);
|
||||
|
||||
for lp in seeder::SYNC_IN {
|
||||
let a = fixture.account(lp).expect("sync-in account");
|
||||
let stats = a.seeded.as_ref().expect("sync-in seeded stats");
|
||||
assert!(stats.emails > 0, "{lp}: expected seeded emails");
|
||||
let user = Jmap::connect(&fixture.base_url, &a.email, &a.password).expect("login sync-in");
|
||||
assert_eq!(user.account_id, a.account_id);
|
||||
verify_account(lp, &user, &a.account_id, stats);
|
||||
}
|
||||
|
||||
let baseline = bare();
|
||||
for lp in seeder::SYNC_OUT {
|
||||
let a = fixture.account(lp).expect("sync-out account");
|
||||
assert!(a.seeded.is_none(), "{lp}: targets must not be seeded");
|
||||
let user = Jmap::connect(&fixture.base_url, &a.email, &a.password).expect("login sync-out");
|
||||
verify_account(lp, &user, &a.account_id, &baseline);
|
||||
}
|
||||
|
||||
let admin_fx = fixture
|
||||
.account(seeder::ADMIN_LOCALPART)
|
||||
.expect("admin account");
|
||||
assert!(admin_fx.admin_role);
|
||||
let admin = Jmap::connect(&fixture.base_url, &admin_fx.email, &admin_fx.password)
|
||||
.expect("login admin account");
|
||||
let test1 = fixture.account("test1").expect("test1");
|
||||
let resolved = admin
|
||||
.request(
|
||||
&[CORE, PRINCIPALS],
|
||||
json!([
|
||||
["Principal/query", { "filter": { "name": test1.email } }, "q"],
|
||||
["Principal/get", {
|
||||
"#ids": { "resultOf": "q", "name": "Principal/query", "path": "/ids" },
|
||||
"properties": ["id", "name", "accounts"]
|
||||
}, "g"]
|
||||
]),
|
||||
)
|
||||
.expect("Principal discovery");
|
||||
let principals = resolved
|
||||
.get("methodResponses")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|r| r.get(1))
|
||||
.and_then(|r| r.get(1))
|
||||
.and_then(|g| g.get("list"))
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let exact: Vec<&Value> = principals
|
||||
.iter()
|
||||
.filter(|p| p.get("name").and_then(Value::as_str) == Some(test1.email.as_str()))
|
||||
.collect();
|
||||
assert_eq!(exact.len(), 1, "admin Principal exact-name resolution");
|
||||
let owner = exact[0]
|
||||
.get("accounts")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|m| {
|
||||
m.iter().find_map(|(_, v)| {
|
||||
v.get("urn:ietf:params:jmap:principals:owner")
|
||||
.and_then(|o| o.get("accountIdForPrincipal"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
})
|
||||
.expect("accountIdForPrincipal");
|
||||
assert_eq!(owner, test1.account_id, "resolved account id matches test1");
|
||||
|
||||
seeder::teardown(stalwart.base_url()).expect("teardown");
|
||||
|
||||
let (admin_user, admin_password) = &fixture.admin_login;
|
||||
let gone = seeder::admin::Admin::connect(&fixture.base_url, admin_user, admin_password)
|
||||
.expect("admin reconnect")
|
||||
.domain_id(seeder::DOMAIN)
|
||||
.expect("domain_id query");
|
||||
assert!(gone.is_none(), "domain should be gone after teardown");
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::error::{SeedError, SeedResult};
|
||||
use super::jmap::Jmap;
|
||||
|
||||
pub const STALWART: &[&str] = &["urn:ietf:params:jmap:core", "urn:stalwart:jmap"];
|
||||
|
||||
pub struct Admin {
|
||||
jmap: Jmap,
|
||||
account_id: String,
|
||||
}
|
||||
|
||||
impl Admin {
|
||||
pub fn connect(base: &str, user: &str, password: &str) -> SeedResult<Admin> {
|
||||
let jmap = Jmap::connect(base, user, password)?;
|
||||
let account_id = jmap.account_id.clone();
|
||||
Ok(Admin { jmap, account_id })
|
||||
}
|
||||
|
||||
fn created_id(response: &Value, key: &str) -> SeedResult<String> {
|
||||
response
|
||||
.get("created")
|
||||
.and_then(|c| c.get(key))
|
||||
.and_then(|o| o.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| SeedError::Shape(format!("create {key} missing id: {response}")))
|
||||
}
|
||||
|
||||
pub fn domain_id(&self, name: &str) -> SeedResult<Option<String>> {
|
||||
let calls = json!([
|
||||
["x:Domain/query", { "accountId": self.account_id, "filter": { "name": name } }, "q"],
|
||||
["x:Domain/get", {
|
||||
"accountId": self.account_id,
|
||||
"#ids": { "resultOf": "q", "name": "x:Domain/query", "path": "/ids" },
|
||||
"properties": ["id", "name"]
|
||||
}, "g"]
|
||||
]);
|
||||
let parsed = self.jmap.request(STALWART, calls)?;
|
||||
let responses = parsed
|
||||
.get("methodResponses")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| SeedError::Shape("missing methodResponses".to_owned()))?;
|
||||
let get = responses
|
||||
.get(1)
|
||||
.and_then(|r| r.get(1))
|
||||
.ok_or_else(|| SeedError::Shape("missing x:Domain/get".to_owned()))?;
|
||||
let list = get.get("list").and_then(Value::as_array);
|
||||
if let Some(list) = list {
|
||||
for entry in list {
|
||||
if entry.get("name").and_then(Value::as_str) == Some(name)
|
||||
&& let Some(id) = entry.get("id").and_then(Value::as_str)
|
||||
{
|
||||
return Ok(Some(id.to_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn ensure_domain(&self, name: &str) -> SeedResult<String> {
|
||||
if let Some(id) = self.domain_id(name)? {
|
||||
return Ok(id);
|
||||
}
|
||||
let response = self.jmap.set_create(
|
||||
STALWART,
|
||||
"x:Domain/set",
|
||||
&self.account_id,
|
||||
json!({ "v": { "name": name } }),
|
||||
&[],
|
||||
)?;
|
||||
Self::created_id(&response, "v")
|
||||
}
|
||||
|
||||
fn accounts_in_domain(&self, domain_id: &str) -> SeedResult<Vec<String>> {
|
||||
let response = self.jmap.call(
|
||||
STALWART,
|
||||
"x:Account/get",
|
||||
&self.account_id,
|
||||
json!({ "properties": ["id", "domainId"] }),
|
||||
)?;
|
||||
let mut ids = Vec::new();
|
||||
if let Some(list) = response.get("list").and_then(Value::as_array) {
|
||||
for entry in list {
|
||||
if entry.get("domainId").and_then(Value::as_str) == Some(domain_id)
|
||||
&& let Some(id) = entry.get("id").and_then(Value::as_str)
|
||||
{
|
||||
ids.push(id.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn destroy(&self, method: &str, ids: &[String]) -> SeedResult<()> {
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
self.jmap.call(
|
||||
STALWART,
|
||||
method,
|
||||
&self.account_id,
|
||||
json!({ "destroy": ids }),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn invalidate_caches(&self) -> SeedResult<()> {
|
||||
self.jmap.call(
|
||||
STALWART,
|
||||
"x:Action/set",
|
||||
&self.account_id,
|
||||
json!({ "create": { "c": { "@type": "InvalidateCaches" } } }),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn teardown_domain(&self, name: &str) -> SeedResult<()> {
|
||||
let Some(domain_id) = self.domain_id(name)? else {
|
||||
return Ok(());
|
||||
};
|
||||
let accounts = self.accounts_in_domain(&domain_id)?;
|
||||
self.destroy("x:Account/set", &accounts)?;
|
||||
for _ in 0..6 {
|
||||
let response = self.jmap.call(
|
||||
STALWART,
|
||||
"x:Domain/set",
|
||||
&self.account_id,
|
||||
json!({ "destroy": [domain_id] }),
|
||||
)?;
|
||||
if response
|
||||
.get("destroyed")
|
||||
.and_then(Value::as_array)
|
||||
.map(|d| d.iter().any(|v| v.as_str() == Some(domain_id.as_str())))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let linked = response
|
||||
.get("notDestroyed")
|
||||
.and_then(|n| n.get(&domain_id))
|
||||
.and_then(|e| e.get("linkedObjects"))
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if linked.is_empty() {
|
||||
return Err(SeedError::Method {
|
||||
method: "x:Domain/set".to_owned(),
|
||||
detail: format!("domain {name} not destroyed: {response}"),
|
||||
});
|
||||
}
|
||||
let mut dkim = Vec::new();
|
||||
let mut accounts = Vec::new();
|
||||
for obj in &linked {
|
||||
let kind = obj
|
||||
.get("object")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let Some(id) = obj.get("id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
match kind {
|
||||
"DkimSignature" => dkim.push(id.to_owned()),
|
||||
"Account" => accounts.push(id.to_owned()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.destroy("x:Account/set", &accounts)?;
|
||||
self.destroy("x:DkimSignature/set", &dkim)?;
|
||||
}
|
||||
Err(SeedError::Method {
|
||||
method: "x:Domain/set".to_owned(),
|
||||
detail: format!("domain {name} still linked after retries"),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_account(
|
||||
&self,
|
||||
localpart: &str,
|
||||
domain_id: &str,
|
||||
password: &str,
|
||||
admin_role: bool,
|
||||
) -> SeedResult<String> {
|
||||
let role = if admin_role { "Admin" } else { "User" };
|
||||
let create = json!({
|
||||
"a": {
|
||||
"@type": "User",
|
||||
"name": localpart,
|
||||
"domainId": domain_id,
|
||||
"credentials": { "0": { "@type": "Password", "secret": password } },
|
||||
"encryptionAtRest": { "@type": "Disabled" },
|
||||
"permissions": { "@type": "Inherit" },
|
||||
"roles": { "@type": role },
|
||||
"locale": "en_US"
|
||||
}
|
||||
});
|
||||
let response =
|
||||
self.jmap
|
||||
.set_create(STALWART, "x:Account/set", &self.account_id, create, &[])?;
|
||||
Self::created_id(&response, "a")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::io::{BufReader, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use calcard::icalendar::ICalendar;
|
||||
use calcard::vcard::VCard;
|
||||
use flate2::read::GzDecoder;
|
||||
use mail_parser::mailbox::mbox::MessageIterator;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::error::{SeedError, SeedResult};
|
||||
|
||||
fn resources_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("resources")
|
||||
}
|
||||
|
||||
pub struct MboxMessage {
|
||||
pub raw: Vec<u8>,
|
||||
pub received_at: i64,
|
||||
}
|
||||
|
||||
pub fn load_mbox(limit: usize) -> SeedResult<Vec<MboxMessage>> {
|
||||
let path = resources_dir().join("mailbox.gz");
|
||||
let file = std::fs::File::open(&path)?;
|
||||
let mut decoder = GzDecoder::new(file);
|
||||
let mut bytes = Vec::new();
|
||||
decoder.read_to_end(&mut bytes)?;
|
||||
let reader = BufReader::new(std::io::Cursor::new(bytes));
|
||||
let mut out = Vec::new();
|
||||
for item in MessageIterator::new(reader) {
|
||||
let message = item.map_err(|e| SeedError::Resource(format!("mbox parse: {e}")))?;
|
||||
let received_at = message.internal_date() as i64;
|
||||
out.push(MboxMessage {
|
||||
raw: message.unwrap_contents(),
|
||||
received_at,
|
||||
});
|
||||
if out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(SeedError::Resource("mbox yielded no messages".to_owned()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn read_dir_sorted(sub: &str, ext: &str) -> SeedResult<Vec<PathBuf>> {
|
||||
let dir = resources_dir().join(sub);
|
||||
let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir)?
|
||||
.filter_map(Result::ok)
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some(ext))
|
||||
.collect();
|
||||
paths.sort();
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
pub fn load_contacts() -> SeedResult<Vec<Value>> {
|
||||
let mut cards = Vec::new();
|
||||
for path in read_dir_sorted("vcards", "vcf")? {
|
||||
let text = std::fs::read_to_string(&path)?;
|
||||
let vcard = VCard::parse(&text)
|
||||
.map_err(|_| SeedError::Resource(format!("vcard parse failed: {path:?}")))?;
|
||||
let js = vcard.into_jscontact::<String, String>();
|
||||
let value: Value = serde_json::from_str(&js.to_string_pretty())?;
|
||||
if has_nonempty_uid(&value) {
|
||||
cards.push(value);
|
||||
}
|
||||
}
|
||||
Ok(cards)
|
||||
}
|
||||
|
||||
pub fn load_events() -> SeedResult<Vec<Value>> {
|
||||
let mut events = Vec::new();
|
||||
for path in read_dir_sorted("icals", "ics")? {
|
||||
let text = std::fs::read_to_string(&path)?;
|
||||
let ical = ICalendar::parse(&text)
|
||||
.map_err(|_| SeedError::Resource(format!("ical parse failed: {path:?}")))?;
|
||||
let js = ical.into_jscalendar::<String, String>();
|
||||
let value: Value = serde_json::from_str(&js.to_string_pretty())?;
|
||||
collect_events(&value, &mut events);
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn collect_events(value: &Value, out: &mut Vec<Value>) {
|
||||
let at_type = value.get("@type").and_then(Value::as_str);
|
||||
match at_type {
|
||||
Some("Event") if has_nonempty_uid(value) => {
|
||||
out.push(value.clone());
|
||||
}
|
||||
Some("Group") => {
|
||||
if let Some(entries) = value.get("entries").and_then(Value::as_array) {
|
||||
for entry in entries {
|
||||
collect_events(entry, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_nonempty_uid(value: &Value) -> bool {
|
||||
value
|
||||
.get("uid")
|
||||
.and_then(Value::as_str)
|
||||
.map(|u| !u.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SeedError {
|
||||
#[error("http error: {0}")]
|
||||
Http(String),
|
||||
|
||||
#[error("jmap method error ({method}): {detail}")]
|
||||
Method { method: String, detail: String },
|
||||
|
||||
#[error("unexpected jmap response: {0}")]
|
||||
Shape(String),
|
||||
|
||||
#[error("resource error: {0}")]
|
||||
Resource(String),
|
||||
|
||||
#[error("json error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
pub type SeedResult<T> = Result<T, SeedError>;
|
||||
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine;
|
||||
use serde_json::{Value, json};
|
||||
use ureq::Agent;
|
||||
use ureq::config::RedirectAuthHeaders;
|
||||
use ureq::tls::{TlsConfig, TlsProvider};
|
||||
|
||||
use super::error::{SeedError, SeedResult};
|
||||
|
||||
pub struct Jmap {
|
||||
agent: Agent,
|
||||
api_url: String,
|
||||
upload_url: String,
|
||||
auth: String,
|
||||
pub account_id: String,
|
||||
}
|
||||
|
||||
fn build_agent() -> Agent {
|
||||
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||
let tls = TlsConfig::builder()
|
||||
.provider(TlsProvider::Rustls)
|
||||
.unversioned_rustls_crypto_provider(provider)
|
||||
.disable_verification(true)
|
||||
.build();
|
||||
Agent::config_builder()
|
||||
.tls_config(tls)
|
||||
.http_status_as_error(false)
|
||||
.max_redirects(10)
|
||||
.redirect_auth_headers(RedirectAuthHeaders::SameHost)
|
||||
.build()
|
||||
.new_agent()
|
||||
}
|
||||
|
||||
fn basic(user: &str, password: &str) -> String {
|
||||
let mut raw = String::with_capacity(user.len() + password.len() + 1);
|
||||
raw.push_str(user);
|
||||
raw.push(':');
|
||||
raw.push_str(password);
|
||||
format!(
|
||||
"Basic {}",
|
||||
base64::engine::general_purpose::STANDARD.encode(raw.as_bytes())
|
||||
)
|
||||
}
|
||||
|
||||
impl Jmap {
|
||||
pub fn connect(base: &str, user: &str, password: &str) -> SeedResult<Jmap> {
|
||||
let agent = build_agent();
|
||||
let auth = basic(user, password);
|
||||
let session = discover(&agent, base, &auth)?;
|
||||
let account_id = primary_account(&session)?;
|
||||
let api_url = string_field(&session, "apiUrl")?;
|
||||
let upload_url = string_field(&session, "uploadUrl")?;
|
||||
Ok(Jmap {
|
||||
agent,
|
||||
api_url,
|
||||
upload_url,
|
||||
auth,
|
||||
account_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn request(&self, using: &[&str], method_calls: Value) -> SeedResult<Value> {
|
||||
let body = json!({ "using": using, "methodCalls": method_calls });
|
||||
let mut resp = self
|
||||
.agent
|
||||
.post(&self.api_url)
|
||||
.header("Authorization", &self.auth)
|
||||
.header("Content-Type", "application/json")
|
||||
.send_json(&body)
|
||||
.map_err(|e| SeedError::Http(e.to_string()))?;
|
||||
let status = resp.status().as_u16();
|
||||
let text = resp
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.map_err(|e| SeedError::Http(e.to_string()))?;
|
||||
if status != 200 {
|
||||
return Err(SeedError::Http(format!("status {status}: {text}")));
|
||||
}
|
||||
let parsed: Value = serde_json::from_str(&text)?;
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
pub fn call(
|
||||
&self,
|
||||
using: &[&str],
|
||||
method: &str,
|
||||
account_id: &str,
|
||||
mut args: Value,
|
||||
) -> SeedResult<Value> {
|
||||
if let Value::Object(map) = &mut args {
|
||||
map.insert("accountId".to_owned(), Value::String(account_id.to_owned()));
|
||||
}
|
||||
let calls = json!([[method, args, "c0"]]);
|
||||
let parsed = self.request(using, calls)?;
|
||||
let response = first_response(&parsed, method)?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub fn set_create(
|
||||
&self,
|
||||
using: &[&str],
|
||||
method: &str,
|
||||
account_id: &str,
|
||||
creates: Value,
|
||||
extra_args: &[(&str, Value)],
|
||||
) -> SeedResult<Value> {
|
||||
let mut args = json!({ "create": creates });
|
||||
if let Value::Object(map) = &mut args {
|
||||
for (k, v) in extra_args {
|
||||
map.insert((*k).to_owned(), v.clone());
|
||||
}
|
||||
}
|
||||
let response = self.call(using, method, account_id, args)?;
|
||||
if let Some(not_created) = response.get("notCreated")
|
||||
&& not_created.is_object()
|
||||
&& !not_created
|
||||
.as_object()
|
||||
.map(|m| m.is_empty())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return Err(SeedError::Method {
|
||||
method: method.to_owned(),
|
||||
detail: format!("notCreated: {not_created}"),
|
||||
});
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub fn upload(&self, account_id: &str, content_type: &str, bytes: &[u8]) -> SeedResult<String> {
|
||||
let url = self.upload_url.replace("{accountId}", account_id);
|
||||
let mut resp = self
|
||||
.agent
|
||||
.post(&url)
|
||||
.header("Authorization", &self.auth)
|
||||
.header("Content-Type", content_type)
|
||||
.send(bytes)
|
||||
.map_err(|e| SeedError::Http(e.to_string()))?;
|
||||
let status = resp.status().as_u16();
|
||||
let text = resp
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.map_err(|e| SeedError::Http(e.to_string()))?;
|
||||
if status != 200 {
|
||||
return Err(SeedError::Http(format!("upload status {status}: {text}")));
|
||||
}
|
||||
let parsed: Value = serde_json::from_str(&text)?;
|
||||
parsed
|
||||
.get("blobId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| SeedError::Shape(format!("upload response missing blobId: {text}")))
|
||||
}
|
||||
}
|
||||
|
||||
fn discover(agent: &Agent, base: &str, auth: &str) -> SeedResult<Value> {
|
||||
let candidates = if base.contains("/.well-known/jmap") {
|
||||
vec![base.to_owned()]
|
||||
} else {
|
||||
vec![
|
||||
base.to_owned(),
|
||||
format!("{}/.well-known/jmap", base.trim_end_matches('/')),
|
||||
]
|
||||
};
|
||||
let mut last = String::new();
|
||||
for url in candidates {
|
||||
let mut resp = agent
|
||||
.get(&url)
|
||||
.header("Authorization", auth)
|
||||
.call()
|
||||
.map_err(|e| SeedError::Http(e.to_string()))?;
|
||||
let status = resp.status().as_u16();
|
||||
let text = resp
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.map_err(|e| SeedError::Http(e.to_string()))?;
|
||||
if status == 200
|
||||
&& let Ok(v) = serde_json::from_str::<Value>(&text)
|
||||
&& v.get("apiUrl").is_some()
|
||||
&& v.get("accounts")
|
||||
.and_then(Value::as_object)
|
||||
.map(|a| !a.is_empty())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(v);
|
||||
}
|
||||
last = format!("status {status}: {text}");
|
||||
}
|
||||
Err(SeedError::Shape(format!(
|
||||
"no JMAP session object found ({last})"
|
||||
)))
|
||||
}
|
||||
|
||||
fn string_field(session: &Value, key: &str) -> SeedResult<String> {
|
||||
session
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| SeedError::Shape(format!("session missing {key}")))
|
||||
}
|
||||
|
||||
fn primary_account(session: &Value) -> SeedResult<String> {
|
||||
if let Some(id) = session
|
||||
.get("primaryAccounts")
|
||||
.and_then(|p| p.get("urn:ietf:params:jmap:mail"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
return Ok(id.to_owned());
|
||||
}
|
||||
session
|
||||
.get("accounts")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|m| m.keys().next().cloned())
|
||||
.ok_or_else(|| SeedError::Shape("session has no accounts".to_owned()))
|
||||
}
|
||||
|
||||
fn first_response(parsed: &Value, method: &str) -> SeedResult<Value> {
|
||||
let responses = parsed
|
||||
.get("methodResponses")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| SeedError::Shape("response missing methodResponses".to_owned()))?;
|
||||
let first = responses
|
||||
.first()
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| SeedError::Shape("empty methodResponses".to_owned()))?;
|
||||
let name = first.first().and_then(Value::as_str).unwrap_or_default();
|
||||
let payload = first
|
||||
.get(1)
|
||||
.cloned()
|
||||
.ok_or_else(|| SeedError::Shape("method response missing payload".to_owned()))?;
|
||||
if name == "error" {
|
||||
return Err(SeedError::Method {
|
||||
method: method.to_owned(),
|
||||
detail: payload.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
pub mod admin;
|
||||
pub mod data;
|
||||
pub mod error;
|
||||
pub mod jmap;
|
||||
pub mod seed;
|
||||
|
||||
use admin::Admin;
|
||||
use error::SeedResult;
|
||||
use jmap::Jmap;
|
||||
use seed::{FileSpec, Layout, MailboxSpec};
|
||||
|
||||
pub const ADMIN_USER: &str = "admin";
|
||||
pub const ADMIN_PASSWORD: &str = "admin";
|
||||
pub const DOMAIN: &str = "vandelay.org";
|
||||
pub const USER_PASSWORD: &str = "VandelayUser#2026";
|
||||
pub const ADMIN_ACCOUNT_PASSWORD: &str = "VandelayAdmin#2026";
|
||||
|
||||
pub const SYNC_IN: [&str; 3] = ["test1", "test2", "test3"];
|
||||
pub const SYNC_OUT: [&str; 3] = ["test4", "test5", "test6"];
|
||||
pub const ADMIN_LOCALPART: &str = "vandeladmin";
|
||||
pub const CUSTOM_IDENTITY_NAME: &str = "Vandelay Test (Custom)";
|
||||
|
||||
macro_rules! mailbox {
|
||||
($k:expr, $n:expr, $p:expr, $r:expr) => {
|
||||
MailboxSpec {
|
||||
key: $k,
|
||||
name: $n,
|
||||
parent: $p,
|
||||
role: $r,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! node {
|
||||
($k:expr, $n:expr, $p:expr, $d:expr) => {
|
||||
FileSpec {
|
||||
key: $k,
|
||||
name: $n,
|
||||
parent: $p,
|
||||
directory: $d,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static LAYOUT1_MAILBOXES: &[MailboxSpec] = &[
|
||||
mailbox!("proj", "Projects", None, None),
|
||||
mailbox!("alpha", "Alpha", Some("proj"), None),
|
||||
mailbox!("sub", "Subtasks", Some("alpha"), None),
|
||||
mailbox!("done", "Done", Some("sub"), None),
|
||||
mailbox!("beta", "Beta", Some("proj"), None),
|
||||
mailbox!("backlog", "Backlog", Some("beta"), None),
|
||||
mailbox!("arch", "Archive", None, Some("archive")),
|
||||
];
|
||||
|
||||
static LAYOUT2_MAILBOXES: &[MailboxSpec] = &[
|
||||
mailbox!("work", "Work", None, None),
|
||||
mailbox!("clients", "Clients", Some("work"), None),
|
||||
mailbox!("acme", "Acme Corp", Some("clients"), None),
|
||||
mailbox!("inv", "Invoices", Some("acme"), None),
|
||||
mailbox!("globex", "Globex", Some("clients"), None),
|
||||
mailbox!("internal", "Internal", Some("work"), None),
|
||||
mailbox!("pers", "Personal", None, None),
|
||||
mailbox!("rcpt", "Receipts", Some("pers"), None),
|
||||
mailbox!("y2025", "2025", Some("rcpt"), None),
|
||||
];
|
||||
|
||||
static LAYOUT3_MAILBOXES: &[MailboxSpec] = &[
|
||||
mailbox!("lists", "Lists", None, None),
|
||||
mailbox!("news", "Newsletters", Some("lists"), None),
|
||||
mailbox!("tech", "Tech", Some("news"), None),
|
||||
mailbox!("notif", "Notifications", Some("lists"), None),
|
||||
mailbox!("misc", "Misc", None, None),
|
||||
];
|
||||
|
||||
static LAYOUT1_FILES: &[FileSpec] = &[
|
||||
node!("d_docs", "Documents", None, true),
|
||||
node!("d_rep", "Reports", Some("d_docs"), true),
|
||||
node!("d_24", "2024", Some("d_rep"), true),
|
||||
node!("f_q4", "q4-results.bin", Some("d_24"), false),
|
||||
node!("d_25", "2025", Some("d_rep"), true),
|
||||
node!("f_q1", "q1-results.bin", Some("d_25"), false),
|
||||
node!("d_spec", "Specs", Some("d_docs"), true),
|
||||
node!("f_ovr", "overview.bin", Some("d_spec"), false),
|
||||
node!("f_readme", "README.bin", None, false),
|
||||
];
|
||||
|
||||
static LAYOUT2_FILES: &[FileSpec] = &[
|
||||
node!("m_media", "Media", None, true),
|
||||
node!("m_photos", "Photos", Some("m_media"), true),
|
||||
node!("m_23", "2023", Some("m_photos"), true),
|
||||
node!("f_trip", "trip.bin", Some("m_23"), false),
|
||||
node!("m_audio", "Audio", Some("m_media"), true),
|
||||
node!("f_notes", "notes.bin", Some("m_audio"), false),
|
||||
node!("m_proj", "Projects", None, true),
|
||||
node!("m_src", "src", Some("m_proj"), true),
|
||||
node!("f_main", "main.bin", Some("m_src"), false),
|
||||
];
|
||||
|
||||
static LAYOUT3_FILES: &[FileSpec] = &[
|
||||
node!("b_bak", "Backup", None, true),
|
||||
node!("b_db", "db", Some("b_bak"), true),
|
||||
node!("f_snap", "snapshot.bin", Some("b_db"), false),
|
||||
node!("f_scratch", "scratch.bin", None, false),
|
||||
];
|
||||
|
||||
fn layout_for(localpart: &str) -> Layout {
|
||||
match localpart {
|
||||
"test1" => Layout {
|
||||
mailboxes: LAYOUT1_MAILBOXES,
|
||||
file_tree: LAYOUT1_FILES,
|
||||
email_count: 400,
|
||||
sieve_active: Some(true),
|
||||
identity: true,
|
||||
extra_address_book: true,
|
||||
extra_calendar: true,
|
||||
},
|
||||
"test2" => Layout {
|
||||
mailboxes: LAYOUT2_MAILBOXES,
|
||||
file_tree: LAYOUT2_FILES,
|
||||
email_count: 110,
|
||||
sieve_active: Some(false),
|
||||
identity: true,
|
||||
extra_address_book: false,
|
||||
extra_calendar: true,
|
||||
},
|
||||
_ => Layout {
|
||||
mailboxes: LAYOUT3_MAILBOXES,
|
||||
file_tree: LAYOUT3_FILES,
|
||||
email_count: 70,
|
||||
sieve_active: None,
|
||||
identity: false,
|
||||
extra_address_book: false,
|
||||
extra_calendar: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AccountFixture {
|
||||
pub localpart: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub admin_role: bool,
|
||||
pub account_id: String,
|
||||
pub seeded: Option<seed::SeedStats>,
|
||||
}
|
||||
|
||||
pub struct Fixture {
|
||||
pub base_url: String,
|
||||
pub domain: String,
|
||||
pub domain_id: String,
|
||||
pub admin_login: (String, String),
|
||||
pub accounts: Vec<AccountFixture>,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
pub fn account(&self, localpart: &str) -> Option<&AccountFixture> {
|
||||
self.accounts.iter().find(|a| a.localpart == localpart)
|
||||
}
|
||||
}
|
||||
|
||||
fn email_of(localpart: &str) -> String {
|
||||
format!("{localpart}@{DOMAIN}")
|
||||
}
|
||||
|
||||
pub fn teardown(base_url: &str) -> SeedResult<()> {
|
||||
let admin = Admin::connect(base_url, ADMIN_USER, ADMIN_PASSWORD)?;
|
||||
admin.teardown_domain(DOMAIN)?;
|
||||
admin.invalidate_caches()
|
||||
}
|
||||
|
||||
pub fn provision(base_url: &str) -> SeedResult<Fixture> {
|
||||
let admin = Admin::connect(base_url, ADMIN_USER, ADMIN_PASSWORD)?;
|
||||
admin.teardown_domain(DOMAIN)?;
|
||||
admin.invalidate_caches()?;
|
||||
let domain_id = admin.ensure_domain(DOMAIN)?;
|
||||
admin.invalidate_caches()?;
|
||||
|
||||
let mut specs: Vec<(String, String, bool)> = Vec::new();
|
||||
for lp in SYNC_IN.iter().chain(SYNC_OUT.iter()) {
|
||||
specs.push(((*lp).to_owned(), USER_PASSWORD.to_owned(), false));
|
||||
}
|
||||
specs.push((
|
||||
ADMIN_LOCALPART.to_owned(),
|
||||
ADMIN_ACCOUNT_PASSWORD.to_owned(),
|
||||
true,
|
||||
));
|
||||
|
||||
let mut accounts = Vec::new();
|
||||
for (localpart, password, admin_role) in &specs {
|
||||
admin.create_account(localpart, &domain_id, password, *admin_role)?;
|
||||
}
|
||||
admin.invalidate_caches()?;
|
||||
for (localpart, password, admin_role) in &specs {
|
||||
let email = email_of(localpart);
|
||||
let user = Jmap::connect(base_url, &email, password)?;
|
||||
accounts.push(AccountFixture {
|
||||
localpart: localpart.clone(),
|
||||
email,
|
||||
password: password.clone(),
|
||||
admin_role: *admin_role,
|
||||
account_id: user.account_id.clone(),
|
||||
seeded: None,
|
||||
});
|
||||
}
|
||||
|
||||
let messages = data::load_mbox(usize::MAX)?;
|
||||
let contacts = data::load_contacts()?;
|
||||
let events = data::load_events()?;
|
||||
|
||||
let mut offset = 0;
|
||||
for localpart in SYNC_IN {
|
||||
let index = accounts
|
||||
.iter()
|
||||
.position(|a| a.localpart == localpart)
|
||||
.ok_or_else(|| error::SeedError::Shape(format!("missing account {localpart}")))?;
|
||||
let layout = layout_for(localpart);
|
||||
let (email, password, account_id) = {
|
||||
let a = &accounts[index];
|
||||
(a.email.clone(), a.password.clone(), a.account_id.clone())
|
||||
};
|
||||
let user = Jmap::connect(base_url, &email, &password)?;
|
||||
let end = (offset + layout.email_count).min(messages.len());
|
||||
let slice = &messages[offset..end];
|
||||
offset = end;
|
||||
let (c0, c1, e0, e1) = match localpart {
|
||||
"test1" => (0, contacts.len().min(3), 0, events.len().min(4)),
|
||||
"test2" => (
|
||||
contacts.len().min(3),
|
||||
contacts.len().min(6),
|
||||
events.len().min(4),
|
||||
events.len().min(8),
|
||||
),
|
||||
_ => (
|
||||
contacts.len().min(6),
|
||||
contacts.len(),
|
||||
events.len().min(8),
|
||||
events.len(),
|
||||
),
|
||||
};
|
||||
let stats = seed::seed_account(
|
||||
&user,
|
||||
&account_id,
|
||||
&email,
|
||||
&layout,
|
||||
slice,
|
||||
&contacts[c0..c1],
|
||||
&events[e0..e1],
|
||||
)?;
|
||||
accounts[index].seeded = Some(stats);
|
||||
}
|
||||
|
||||
Ok(Fixture {
|
||||
base_url: base_url.to_owned(),
|
||||
domain: DOMAIN.to_owned(),
|
||||
domain_id,
|
||||
admin_login: (ADMIN_USER.to_owned(), ADMIN_PASSWORD.to_owned()),
|
||||
accounts,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
use super::data::MboxMessage;
|
||||
use super::error::{SeedError, SeedResult};
|
||||
use super::jmap::Jmap;
|
||||
|
||||
const CORE: &str = "urn:ietf:params:jmap:core";
|
||||
const MAIL: &str = "urn:ietf:params:jmap:mail";
|
||||
const SIEVE: &str = "urn:ietf:params:jmap:sieve";
|
||||
const CONTACTS: &str = "urn:ietf:params:jmap:contacts";
|
||||
const CALENDARS: &str = "urn:ietf:params:jmap:calendars";
|
||||
const FILENODE: &str = "urn:ietf:params:jmap:filenode";
|
||||
|
||||
pub struct MailboxSpec {
|
||||
pub key: &'static str,
|
||||
pub name: &'static str,
|
||||
pub parent: Option<&'static str>,
|
||||
pub role: Option<&'static str>,
|
||||
}
|
||||
|
||||
pub struct FileSpec {
|
||||
pub key: &'static str,
|
||||
pub name: &'static str,
|
||||
pub parent: Option<&'static str>,
|
||||
pub directory: bool,
|
||||
}
|
||||
|
||||
pub struct Layout {
|
||||
pub mailboxes: &'static [MailboxSpec],
|
||||
pub file_tree: &'static [FileSpec],
|
||||
pub email_count: usize,
|
||||
pub sieve_active: Option<bool>,
|
||||
pub identity: bool,
|
||||
pub extra_address_book: bool,
|
||||
pub extra_calendar: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SeedStats {
|
||||
pub mailboxes_created: usize,
|
||||
pub emails: usize,
|
||||
pub contacts: usize,
|
||||
pub events: usize,
|
||||
pub sieve_active: Option<bool>,
|
||||
pub identity: bool,
|
||||
pub file_nodes: usize,
|
||||
pub address_books: usize,
|
||||
pub calendars: usize,
|
||||
}
|
||||
|
||||
pub fn seed_account(
|
||||
jmap: &Jmap,
|
||||
account_id: &str,
|
||||
account_email: &str,
|
||||
layout: &Layout,
|
||||
emails: &[MboxMessage],
|
||||
contacts: &[Value],
|
||||
events: &[Value],
|
||||
) -> SeedResult<SeedStats> {
|
||||
let leaf_mailboxes = create_mailboxes(jmap, account_id, layout)?;
|
||||
let mut targets = vec![inbox_id(jmap, account_id)?];
|
||||
targets.extend(leaf_mailboxes);
|
||||
let imported = import_emails(
|
||||
jmap,
|
||||
account_id,
|
||||
&targets,
|
||||
&emails[..layout.email_count.min(emails.len())],
|
||||
)?;
|
||||
|
||||
if let Some(active) = layout.sieve_active {
|
||||
seed_sieve(jmap, account_id, active)?;
|
||||
}
|
||||
if layout.identity {
|
||||
seed_identity(jmap, account_id, account_email)?;
|
||||
}
|
||||
if !layout.file_tree.is_empty() {
|
||||
seed_filenodes(jmap, account_id, layout.file_tree)?;
|
||||
}
|
||||
let (n_contacts, n_events) =
|
||||
seed_contacts_calendars(jmap, account_id, layout, contacts, events)?;
|
||||
|
||||
Ok(SeedStats {
|
||||
mailboxes_created: layout.mailboxes.len(),
|
||||
emails: imported,
|
||||
contacts: n_contacts,
|
||||
events: n_events,
|
||||
sieve_active: layout.sieve_active,
|
||||
identity: layout.identity,
|
||||
file_nodes: layout.file_tree.len(),
|
||||
address_books: 1 + usize::from(layout.extra_address_book),
|
||||
calendars: 1 + usize::from(layout.extra_calendar),
|
||||
})
|
||||
}
|
||||
|
||||
fn create_mailboxes(jmap: &Jmap, account_id: &str, layout: &Layout) -> SeedResult<Vec<String>> {
|
||||
if layout.mailboxes.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut create = serde_json::Map::new();
|
||||
for spec in layout.mailboxes {
|
||||
let mut obj = serde_json::Map::new();
|
||||
obj.insert("name".to_owned(), Value::String(spec.name.to_owned()));
|
||||
if let Some(parent) = spec.parent {
|
||||
obj.insert("parentId".to_owned(), Value::String(format!("#{parent}")));
|
||||
}
|
||||
if let Some(role) = spec.role {
|
||||
obj.insert("role".to_owned(), Value::String(role.to_owned()));
|
||||
}
|
||||
create.insert(spec.key.to_owned(), Value::Object(obj));
|
||||
}
|
||||
let response = jmap.set_create(
|
||||
&[CORE, MAIL],
|
||||
"Mailbox/set",
|
||||
account_id,
|
||||
Value::Object(create),
|
||||
&[],
|
||||
)?;
|
||||
let created = response
|
||||
.get("created")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| SeedError::Shape(format!("Mailbox/set created missing: {response}")))?;
|
||||
let mut leaves = Vec::new();
|
||||
let parents: std::collections::HashSet<&str> =
|
||||
layout.mailboxes.iter().filter_map(|m| m.parent).collect();
|
||||
for spec in layout.mailboxes {
|
||||
if parents.contains(spec.key) {
|
||||
continue;
|
||||
}
|
||||
if let Some(id) = created
|
||||
.get(spec.key)
|
||||
.and_then(|c| c.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
leaves.push(id.to_owned());
|
||||
}
|
||||
}
|
||||
Ok(leaves)
|
||||
}
|
||||
|
||||
fn inbox_id(jmap: &Jmap, account_id: &str) -> SeedResult<String> {
|
||||
let response = jmap.call(
|
||||
&[CORE, MAIL],
|
||||
"Mailbox/query",
|
||||
account_id,
|
||||
json!({ "filter": { "role": "inbox" } }),
|
||||
)?;
|
||||
response
|
||||
.get("ids")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|a| a.first())
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| SeedError::Shape("no inbox mailbox".to_owned()))
|
||||
}
|
||||
|
||||
fn import_emails(
|
||||
jmap: &Jmap,
|
||||
account_id: &str,
|
||||
targets: &[String],
|
||||
messages: &[MboxMessage],
|
||||
) -> SeedResult<usize> {
|
||||
if messages.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut imported = 0;
|
||||
for (batch_index, chunk) in messages.chunks(50).enumerate() {
|
||||
let mut emails = serde_json::Map::new();
|
||||
for (i, message) in chunk.iter().enumerate() {
|
||||
let blob_id = jmap.upload(account_id, "message/rfc822", &message.raw)?;
|
||||
let mailbox = &targets[(batch_index * 50 + i) % targets.len()];
|
||||
let received_at = OffsetDateTime::from_unix_timestamp(message.received_at)
|
||||
.unwrap_or(OffsetDateTime::UNIX_EPOCH)
|
||||
.format(&Rfc3339)
|
||||
.map_err(|e| SeedError::Resource(format!("receivedAt format: {e}")))?;
|
||||
emails.insert(
|
||||
format!("e{batch_index}_{i}"),
|
||||
json!({
|
||||
"blobId": blob_id,
|
||||
"mailboxIds": { mailbox.as_str(): true },
|
||||
"keywords": { "$seen": true },
|
||||
"receivedAt": received_at
|
||||
}),
|
||||
);
|
||||
}
|
||||
let response = jmap.call(
|
||||
&[CORE, MAIL],
|
||||
"Email/import",
|
||||
account_id,
|
||||
json!({ "emails": Value::Object(emails) }),
|
||||
)?;
|
||||
if let Some(created) = response.get("created").and_then(Value::as_object) {
|
||||
imported += created.len();
|
||||
}
|
||||
if let Some(not_created) = response.get("notCreated").and_then(Value::as_object)
|
||||
&& !not_created.is_empty()
|
||||
{
|
||||
return Err(SeedError::Method {
|
||||
method: "Email/import".to_owned(),
|
||||
detail: format!("notCreated: {not_created:?}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
fn seed_sieve(jmap: &Jmap, account_id: &str, active: bool) -> SeedResult<()> {
|
||||
let primary = "require [\"fileinto\"];\nif header :contains \"subject\" \"test\" {\n fileinto \"INBOX\";\n}\n";
|
||||
let primary_blob = jmap.upload(account_id, "application/octet-stream", primary.as_bytes())?;
|
||||
let secondary = "require [\"vacation\"];\nvacation :days 1 \"out of office\";\n";
|
||||
let secondary_blob =
|
||||
jmap.upload(account_id, "application/octet-stream", secondary.as_bytes())?;
|
||||
let extra: Vec<(&str, Value)> = if active {
|
||||
vec![("onSuccessActivateScript", Value::String("#s0".to_owned()))]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
jmap.set_create(
|
||||
&[CORE, SIEVE],
|
||||
"SieveScript/set",
|
||||
account_id,
|
||||
json!({
|
||||
"s0": { "name": "vandelay-test-filter", "blobId": primary_blob },
|
||||
"s1": { "name": "vandelay-vacation", "blobId": secondary_blob }
|
||||
}),
|
||||
&extra,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn seed_identity(jmap: &Jmap, account_id: &str, account_email: &str) -> SeedResult<()> {
|
||||
jmap.set_create(
|
||||
&[CORE, MAIL],
|
||||
"Identity/set",
|
||||
account_id,
|
||||
json!({
|
||||
"i0": {
|
||||
"name": super::CUSTOM_IDENTITY_NAME,
|
||||
"email": account_email,
|
||||
"textSignature": "-- \nVandelay Industries",
|
||||
"htmlSignature": "<p>Vandelay Industries</p>"
|
||||
}
|
||||
}),
|
||||
&[],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn random_bytes(len: usize) -> SeedResult<Vec<u8>> {
|
||||
let mut buf = vec![0u8; len];
|
||||
let mut file = std::fs::File::open("/dev/urandom")?;
|
||||
file.read_exact(&mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn seed_filenodes(jmap: &Jmap, account_id: &str, tree: &[FileSpec]) -> SeedResult<()> {
|
||||
let mut create = serde_json::Map::new();
|
||||
for (i, spec) in tree.iter().enumerate() {
|
||||
let mut obj = serde_json::Map::new();
|
||||
obj.insert("name".to_owned(), Value::String(spec.name.to_owned()));
|
||||
if let Some(parent) = spec.parent {
|
||||
obj.insert("parentId".to_owned(), Value::String(format!("#{parent}")));
|
||||
}
|
||||
if spec.directory {
|
||||
obj.insert("nodeType".to_owned(), Value::String("directory".to_owned()));
|
||||
} else {
|
||||
let bytes = random_bytes(128 + i * 17)?;
|
||||
let blob_id = jmap.upload(account_id, "application/octet-stream", &bytes)?;
|
||||
obj.insert("nodeType".to_owned(), Value::String("file".to_owned()));
|
||||
obj.insert("blobId".to_owned(), Value::String(blob_id));
|
||||
obj.insert(
|
||||
"type".to_owned(),
|
||||
Value::String("application/octet-stream".to_owned()),
|
||||
);
|
||||
}
|
||||
create.insert(spec.key.to_owned(), Value::Object(obj));
|
||||
}
|
||||
jmap.set_create(
|
||||
&[CORE, FILENODE],
|
||||
"FileNode/set",
|
||||
account_id,
|
||||
Value::Object(create),
|
||||
&[],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn default_collection(
|
||||
jmap: &Jmap,
|
||||
account_id: &str,
|
||||
method: &str,
|
||||
using: &[&str],
|
||||
) -> SeedResult<String> {
|
||||
let response = jmap.call(
|
||||
using,
|
||||
method,
|
||||
account_id,
|
||||
json!({ "properties": ["id", "isDefault"] }),
|
||||
)?;
|
||||
let list = response
|
||||
.get("list")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| SeedError::Shape(format!("{method} list missing")))?;
|
||||
for entry in list {
|
||||
if entry.get("isDefault").and_then(Value::as_bool) == Some(true)
|
||||
&& let Some(id) = entry.get("id").and_then(Value::as_str)
|
||||
{
|
||||
return Ok(id.to_owned());
|
||||
}
|
||||
}
|
||||
list.first()
|
||||
.and_then(|e| e.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| SeedError::Shape(format!("{method} has no entries")))
|
||||
}
|
||||
|
||||
fn seed_contacts_calendars(
|
||||
jmap: &Jmap,
|
||||
account_id: &str,
|
||||
layout: &Layout,
|
||||
contacts: &[Value],
|
||||
events: &[Value],
|
||||
) -> SeedResult<(usize, usize)> {
|
||||
let default_ab = default_collection(jmap, account_id, "AddressBook/get", &[CORE, CONTACTS])?;
|
||||
let mut address_books = vec![default_ab];
|
||||
if layout.extra_address_book {
|
||||
let response = jmap.set_create(
|
||||
&[CORE, CONTACTS],
|
||||
"AddressBook/set",
|
||||
account_id,
|
||||
json!({ "ab": { "name": "Vandelay Work Contacts" } }),
|
||||
&[],
|
||||
)?;
|
||||
if let Some(id) = response
|
||||
.get("created")
|
||||
.and_then(|c| c.get("ab"))
|
||||
.and_then(|o| o.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
address_books.push(id.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
let default_cal = default_collection(jmap, account_id, "Calendar/get", &[CORE, CALENDARS])?;
|
||||
let mut calendars = vec![default_cal];
|
||||
if layout.extra_calendar {
|
||||
let response = jmap.set_create(
|
||||
&[CORE, CALENDARS],
|
||||
"Calendar/set",
|
||||
account_id,
|
||||
json!({ "cal": { "name": "Vandelay Team Calendar", "color": "#3366cc" } }),
|
||||
&[],
|
||||
)?;
|
||||
if let Some(id) = response
|
||||
.get("created")
|
||||
.and_then(|c| c.get("cal"))
|
||||
.and_then(|o| o.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
calendars.push(id.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
let mut n_contacts = 0;
|
||||
for (i, card) in contacts.iter().enumerate() {
|
||||
let mut obj = card
|
||||
.as_object()
|
||||
.ok_or_else(|| SeedError::Shape("contact not an object".to_owned()))?
|
||||
.clone();
|
||||
let book = &address_books[i % address_books.len()];
|
||||
obj.insert("addressBookIds".to_owned(), json!({ book.as_str(): true }));
|
||||
jmap.set_create(
|
||||
&[CORE, CONTACTS],
|
||||
"ContactCard/set",
|
||||
account_id,
|
||||
json!({ "card": Value::Object(obj) }),
|
||||
&[],
|
||||
)?;
|
||||
n_contacts += 1;
|
||||
}
|
||||
|
||||
let mut n_events = 0;
|
||||
for (i, event) in events.iter().enumerate() {
|
||||
let mut obj = event
|
||||
.as_object()
|
||||
.ok_or_else(|| SeedError::Shape("event not an object".to_owned()))?
|
||||
.clone();
|
||||
for immutable in ["method", "prodId"] {
|
||||
obj.remove(immutable);
|
||||
}
|
||||
let calendar = &calendars[i % calendars.len()];
|
||||
obj.insert("calendarIds".to_owned(), json!({ calendar.as_str(): true }));
|
||||
jmap.set_create(
|
||||
&[CORE, CALENDARS],
|
||||
"CalendarEvent/set",
|
||||
account_id,
|
||||
json!({ "event": Value::Object(obj) }),
|
||||
&[],
|
||||
)?;
|
||||
n_events += 1;
|
||||
}
|
||||
|
||||
Ok((n_contacts, n_events))
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
/*
|
||||
* 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 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");
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
/*
|
||||
* 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;
|
||||
use vandelay::jmap::account::AccountSelector;
|
||||
use vandelay::jmap::http::Auth;
|
||||
use vandelay::logging::Logger;
|
||||
|
||||
fn base_url() -> &'static str {
|
||||
shared_stalwart().base_url()
|
||||
}
|
||||
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 carddav_cfg(base_url: &str, domain: &str, localpart: &str) -> DavImportConfig {
|
||||
DavImportConfig {
|
||||
kind: DavKindArg::Carddav,
|
||||
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 card_uids(conn: &Connection) -> HashSet<String> {
|
||||
let mut stmt = conn.prepare("SELECT uid FROM contact_cards").unwrap();
|
||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0)).unwrap();
|
||||
let mut out = HashSet::new();
|
||||
for r in rows {
|
||||
out.insert(r.unwrap());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn carddav_import_test1_yields_address_books_and_cards() {
|
||||
let fx = seeder::provision(base_url()).expect("provision");
|
||||
let acc = fx.account("test1").expect("test1");
|
||||
let archive = tmp_archive("carddav");
|
||||
|
||||
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.address_books > 0,
|
||||
"test1 layout requests an extra address book"
|
||||
);
|
||||
assert!(seeded.contacts > 0, "test1 layout seeds contacts");
|
||||
assert!(
|
||||
seeded.mailboxes_created > 0,
|
||||
"test1 has a seeded mailbox tree"
|
||||
);
|
||||
assert!(seeded.emails > 0, "test1 has seeded emails");
|
||||
assert!(seeded.calendars > 0, "test1 has seeded calendars");
|
||||
assert!(seeded.events > 0, "test1 has seeded events");
|
||||
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),
|
||||
carddav_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 books = count(&conn, "address_books") as usize;
|
||||
let cards = count(&conn, "contact_cards") as usize;
|
||||
assert!(
|
||||
books >= seeded.address_books,
|
||||
"imported address books ({books}) covers seeded layout ({})",
|
||||
seeded.address_books,
|
||||
);
|
||||
assert!(
|
||||
cards >= seeded.contacts,
|
||||
"imported cards ({cards}) covers seeded layout ({})",
|
||||
seeded.contacts,
|
||||
);
|
||||
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn carddav_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!("carddav-all-{localpart}"));
|
||||
let summary = sync::import_dav::run(
|
||||
common(&archive),
|
||||
carddav_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 books = count(&conn, "address_books") as usize;
|
||||
assert!(
|
||||
books >= 1,
|
||||
"{localpart} has at least one address book (Stalwart default)"
|
||||
);
|
||||
}
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn carddav_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("carddav-batch-big");
|
||||
sync::import_dav::run(
|
||||
common(&archive_big),
|
||||
carddav_cfg(&fx.base_url, &fx.domain, &acc.localpart),
|
||||
)
|
||||
.expect("import big batch");
|
||||
let conn = Connection::open(&archive_big).unwrap();
|
||||
let big = count(&conn, "contact_cards");
|
||||
drop(conn);
|
||||
|
||||
let archive_small = tmp_archive("carddav-batch-small");
|
||||
let mut cfg = carddav_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, "contact_cards");
|
||||
assert_eq!(big, small, "batch size must not affect imported count");
|
||||
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn carddav_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("carddav-rt");
|
||||
|
||||
let imp = sync::import_dav::run(
|
||||
common(&archive),
|
||||
carddav_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_cards = count(&conn, "contact_cards");
|
||||
assert!(local_cards > 0, "DAV import landed contact cards");
|
||||
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_cards_1: u64 = e1
|
||||
.per_type
|
||||
.iter()
|
||||
.filter(|(t, _)| *t == "ContactCard")
|
||||
.map(|(_, c)| c.created)
|
||||
.sum();
|
||||
assert!(
|
||||
created_cards_1 > 0,
|
||||
"first export created ContactCard 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 carddav_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("carddav-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("carddav-parity-dav");
|
||||
let s_dav = sync::import_dav::run(
|
||||
common(&archive_dav),
|
||||
carddav_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 = card_uids(&conn_jmap);
|
||||
drop(conn_jmap);
|
||||
|
||||
let conn_dav = Connection::open(&archive_dav).unwrap();
|
||||
let uids_dav = card_uids(&conn_dav);
|
||||
drop(conn_dav);
|
||||
|
||||
assert!(
|
||||
!uids_jmap.is_empty(),
|
||||
"jmap import should yield at least one card uid"
|
||||
);
|
||||
assert!(
|
||||
!uids_dav.is_empty(),
|
||||
"dav import should yield at least one card 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 carddav_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("carddav-idem");
|
||||
|
||||
sync::import_dav::run(
|
||||
common(&archive),
|
||||
carddav_cfg(&fx.base_url, &fx.domain, &acc.localpart),
|
||||
)
|
||||
.expect("import 1");
|
||||
let conn1 = Connection::open(&archive).unwrap();
|
||||
let n1 = count(&conn1, "contact_cards");
|
||||
drop(conn1);
|
||||
|
||||
sync::import_dav::run(
|
||||
common(&archive),
|
||||
carddav_cfg(&fx.base_url, &fx.domain, &acc.localpart),
|
||||
)
|
||||
.expect("import 2");
|
||||
let conn2 = Connection::open(&archive).unwrap();
|
||||
let n2 = count(&conn2, "contact_cards");
|
||||
assert_eq!(n1, n2, "second import does not duplicate cards");
|
||||
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
/*
|
||||
* 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()
|
||||
}
|
||||
|
||||
fn imaps_url() -> String {
|
||||
let s = shared_stalwart();
|
||||
format!("imaps://{}:{}", s.host, s.imaps_port)
|
||||
}
|
||||
|
||||
use vandelay::jmap::account::AccountSelector;
|
||||
use vandelay::jmap::http::Auth;
|
||||
use vandelay::logging::Logger;
|
||||
use vandelay::sync::import_imap::{ImapAuth, ImapImportConfig};
|
||||
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-imap-{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: 1,
|
||||
dry_run: false,
|
||||
max_retries: 3,
|
||||
allow_invalid_certs: true,
|
||||
logger: Logger::from_flags(false, 0),
|
||||
}
|
||||
}
|
||||
|
||||
fn imap_basic_config(localpart: &str) -> ImapImportConfig {
|
||||
ImapImportConfig {
|
||||
url: imaps_url(),
|
||||
auth: ImapAuth::Basic {
|
||||
user: format!("{localpart}@{}", seeder::DOMAIN),
|
||||
password: seeder::USER_PASSWORD.to_owned(),
|
||||
},
|
||||
allow_cleartext: false,
|
||||
compress: false,
|
||||
include: Vec::new(),
|
||||
exclude: Vec::new(),
|
||||
exclude_special: Vec::new(),
|
||||
folder: Vec::new(),
|
||||
subscribed_only: false,
|
||||
automap: true,
|
||||
include_deleted: false,
|
||||
fetch_batch: 256,
|
||||
imap_connections: 4,
|
||||
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 blob_hashes(conn: &Connection) -> HashSet<Vec<u8>> {
|
||||
let mut stmt = conn.prepare("SELECT hash FROM blobs").unwrap();
|
||||
let rows = stmt.query_map([], |r| r.get::<_, Vec<u8>>(0)).unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn imap_import_test1_lands_mailbox_tree_and_emails() {
|
||||
let fx = seeder::provision(base_url()).expect("provision");
|
||||
let acc = fx.account("test1").expect("test1");
|
||||
let archive = tmp_archive("test1");
|
||||
assert_eq!(fx.domain, seeder::DOMAIN);
|
||||
assert!(!fx.domain_id.is_empty());
|
||||
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");
|
||||
if let Some(seeded) = &acc.seeded {
|
||||
assert!(seeded.emails > 0, "test1 was seeded with emails");
|
||||
assert!(seeded.contacts > 0, "test1 should be seeded with contacts");
|
||||
assert!(seeded.events > 0, "test1 should be seeded with events");
|
||||
assert!(
|
||||
seeded.file_nodes > 0,
|
||||
"test1 should be seeded with file nodes"
|
||||
);
|
||||
assert!(
|
||||
seeded.address_books > 0,
|
||||
"test1 layout requests an extra address book"
|
||||
);
|
||||
assert!(
|
||||
seeded.calendars > 0,
|
||||
"test1 layout requests an extra calendar"
|
||||
);
|
||||
assert!(seeded.identity, "test1 layout requests a custom identity");
|
||||
assert_eq!(
|
||||
seeded.sieve_active,
|
||||
Some(true),
|
||||
"test1 layout activates a sieve script"
|
||||
);
|
||||
}
|
||||
|
||||
let summary = sync::import_imap::run(common(&archive), imap_basic_config(&acc.localpart))
|
||||
.expect("import");
|
||||
assert!(!summary.any_failed(), "import had failures: {summary:?}");
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
let mailbox_count = count(&conn, "mailboxes") as usize;
|
||||
let email_count = count(&conn, "emails") as usize;
|
||||
let blob_count = count(&conn, "blobs") as usize;
|
||||
|
||||
let seeded = acc.seeded.as_ref().expect("seed stats");
|
||||
assert!(
|
||||
mailbox_count >= seeded.mailboxes_created,
|
||||
"mailboxes {mailbox_count} should cover the seeded tree ({}) plus Stalwart defaults",
|
||||
seeded.mailboxes_created
|
||||
);
|
||||
assert!(email_count > 0, "at least some emails imported");
|
||||
assert!(blob_count > 0, "blobs interned");
|
||||
|
||||
let inbox_role: Option<String> = conn
|
||||
.query_row("SELECT role FROM mailboxes WHERE name = 'INBOX'", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(inbox_role, Some("inbox".to_owned()));
|
||||
|
||||
let archive_role_exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM mailboxes WHERE role = 'archive')",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
archive_role_exists,
|
||||
"seeded archive mailbox surfaces with archive role"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn imap_second_run_is_convergent() {
|
||||
let fx = seeder::provision(base_url()).expect("provision");
|
||||
let acc = fx.account("test2").expect("test2");
|
||||
let archive = tmp_archive("converge");
|
||||
|
||||
sync::import_imap::run(common(&archive), imap_basic_config(&acc.localpart))
|
||||
.expect("first import");
|
||||
|
||||
let summary = sync::import_imap::run(common(&archive), imap_basic_config(&acc.localpart))
|
||||
.expect("second import");
|
||||
for (name, counts) in &summary.per_type {
|
||||
assert_eq!(
|
||||
counts.created, 0,
|
||||
"{name}: convergent run should create nothing"
|
||||
);
|
||||
assert_eq!(
|
||||
counts.deleted, 0,
|
||||
"{name}: convergent run should delete nothing"
|
||||
);
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn imap_and_jmap_imports_share_blob_set() {
|
||||
let fx = seeder::provision(base_url()).expect("provision");
|
||||
let acc = fx.account("test1").expect("test1");
|
||||
let imap_archive = tmp_archive("parity-imap");
|
||||
let jmap_archive = tmp_archive("parity-jmap");
|
||||
|
||||
sync::import_imap::run(common(&imap_archive), imap_basic_config(&acc.localpart))
|
||||
.expect("imap import");
|
||||
|
||||
let jmap_cfg = ImportConfig {
|
||||
connect: ConnectConfig {
|
||||
url: fx.base_url.clone(),
|
||||
auth: Auth::Basic {
|
||||
user: acc.email.clone(),
|
||||
password: seeder::USER_PASSWORD.to_owned(),
|
||||
},
|
||||
account: AccountSelector::Id(acc.account_id.clone()),
|
||||
},
|
||||
objects: None,
|
||||
allow_source_change: false,
|
||||
};
|
||||
let jmap_common = CommonConfig {
|
||||
archive: jmap_archive.clone(),
|
||||
threads: 4,
|
||||
dry_run: false,
|
||||
max_retries: 5,
|
||||
allow_invalid_certs: true,
|
||||
logger: Logger::from_flags(false, 0),
|
||||
};
|
||||
sync::import_jmap::run(jmap_common, jmap_cfg).expect("jmap import");
|
||||
|
||||
let imap_conn = Connection::open(&imap_archive).unwrap();
|
||||
let jmap_conn = Connection::open(&jmap_archive).unwrap();
|
||||
|
||||
let imap_blobs = blob_hashes(&imap_conn);
|
||||
let jmap_blobs = blob_hashes(&jmap_conn);
|
||||
|
||||
let missing: Vec<&Vec<u8>> = imap_blobs.difference(&jmap_blobs).collect();
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"IMAP archive has {} blobs the JMAP archive lacks; bytes differ between protocols",
|
||||
missing.len()
|
||||
);
|
||||
|
||||
let imap_emails = count(&imap_conn, "emails");
|
||||
let jmap_emails = count(&jmap_conn, "emails");
|
||||
assert!(
|
||||
imap_emails >= jmap_emails,
|
||||
"IMAP saw fewer emails ({imap_emails}) than JMAP ({jmap_emails})"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&imap_archive);
|
||||
let _ = std::fs::remove_file(&jmap_archive);
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn imap_import_test3_lands_mailbox_tree_and_emails() {
|
||||
let fx = seeder::provision(base_url()).expect("provision");
|
||||
let acc = fx.account("test3").expect("test3");
|
||||
let archive = tmp_archive("test3");
|
||||
|
||||
let summary = sync::import_imap::run(common(&archive), imap_basic_config(&acc.localpart))
|
||||
.expect("import");
|
||||
assert!(!summary.any_failed(), "import had failures: {summary:?}");
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert!(count(&conn, "mailboxes") > 0);
|
||||
assert!(count(&conn, "blobs") >= 0);
|
||||
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn imap_multi_chunk_metadata_fetch_converges() {
|
||||
let fx = seeder::provision(base_url()).expect("provision");
|
||||
let acc = fx.account("test1").expect("test1");
|
||||
let archive = tmp_archive("multi_chunk");
|
||||
|
||||
let mut cfg = imap_basic_config(&acc.localpart);
|
||||
cfg.fetch_batch = 5;
|
||||
let summary = sync::import_imap::run(common(&archive), cfg).expect("import");
|
||||
assert!(!summary.any_failed(), "import had failures: {summary:?}");
|
||||
|
||||
let mut cfg2 = imap_basic_config(&acc.localpart);
|
||||
cfg2.fetch_batch = 5;
|
||||
let second = sync::import_imap::run(common(&archive), cfg2).expect("second");
|
||||
for (name, c) in &second.per_type {
|
||||
assert_eq!(c.created, 0, "{name}: convergent run creates nothing");
|
||||
assert_eq!(c.deleted, 0, "{name}: convergent run deletes nothing");
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn imap_dry_run_reports_new_diff_without_writing() {
|
||||
let fx = seeder::provision(base_url()).expect("provision");
|
||||
let acc = fx.account("test2").expect("test2");
|
||||
let archive = tmp_archive("dryrun");
|
||||
|
||||
let mut common_cfg = common(&archive);
|
||||
common_cfg.dry_run = true;
|
||||
let summary =
|
||||
sync::import_imap::run(common_cfg, imap_basic_config(&acc.localpart)).expect("dry-run");
|
||||
let mailbox = summary
|
||||
.per_type
|
||||
.iter()
|
||||
.find(|(k, _)| *k == "mailbox")
|
||||
.expect("mailbox counts");
|
||||
assert!(mailbox.1.created > 0, "dry-run reports new mailboxes");
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert_eq!(
|
||||
count(&conn, "mailboxes"),
|
||||
0,
|
||||
"dry-run must not persist mailboxes"
|
||||
);
|
||||
assert_eq!(count(&conn, "emails"), 0, "dry-run must not persist emails");
|
||||
drop(conn);
|
||||
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn imap_imported_archive_exports_via_jmap() {
|
||||
let fx = seeder::provision(base_url()).expect("provision");
|
||||
let src = fx.account("test1").expect("test1");
|
||||
let dst = fx.account("test4").expect("test4");
|
||||
let archive = tmp_archive("roundtrip");
|
||||
|
||||
sync::import_imap::run(common(&archive), imap_basic_config(&src.localpart))
|
||||
.expect("imap import");
|
||||
|
||||
let export_common = CommonConfig {
|
||||
archive: archive.clone(),
|
||||
threads: 4,
|
||||
dry_run: false,
|
||||
max_retries: 5,
|
||||
allow_invalid_certs: true,
|
||||
logger: Logger::from_flags(false, 0),
|
||||
};
|
||||
let export_cfg = ExportConfig {
|
||||
connect: ConnectConfig {
|
||||
url: fx.base_url.clone(),
|
||||
auth: Auth::Basic {
|
||||
user: dst.email.clone(),
|
||||
password: seeder::USER_PASSWORD.to_owned(),
|
||||
},
|
||||
account: AccountSelector::Id(dst.account_id.clone()),
|
||||
},
|
||||
objects: None,
|
||||
prune: false,
|
||||
yes: false,
|
||||
};
|
||||
let first = sync::export::run(export_common, export_cfg).expect("first export");
|
||||
assert!(!first.any_failed(), "first export had failures: {first:?}");
|
||||
|
||||
let export_common2 = CommonConfig {
|
||||
archive: archive.clone(),
|
||||
threads: 4,
|
||||
dry_run: false,
|
||||
max_retries: 5,
|
||||
allow_invalid_certs: true,
|
||||
logger: Logger::from_flags(false, 0),
|
||||
};
|
||||
let export_cfg2 = ExportConfig {
|
||||
connect: ConnectConfig {
|
||||
url: fx.base_url.clone(),
|
||||
auth: Auth::Basic {
|
||||
user: dst.email.clone(),
|
||||
password: seeder::USER_PASSWORD.to_owned(),
|
||||
},
|
||||
account: AccountSelector::Id(dst.account_id.clone()),
|
||||
},
|
||||
objects: None,
|
||||
prune: false,
|
||||
yes: false,
|
||||
};
|
||||
let second = sync::export::run(export_common2, export_cfg2).expect("second export");
|
||||
for (name, counts) in &second.per_type {
|
||||
assert_eq!(
|
||||
counts.created, 0,
|
||||
"{name}: round-trip second export should create nothing"
|
||||
);
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
+1150
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,651 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
#![allow(dead_code, clippy::needless_borrows_for_generic_args)]
|
||||
|
||||
mod integration;
|
||||
mod seeder;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
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_maildir::{MaildirImportConfig, run as run_maildir};
|
||||
use vandelay::sync::{self, CommonConfig, ConnectConfig, ImportConfig};
|
||||
|
||||
fn tmp_archive(tag: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!(
|
||||
"vandelay-sync-maildir-{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: 1,
|
||||
dry_run: false,
|
||||
max_retries: 1,
|
||||
allow_invalid_certs: true,
|
||||
logger: Logger::from_flags(true, 0),
|
||||
}
|
||||
}
|
||||
|
||||
fn base_cfg(root: &Path) -> MaildirImportConfig {
|
||||
MaildirImportConfig {
|
||||
maildir: root.to_path_buf(),
|
||||
include: Vec::new(),
|
||||
exclude: Vec::new(),
|
||||
folder: Vec::new(),
|
||||
automap: true,
|
||||
include_deleted: false,
|
||||
allow_source_change: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_maildir(root: &Path) {
|
||||
for sub in ["cur", "new", "tmp"] {
|
||||
fs::create_dir_all(root.join(sub)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_subfolder(root: &Path, dotted: &str) -> PathBuf {
|
||||
let sub = root.join(dotted);
|
||||
for s in ["cur", "new", "tmp"] {
|
||||
fs::create_dir_all(sub.join(s)).unwrap();
|
||||
}
|
||||
sub
|
||||
}
|
||||
|
||||
fn write(folder: &Path, sub: &str, filename: &str, body: &[u8]) -> PathBuf {
|
||||
let dir = folder.join(sub);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join(filename);
|
||||
fs::write(&p, body).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
fn msg(id: &str, subject: &str, body: &str) -> Vec<u8> {
|
||||
format!(
|
||||
"From: [email protected]\r\nTo: [email protected]\r\n\
|
||||
Subject: {subject}\r\nMessage-ID: <{id}@example.com>\r\n\
|
||||
Date: Mon, 12 May 2025 10:00:00 +0000\r\n\r\n{body}\r\n"
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn build_fixture(root: &Path) {
|
||||
ensure_maildir(root);
|
||||
write(
|
||||
root,
|
||||
"cur",
|
||||
"100.M001.host:2,S",
|
||||
&msg("conv-i1", "i1", "body 1"),
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"cur",
|
||||
"101.M002.host:2,",
|
||||
&msg("conv-i2", "i2", "body 2"),
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"new",
|
||||
"102.M003.host",
|
||||
&msg("conv-i3", "i3", "body 3"),
|
||||
);
|
||||
let sent = ensure_subfolder(root, ".Sent");
|
||||
write(
|
||||
&sent,
|
||||
"cur",
|
||||
"200.M001.host:2,S",
|
||||
&msg("conv-s1", "sent-1", "sent body"),
|
||||
);
|
||||
ensure_subfolder(root, ".Archive");
|
||||
let arch25 = ensure_subfolder(root, ".Archive.2025");
|
||||
write(
|
||||
&arch25,
|
||||
"cur",
|
||||
"300.M001.host:2,S",
|
||||
&msg("conv-a1", "arch-1", "arch body 1"),
|
||||
);
|
||||
write(
|
||||
&arch25,
|
||||
"cur",
|
||||
"301.M002.host:2,SF",
|
||||
&msg("conv-a2", "arch-2", "arch body 2"),
|
||||
);
|
||||
}
|
||||
|
||||
fn count(conn: &Connection, table: &str) -> i64 {
|
||||
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn snapshot_hashes_by_folder(conn: &Connection) -> BTreeMap<String, BTreeMap<String, String>> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT m.folder, b.hash, e.keywords
|
||||
FROM emails e
|
||||
JOIN blobs b ON b.id = e.blob_id
|
||||
JOIN sync_id_maildir m ON m.local_id = e.id AND m.type_name = 'email'
|
||||
ORDER BY m.folder, m.unique_id",
|
||||
)
|
||||
.unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
let folder: String = r.get(0)?;
|
||||
let hash: Vec<u8> = r.get(1)?;
|
||||
let kw: String = r.get(2)?;
|
||||
Ok((folder, hex(&hash), kw))
|
||||
})
|
||||
.unwrap();
|
||||
let mut out: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
|
||||
for r in rows {
|
||||
let (folder, hash, kw) = r.unwrap();
|
||||
out.entry(folder).or_default().insert(hash, kw);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(s, "{b:02x}");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_run_with_no_changes_creates_nothing_and_deletes_nothing() {
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
build_fixture(td.path());
|
||||
let archive = tmp_archive("idempotent");
|
||||
let first = run_maildir(common(&archive), base_cfg(td.path())).expect("first");
|
||||
assert!(!first.any_failed());
|
||||
let (_, mbox_first) = &first.per_type[0];
|
||||
let (_, email_first) = &first.per_type[1];
|
||||
assert!(mbox_first.created >= 4);
|
||||
assert_eq!(email_first.created, 6);
|
||||
|
||||
let second = run_maildir(common(&archive), base_cfg(td.path())).expect("second");
|
||||
let (_, mbox_second) = &second.per_type[0];
|
||||
let (_, email_second) = &second.per_type[1];
|
||||
assert_eq!(mbox_second.created, 0);
|
||||
assert_eq!(mbox_second.deleted, 0);
|
||||
assert_eq!(email_second.created, 0);
|
||||
assert_eq!(email_second.deleted, 0);
|
||||
assert_eq!(email_second.fetched, 0, "no body re-read on convergent run");
|
||||
assert!(email_second.skipped >= 6);
|
||||
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flag_change_between_runs_updates_keywords_only() {
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
ensure_maildir(td.path());
|
||||
let path = write(
|
||||
td.path(),
|
||||
"cur",
|
||||
"1.M0.host:2,",
|
||||
&msg("flag-1", "subj", "body"),
|
||||
);
|
||||
let archive = tmp_archive("flag-update");
|
||||
run_maildir(common(&archive), base_cfg(td.path())).expect("first");
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
let email_id: i64 = conn
|
||||
.query_row("SELECT id FROM emails LIMIT 1", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let blob_id: i64 = conn
|
||||
.query_row(
|
||||
"SELECT blob_id FROM emails WHERE id = ?1",
|
||||
[email_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
let kw: String = conn
|
||||
.query_row(
|
||||
"SELECT keywords FROM emails WHERE id = ?1",
|
||||
[email_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!kw.contains("$seen"));
|
||||
drop(conn);
|
||||
|
||||
fs::rename(&path, path.with_file_name("1.M0.host:2,S")).unwrap();
|
||||
|
||||
let summary = run_maildir(common(&archive), base_cfg(td.path())).expect("second");
|
||||
let (_, email_counts) = &summary.per_type[1];
|
||||
assert_eq!(email_counts.created, 0);
|
||||
assert_eq!(email_counts.deleted, 0);
|
||||
assert_eq!(email_counts.fetched, 1, "exactly one flag-only update");
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
let new_id: i64 = conn
|
||||
.query_row("SELECT id FROM emails LIMIT 1", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let new_blob_id: i64 = conn
|
||||
.query_row("SELECT blob_id FROM emails WHERE id = ?1", [new_id], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
let new_kw: String = conn
|
||||
.query_row("SELECT keywords FROM emails WHERE id = ?1", [new_id], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(new_id, email_id, "row identity preserved");
|
||||
assert_eq!(new_blob_id, blob_id, "blob is unchanged");
|
||||
assert!(new_kw.contains("$seen"));
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_on_disk_removes_email_row_and_orphan_blob() {
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
ensure_maildir(td.path());
|
||||
let p1 = write(
|
||||
td.path(),
|
||||
"cur",
|
||||
"1.M0.host:2,",
|
||||
&msg("del-1", "subj-1", "body-1"),
|
||||
);
|
||||
write(
|
||||
td.path(),
|
||||
"cur",
|
||||
"2.M0.host:2,",
|
||||
&msg("del-2", "subj-2", "body-2"),
|
||||
);
|
||||
let archive = tmp_archive("delete");
|
||||
run_maildir(common(&archive), base_cfg(td.path())).expect("first");
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert_eq!(count(&conn, "emails"), 2);
|
||||
assert_eq!(count(&conn, "blobs"), 2);
|
||||
drop(conn);
|
||||
|
||||
fs::remove_file(&p1).unwrap();
|
||||
let summary = run_maildir(common(&archive), base_cfg(td.path())).expect("second");
|
||||
let (_, email_counts) = &summary.per_type[1];
|
||||
assert_eq!(email_counts.deleted, 1);
|
||||
assert_eq!(email_counts.created, 0);
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert_eq!(count(&conn, "emails"), 1);
|
||||
|
||||
assert_eq!(count(&conn, "blobs"), 1);
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_on_disk_inserts_one_new_email_only() {
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
ensure_maildir(td.path());
|
||||
write(td.path(), "cur", "1.M0.host:2,", &msg("a-1", "1", "body"));
|
||||
let archive = tmp_archive("add");
|
||||
run_maildir(common(&archive), base_cfg(td.path())).expect("first");
|
||||
|
||||
write(td.path(), "cur", "2.M0.host:2,", &msg("a-2", "2", "body2"));
|
||||
let summary = run_maildir(common(&archive), base_cfg(td.path())).expect("second");
|
||||
let (_, email_counts) = &summary.per_type[1];
|
||||
assert_eq!(email_counts.created, 1);
|
||||
assert_eq!(email_counts.deleted, 0);
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_delete_vanishes_emails_leaf_first() {
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
ensure_maildir(td.path());
|
||||
ensure_subfolder(td.path(), ".Archive");
|
||||
let arch25 = ensure_subfolder(td.path(), ".Archive.2025");
|
||||
write(&arch25, "cur", "1.M0.host:2,", &msg("d-1", "del-1", "del"));
|
||||
let archive = tmp_archive("folder-delete");
|
||||
run_maildir(common(&archive), base_cfg(td.path())).expect("first");
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert!(
|
||||
conn.query_row("SELECT 1 FROM mailboxes WHERE name = '2025'", [], |r| r
|
||||
.get::<_, i64>(0))
|
||||
.is_ok()
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
fs::remove_dir_all(td.path().join(".Archive.2025")).unwrap();
|
||||
let summary = run_maildir(common(&archive), base_cfg(td.path())).expect("second");
|
||||
let (_, mbox_counts) = &summary.per_type[0];
|
||||
let (_, email_counts) = &summary.per_type[1];
|
||||
assert_eq!(mbox_counts.deleted, 1);
|
||||
assert_eq!(email_counts.deleted, 1);
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert!(
|
||||
conn.query_row("SELECT 1 FROM mailboxes WHERE name = '2025'", [], |r| r
|
||||
.get::<_, i64>(0))
|
||||
.is_err()
|
||||
);
|
||||
|
||||
assert!(
|
||||
conn.query_row("SELECT 1 FROM mailboxes WHERE name = 'Archive'", [], |r| {
|
||||
r.get::<_, i64>(0)
|
||||
})
|
||||
.is_ok()
|
||||
);
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_rename_vanishes_old_and_inserts_new() {
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
ensure_maildir(td.path());
|
||||
let old = ensure_subfolder(td.path(), ".Old");
|
||||
write(
|
||||
&old,
|
||||
"cur",
|
||||
"1.M0.host:2,",
|
||||
&msg("r-1", "to-rename", "body"),
|
||||
);
|
||||
let archive = tmp_archive("folder-rename");
|
||||
run_maildir(common(&archive), base_cfg(td.path())).expect("first");
|
||||
|
||||
fs::rename(td.path().join(".Old"), td.path().join(".New")).unwrap();
|
||||
let summary = run_maildir(common(&archive), base_cfg(td.path())).expect("second");
|
||||
let (_, mbox_counts) = &summary.per_type[0];
|
||||
let (_, email_counts) = &summary.per_type[1];
|
||||
assert_eq!(mbox_counts.deleted, 1);
|
||||
assert_eq!(mbox_counts.created, 1);
|
||||
|
||||
assert_eq!(email_counts.deleted, 1);
|
||||
assert_eq!(email_counts.created, 1);
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert_eq!(count(&conn, "blobs"), 1, "blob retained via dedup");
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
fn cwd_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
M.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_path_resolves_to_same_canonical_source() {
|
||||
let _g = cwd_lock();
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
build_fixture(td.path());
|
||||
let archive = tmp_archive("canonical");
|
||||
run_maildir(common(&archive), base_cfg(td.path())).expect("first import");
|
||||
|
||||
let parent = td.path().parent().unwrap();
|
||||
let dir_name = td.path().file_name().unwrap();
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
std::env::set_current_dir(parent).unwrap();
|
||||
let mut second = base_cfg(Path::new(dir_name));
|
||||
second.allow_source_change = false;
|
||||
let result = run_maildir(common(&archive), second);
|
||||
std::env::set_current_dir(cwd).unwrap();
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_then_real_run_produces_same_counts_for_new() {
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
build_fixture(td.path());
|
||||
let archive = tmp_archive("dry-then-real");
|
||||
|
||||
let mut dry = common(&archive);
|
||||
dry.dry_run = true;
|
||||
let dry_summary = run_maildir(dry, base_cfg(td.path())).expect("dry");
|
||||
let (_, dry_email) = &dry_summary.per_type[1];
|
||||
let new_via_dry = dry_email.created;
|
||||
|
||||
let real_summary = run_maildir(common(&archive), base_cfg(td.path())).expect("real run");
|
||||
let (_, real_email) = &real_summary.per_type[1];
|
||||
assert_eq!(new_via_dry, real_email.created);
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trashed_flag_added_between_runs_deletes_present_row() {
|
||||
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
ensure_maildir(td.path());
|
||||
let path = write(
|
||||
td.path(),
|
||||
"cur",
|
||||
"trash.M0.host:2,",
|
||||
&msg("t-1", "subj", "body"),
|
||||
);
|
||||
let archive = tmp_archive("trash-on-present");
|
||||
run_maildir(common(&archive), base_cfg(td.path())).expect("first");
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert_eq!(count(&conn, "emails"), 1);
|
||||
drop(conn);
|
||||
|
||||
fs::rename(&path, path.with_file_name("trash.M0.host:2,T")).unwrap();
|
||||
let summary = run_maildir(common(&archive), base_cfg(td.path())).expect("second");
|
||||
let (_, email_counts) = &summary.per_type[1];
|
||||
assert_eq!(email_counts.deleted, 1, "T flag drops the present row");
|
||||
assert_eq!(email_counts.fetched, 0);
|
||||
assert_eq!(email_counts.created, 0);
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert_eq!(count(&conn, "emails"), 0);
|
||||
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlinked_subfolder_is_followed_and_appears_as_its_own_folder() {
|
||||
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
ensure_maildir(td.path());
|
||||
let real = ensure_subfolder(td.path(), ".Real");
|
||||
write(
|
||||
&real,
|
||||
"cur",
|
||||
"1.M0.host:2,S",
|
||||
&msg("sym-1", "shared", "shared body"),
|
||||
);
|
||||
std::os::unix::fs::symlink(&real, td.path().join(".Shared")).unwrap();
|
||||
let archive = tmp_archive("symlink");
|
||||
let summary = run_maildir(common(&archive), base_cfg(td.path())).expect("import");
|
||||
assert!(!summary.any_failed(), "{summary:?}");
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert!(
|
||||
conn.query_row("SELECT 1 FROM mailboxes WHERE name = 'Real'", [], |r| r
|
||||
.get::<_, i64>(0))
|
||||
.is_ok(),
|
||||
"Real folder discovered"
|
||||
);
|
||||
assert!(
|
||||
conn.query_row("SELECT 1 FROM mailboxes WHERE name = 'Shared'", [], |r| r
|
||||
.get::<_, i64>(
|
||||
0
|
||||
))
|
||||
.is_ok(),
|
||||
"Shared (symlink) folder discovered"
|
||||
);
|
||||
|
||||
assert_eq!(count(&conn, "blobs"), 1);
|
||||
assert_eq!(count(&conn, "emails"), 2);
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convergence_is_stable_across_three_runs() {
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
build_fixture(td.path());
|
||||
let archive = tmp_archive("triple-converge");
|
||||
run_maildir(common(&archive), base_cfg(td.path())).expect("r1");
|
||||
let snap1 = {
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
snapshot_hashes_by_folder(&conn)
|
||||
};
|
||||
run_maildir(common(&archive), base_cfg(td.path())).expect("r2");
|
||||
let snap2 = {
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
snapshot_hashes_by_folder(&conn)
|
||||
};
|
||||
run_maildir(common(&archive), base_cfg(td.path())).expect("r3");
|
||||
let snap3 = {
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
snapshot_hashes_by_folder(&conn)
|
||||
};
|
||||
assert_eq!(snap1, snap2);
|
||||
assert_eq!(snap2, snap3);
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
fn lay_seeder_mbox_as_maildir(root: &Path, messages: &[seeder::data::MboxMessage]) {
|
||||
ensure_maildir(root);
|
||||
for (i, m) in messages.iter().enumerate() {
|
||||
let filename = format!("{seq}.M0.parity-host:2,S", seq = 1_700_000_000 + i as u64);
|
||||
write(root, "cur", &filename, &m.raw);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn maildir_and_jmap_imports_share_blob_bytes() {
|
||||
let fx = seeder::provision(base_url()).expect("provision");
|
||||
let acc = fx.account("test1").expect("test1");
|
||||
|
||||
let corpus = seeder::data::load_mbox(50).expect("mbox corpus");
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
lay_seeder_mbox_as_maildir(td.path(), &corpus);
|
||||
|
||||
let md_archive = tmp_archive("parity-maildir");
|
||||
let jmap_archive = tmp_archive("parity-jmap");
|
||||
|
||||
run_maildir(common(&md_archive), base_cfg(td.path())).expect("maildir import");
|
||||
|
||||
let jmap_cfg = ImportConfig {
|
||||
connect: ConnectConfig {
|
||||
url: fx.base_url.clone(),
|
||||
auth: Auth::Basic {
|
||||
user: acc.email.clone(),
|
||||
password: seeder::USER_PASSWORD.to_owned(),
|
||||
},
|
||||
account: AccountSelector::Id(acc.account_id.clone()),
|
||||
},
|
||||
objects: Some(vec![
|
||||
vandelay::types::ObjectType::Mailbox,
|
||||
vandelay::types::ObjectType::Email,
|
||||
]),
|
||||
allow_source_change: false,
|
||||
};
|
||||
let jmap_common = CommonConfig {
|
||||
archive: jmap_archive.clone(),
|
||||
threads: 4,
|
||||
dry_run: false,
|
||||
max_retries: 5,
|
||||
allow_invalid_certs: true,
|
||||
logger: Logger::from_flags(false, 0),
|
||||
};
|
||||
sync::import_jmap::run(jmap_common, jmap_cfg).expect("jmap import");
|
||||
|
||||
let md_hashes = blob_hashes(&Connection::open(&md_archive).unwrap());
|
||||
let jmap_hashes = blob_hashes(&Connection::open(&jmap_archive).unwrap());
|
||||
|
||||
assert!(
|
||||
!md_hashes.is_empty(),
|
||||
"maildir archive must have at least one blob"
|
||||
);
|
||||
let missing: Vec<&String> = md_hashes
|
||||
.iter()
|
||||
.filter(|h| !jmap_hashes.contains(*h))
|
||||
.collect();
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"{} blobs in maildir archive missing from JMAP archive: {:?}",
|
||||
missing.len(),
|
||||
missing.iter().take(3).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&md_archive);
|
||||
let _ = std::fs::remove_file(&jmap_archive);
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
|
||||
fn blob_hashes(conn: &Connection) -> std::collections::HashSet<String> {
|
||||
let mut stmt = conn.prepare("SELECT hash FROM blobs").unwrap();
|
||||
stmt.query_map([], |r| Ok(hex(&r.get::<_, Vec<u8>>(0)?)))
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn maildir_message_count_matches_jmap_for_same_corpus() {
|
||||
|
||||
let fx = seeder::provision(base_url()).expect("provision");
|
||||
let acc = fx.account("test1").expect("test1");
|
||||
let corpus = seeder::data::load_mbox(30).expect("mbox corpus");
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
lay_seeder_mbox_as_maildir(td.path(), &corpus);
|
||||
|
||||
let md_archive = tmp_archive("count-maildir");
|
||||
run_maildir(common(&md_archive), base_cfg(td.path())).expect("maildir import");
|
||||
let md_conn = Connection::open(&md_archive).unwrap();
|
||||
let md_count = count(&md_conn, "emails");
|
||||
assert_eq!(
|
||||
md_count as usize,
|
||||
corpus.len(),
|
||||
"all corpus messages imported"
|
||||
);
|
||||
|
||||
let jmap_archive = tmp_archive("count-jmap");
|
||||
let jmap_cfg = ImportConfig {
|
||||
connect: ConnectConfig {
|
||||
url: fx.base_url.clone(),
|
||||
auth: Auth::Basic {
|
||||
user: acc.email.clone(),
|
||||
password: seeder::USER_PASSWORD.to_owned(),
|
||||
},
|
||||
account: AccountSelector::Id(acc.account_id.clone()),
|
||||
},
|
||||
objects: Some(vec![
|
||||
vandelay::types::ObjectType::Mailbox,
|
||||
vandelay::types::ObjectType::Email,
|
||||
]),
|
||||
allow_source_change: false,
|
||||
};
|
||||
let jmap_common = CommonConfig {
|
||||
archive: jmap_archive.clone(),
|
||||
threads: 4,
|
||||
dry_run: false,
|
||||
max_retries: 5,
|
||||
allow_invalid_certs: true,
|
||||
logger: Logger::from_flags(false, 0),
|
||||
};
|
||||
sync::import_jmap::run(jmap_common, jmap_cfg).expect("jmap import");
|
||||
|
||||
let _ = std::fs::remove_file(&md_archive);
|
||||
let _ = std::fs::remove_file(&jmap_archive);
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
fn base_url() -> &'static str {
|
||||
shared_stalwart().base_url()
|
||||
}
|
||||
|
||||
fn sieve_url() -> String {
|
||||
let s = shared_stalwart();
|
||||
format!("sieve://{}:{}", s.host, s.sieve_port)
|
||||
}
|
||||
|
||||
fn sieves_url() -> String {
|
||||
let s = shared_stalwart();
|
||||
format!("sieves://{}:{}", s.host, s.sieve_port)
|
||||
}
|
||||
use vandelay::jmap::account::AccountSelector;
|
||||
use vandelay::jmap::http::Auth;
|
||||
use vandelay::logging::Logger;
|
||||
use vandelay::sync::import_managesieve::{ManageSieveAuth, ManageSieveImportConfig};
|
||||
use vandelay::sync::{self, CommonConfig, ConnectConfig, ImportConfig};
|
||||
|
||||
fn tmp_archive(tag: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!(
|
||||
"vandelay-managesieve-{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: 1,
|
||||
dry_run: false,
|
||||
max_retries: 3,
|
||||
allow_invalid_certs: true,
|
||||
logger: Logger::from_flags(false, 0),
|
||||
}
|
||||
}
|
||||
|
||||
fn basic_config(localpart: &str) -> ManageSieveImportConfig {
|
||||
ManageSieveImportConfig {
|
||||
url: sieve_url(),
|
||||
auth: ManageSieveAuth::Basic {
|
||||
user: format!("{localpart}@{}", seeder::DOMAIN),
|
||||
password: seeder::USER_PASSWORD.to_owned(),
|
||||
},
|
||||
allow_cleartext: false,
|
||||
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 managesieve_import_test1_lands_active_sieve_script() {
|
||||
let fx = seeder::provision(base_url()).expect("provision");
|
||||
let acc = fx.account("test1").expect("test1");
|
||||
let archive = tmp_archive("test1");
|
||||
|
||||
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!(!fx.base_url.is_empty(), "base url set");
|
||||
assert!(!acc.account_id.is_empty(), "JMAP account id resolved");
|
||||
assert!(!acc.email.is_empty());
|
||||
assert!(!acc.admin_role, "test1 is a regular user");
|
||||
if let Some(seeded) = &acc.seeded {
|
||||
assert_eq!(
|
||||
seeded.sieve_active,
|
||||
Some(true),
|
||||
"test1 layout activates a sieve"
|
||||
);
|
||||
assert!(seeded.emails > 0);
|
||||
assert!(seeded.contacts > 0);
|
||||
assert!(seeded.events > 0);
|
||||
assert!(seeded.file_nodes > 0);
|
||||
assert!(seeded.address_books > 0);
|
||||
assert!(seeded.calendars > 0);
|
||||
assert!(seeded.identity);
|
||||
assert!(seeded.mailboxes_created > 0);
|
||||
}
|
||||
assert_eq!(acc.password, seeder::USER_PASSWORD);
|
||||
|
||||
let summary = sync::import_managesieve::run(common(&archive), basic_config(&acc.localpart))
|
||||
.expect("import");
|
||||
assert!(!summary.any_failed(), "import had failures: {summary:?}");
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
let scripts = count(&conn, "sieve_scripts");
|
||||
assert!(scripts >= 2, "test1 layout seeds two scripts");
|
||||
|
||||
let active: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM sieve_scripts WHERE is_active = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(active, 1, "exactly one of test1's two scripts is active");
|
||||
|
||||
let blob_count = count(&conn, "blobs");
|
||||
assert!(blob_count >= 1, "blob interned for the script body");
|
||||
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn managesieve_import_test2_lands_inactive_sieve_script() {
|
||||
let fx = seeder::provision(base_url()).expect("provision");
|
||||
let acc = fx.account("test2").expect("test2");
|
||||
let archive = tmp_archive("test2");
|
||||
|
||||
let summary = sync::import_managesieve::run(common(&archive), basic_config(&acc.localpart))
|
||||
.expect("import");
|
||||
assert!(!summary.any_failed(), "import had failures: {summary:?}");
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
let scripts = count(&conn, "sieve_scripts");
|
||||
assert!(scripts >= 1, "expected at least one sieve script for test2");
|
||||
let active: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM sieve_scripts WHERE is_active = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(active, 0, "test2's seeded script should be inactive");
|
||||
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn managesieve_import_test3_has_no_scripts() {
|
||||
let fx = seeder::provision(base_url()).expect("provision");
|
||||
let acc = fx.account("test3").expect("test3");
|
||||
let archive = tmp_archive("test3");
|
||||
|
||||
let summary = sync::import_managesieve::run(common(&archive), basic_config(&acc.localpart))
|
||||
.expect("import");
|
||||
assert!(!summary.any_failed(), "import had failures: {summary:?}");
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert_eq!(count(&conn, "sieve_scripts"), 0);
|
||||
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn managesieve_second_run_is_convergent() {
|
||||
let fx = seeder::provision(base_url()).expect("provision");
|
||||
let acc = fx.account("test1").expect("test1");
|
||||
let archive = tmp_archive("converge");
|
||||
|
||||
let first = sync::import_managesieve::run(common(&archive), basic_config(&acc.localpart))
|
||||
.expect("first import");
|
||||
let (_, c1) = &first.per_type[0];
|
||||
assert!(
|
||||
c1.created >= 2,
|
||||
"test1 seeds two scripts, both new on first run"
|
||||
);
|
||||
|
||||
let summary = sync::import_managesieve::run(common(&archive), basic_config(&acc.localpart))
|
||||
.expect("second import");
|
||||
let (_, c) = &summary.per_type[0];
|
||||
assert_eq!(c.created, 0, "convergent run should create nothing");
|
||||
assert_eq!(c.deleted, 0, "convergent run should delete nothing");
|
||||
assert_eq!(c.fetched, 0, "convergent run should not rewrite blobs");
|
||||
assert!(c.skipped >= 2, "both present scripts should be skipped");
|
||||
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn managesieve_and_jmap_imports_share_blob_bytes() {
|
||||
|
||||
let fx = seeder::provision(base_url()).expect("provision");
|
||||
let acc = fx.account("test1").expect("test1");
|
||||
let msieve_archive = tmp_archive("parity-msieve");
|
||||
let jmap_archive = tmp_archive("parity-jmap");
|
||||
|
||||
sync::import_managesieve::run(common(&msieve_archive), basic_config(&acc.localpart))
|
||||
.expect("managesieve import");
|
||||
|
||||
let jmap_cfg = ImportConfig {
|
||||
connect: ConnectConfig {
|
||||
url: fx.base_url.clone(),
|
||||
auth: Auth::Basic {
|
||||
user: acc.email.clone(),
|
||||
password: seeder::USER_PASSWORD.to_owned(),
|
||||
},
|
||||
account: AccountSelector::Id(acc.account_id.clone()),
|
||||
},
|
||||
objects: Some(vec![vandelay::types::ObjectType::SieveScript]),
|
||||
allow_source_change: false,
|
||||
};
|
||||
let jmap_common = CommonConfig {
|
||||
archive: jmap_archive.clone(),
|
||||
threads: 4,
|
||||
dry_run: false,
|
||||
max_retries: 5,
|
||||
allow_invalid_certs: true,
|
||||
logger: Logger::from_flags(false, 0),
|
||||
};
|
||||
sync::import_jmap::run(jmap_common, jmap_cfg).expect("jmap import");
|
||||
|
||||
let msieve_conn = Connection::open(&msieve_archive).unwrap();
|
||||
let jmap_conn = Connection::open(&jmap_archive).unwrap();
|
||||
|
||||
fn name_hash_active(conn: &Connection) -> Vec<(String, Vec<u8>, bool)> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT s.name, b.hash, s.is_active
|
||||
FROM sieve_scripts s JOIN blobs b ON b.id = s.blob_id
|
||||
ORDER BY s.name",
|
||||
)
|
||||
.unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
let name: String = r.get(0)?;
|
||||
let hash: Vec<u8> = r.get(1)?;
|
||||
let active: i64 = r.get(2)?;
|
||||
Ok((name, hash, active != 0))
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
}
|
||||
|
||||
let msieve = name_hash_active(&msieve_conn);
|
||||
let jmap = name_hash_active(&jmap_conn);
|
||||
assert!(
|
||||
!msieve.is_empty(),
|
||||
"managesieve archive must have at least one script row"
|
||||
);
|
||||
assert_eq!(
|
||||
msieve, jmap,
|
||||
"every script's (name, blake3, is_active) must match across protocols"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&msieve_archive);
|
||||
let _ = std::fs::remove_file(&jmap_archive);
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn managesieve_dry_run_reports_diff_without_writing() {
|
||||
let fx = seeder::provision(base_url()).expect("provision");
|
||||
let acc = fx.account("test1").expect("test1");
|
||||
let archive = tmp_archive("dryrun");
|
||||
|
||||
let mut common_cfg = common(&archive);
|
||||
common_cfg.dry_run = true;
|
||||
let summary =
|
||||
sync::import_managesieve::run(common_cfg, basic_config(&acc.localpart)).expect("dryrun");
|
||||
let (_, c) = &summary.per_type[0];
|
||||
assert!(
|
||||
c.created >= 1,
|
||||
"expected at least one 'new' in dry-run plan"
|
||||
);
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert_eq!(
|
||||
count(&conn, "sieve_scripts"),
|
||||
0,
|
||||
"dry-run must not write rows"
|
||||
);
|
||||
assert_eq!(count(&conn, "blobs"), 0, "dry-run must not intern blobs");
|
||||
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn managesieve_source_change_protection_refuses_second_account() {
|
||||
|
||||
let fx = seeder::provision(base_url()).expect("provision");
|
||||
let acc1 = fx.account("test1").expect("test1");
|
||||
let acc2 = fx.account("test2").expect("test2");
|
||||
let archive = tmp_archive("source_change");
|
||||
|
||||
sync::import_managesieve::run(common(&archive), basic_config(&acc1.localpart))
|
||||
.expect("first import");
|
||||
|
||||
let err = sync::import_managesieve::run(common(&archive), basic_config(&acc2.localpart))
|
||||
.expect_err("should refuse the second source without override");
|
||||
assert!(matches!(err, vandelay::error::Error::SourceChange(_)));
|
||||
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn managesieve_implicit_tls_path_succeeds_when_offered() {
|
||||
|
||||
let fx = seeder::provision(base_url()).expect("provision");
|
||||
let acc = fx.account("test1").expect("test1");
|
||||
let archive = tmp_archive("implicit_tls");
|
||||
|
||||
let mut cfg = basic_config(&acc.localpart);
|
||||
cfg.url = sieves_url();
|
||||
let result = sync::import_managesieve::run(common(&archive), cfg);
|
||||
|
||||
if let Err(vandelay::error::Error::Connection(msg)) = &result {
|
||||
eprintln!("(expected on cleartext-only deployments) {msg}");
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn managesieve_round_trip_via_jmap_export_converges() {
|
||||
|
||||
let fx = seeder::provision(base_url()).expect("provision");
|
||||
let src = fx.account("test1").expect("test1");
|
||||
let dst = fx.account("test4").expect("test4");
|
||||
let archive = tmp_archive("roundtrip");
|
||||
|
||||
sync::import_managesieve::run(common(&archive), basic_config(&src.localpart))
|
||||
.expect("managesieve import");
|
||||
|
||||
let import_conn = Connection::open(&archive).unwrap();
|
||||
let imported_scripts = count(&import_conn, "sieve_scripts");
|
||||
assert!(
|
||||
imported_scripts >= 1,
|
||||
"expected at least one script in the source archive"
|
||||
);
|
||||
let imported_active: i64 = import_conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM sieve_scripts WHERE is_active = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
imported_active, 1,
|
||||
"test1 should have exactly one active script"
|
||||
);
|
||||
drop(import_conn);
|
||||
|
||||
let export_common = CommonConfig {
|
||||
archive: archive.clone(),
|
||||
threads: 4,
|
||||
dry_run: false,
|
||||
max_retries: 5,
|
||||
allow_invalid_certs: true,
|
||||
logger: Logger::from_flags(false, 0),
|
||||
};
|
||||
let export_cfg = vandelay::sync::ExportConfig {
|
||||
connect: ConnectConfig {
|
||||
url: fx.base_url.clone(),
|
||||
auth: Auth::Basic {
|
||||
user: dst.email.clone(),
|
||||
password: seeder::USER_PASSWORD.to_owned(),
|
||||
},
|
||||
account: AccountSelector::Id(dst.account_id.clone()),
|
||||
},
|
||||
objects: Some(vec![vandelay::types::ObjectType::SieveScript]),
|
||||
prune: false,
|
||||
yes: false,
|
||||
};
|
||||
let first = sync::export::run(export_common, export_cfg).expect("first export");
|
||||
assert!(!first.any_failed(), "first export had failures: {first:?}");
|
||||
let created_first: u64 = first
|
||||
.per_type
|
||||
.iter()
|
||||
.filter(|(k, _)| *k == "SieveScript")
|
||||
.map(|(_, c)| c.created)
|
||||
.sum();
|
||||
assert_eq!(
|
||||
created_first as i64, imported_scripts,
|
||||
"first export should create every imported script on the target ({first:?})"
|
||||
);
|
||||
|
||||
let export_common2 = CommonConfig {
|
||||
archive: archive.clone(),
|
||||
threads: 4,
|
||||
dry_run: false,
|
||||
max_retries: 5,
|
||||
allow_invalid_certs: true,
|
||||
logger: Logger::from_flags(false, 0),
|
||||
};
|
||||
let export_cfg2 = vandelay::sync::ExportConfig {
|
||||
connect: ConnectConfig {
|
||||
url: fx.base_url.clone(),
|
||||
auth: Auth::Basic {
|
||||
user: dst.email.clone(),
|
||||
password: seeder::USER_PASSWORD.to_owned(),
|
||||
},
|
||||
account: AccountSelector::Id(dst.account_id.clone()),
|
||||
},
|
||||
objects: Some(vec![vandelay::types::ObjectType::SieveScript]),
|
||||
prune: false,
|
||||
yes: false,
|
||||
};
|
||||
let second = sync::export::run(export_common2, export_cfg2).expect("second export");
|
||||
for (name, counts) in &second.per_type {
|
||||
assert_eq!(
|
||||
counts.created, 0,
|
||||
"{name}: round-trip second export should create nothing"
|
||||
);
|
||||
assert_eq!(
|
||||
counts.deleted, 0,
|
||||
"{name}: round-trip second export should delete nothing"
|
||||
);
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rusqlite::Connection;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use vandelay::logging::Logger;
|
||||
use vandelay::sync::CommonConfig;
|
||||
use vandelay::sync::import_takeout::{TakeoutImportConfig, run};
|
||||
|
||||
const RESOURCES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/resources");
|
||||
|
||||
fn common_for(archive: &Path) -> CommonConfig {
|
||||
CommonConfig {
|
||||
archive: archive.to_path_buf(),
|
||||
threads: 1,
|
||||
dry_run: false,
|
||||
max_retries: 0,
|
||||
allow_invalid_certs: false,
|
||||
logger: Logger::from_flags(true, 0),
|
||||
}
|
||||
}
|
||||
|
||||
fn config_for(root: PathBuf) -> TakeoutImportConfig {
|
||||
TakeoutImportConfig {
|
||||
takeout_root: root,
|
||||
allow_source_change: false,
|
||||
automap: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn count(conn: &Connection, table: &str) -> i64 {
|
||||
conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn mailbox_names(conn: &Connection) -> HashSet<String> {
|
||||
let mut stmt = conn.prepare("SELECT name FROM mailboxes").unwrap();
|
||||
stmt.query_map([], |r| r.get::<_, String>(0))
|
||||
.unwrap()
|
||||
.map(|r| r.unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct SyntheticTakeout {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl SyntheticTakeout {
|
||||
fn build(root: &Path) -> Self {
|
||||
Self::write_mail(root);
|
||||
Self::write_calendar(root);
|
||||
Self::write_contacts(root);
|
||||
Self::write_noise_files(root);
|
||||
Self {
|
||||
root: root.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
fn write_mail(root: &Path) {
|
||||
let mail_dir = root.join("Takeout/Mail");
|
||||
fs::create_dir_all(&mail_dir).unwrap();
|
||||
|
||||
let mut all_mail = String::new();
|
||||
let messages = [
|
||||
(1, "Inbox,Opened", "Plain inbox read"),
|
||||
(
|
||||
2,
|
||||
"Inbox,Important,Opened,WorkProject",
|
||||
"Tagged with custom label",
|
||||
),
|
||||
(3, "Inbox,Opened,Starred", "Starred mail"),
|
||||
(4, "Archived,Sent,Opened", "Sent and archived"),
|
||||
(
|
||||
5,
|
||||
"Archived,Sent,Opened,WorkProject/2026-Q1",
|
||||
"Nested custom label",
|
||||
),
|
||||
(
|
||||
6,
|
||||
"Trash,Category Social,Unread",
|
||||
"Trash + Category dropped, unread",
|
||||
),
|
||||
(
|
||||
7,
|
||||
"Inbox,Opened,Category Promotions",
|
||||
"Inbox + Category Promotions dropped",
|
||||
),
|
||||
];
|
||||
for (n, labels, subject) in messages {
|
||||
all_mail.push_str(&takeout_message(n, labels, subject));
|
||||
}
|
||||
fs::write(
|
||||
mail_dir.join("All Mail Including Spam and Trash.mbox"),
|
||||
all_mail,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut per_label = String::new();
|
||||
for n in 8..=9 {
|
||||
per_label.push_str(&takeout_message(
|
||||
n,
|
||||
"Inbox,Opened,Github",
|
||||
"Per-label export",
|
||||
));
|
||||
}
|
||||
fs::write(mail_dir.join("Github.mbox"), per_label).unwrap();
|
||||
}
|
||||
|
||||
fn write_calendar(root: &Path) {
|
||||
let cal_dir = root.join("Takeout/Calendar");
|
||||
fs::create_dir_all(&cal_dir).unwrap();
|
||||
fs::copy(
|
||||
Path::new(RESOURCES).join("icals/000.ics"),
|
||||
cal_dir.join("Weekly.ics"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::copy(
|
||||
Path::new(RESOURCES).join("icals/002.ics"),
|
||||
cal_dir.join("no_calname.ics"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
cal_dir.join("meet_settings.json"),
|
||||
br#"{"Meeting data":[{"Meeting code":"abc-defg-hij"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn write_contacts(root: &Path) {
|
||||
let all_dir = root.join("Takeout/Contacts/All Contacts");
|
||||
let my_dir = root.join("Takeout/Contacts/My Contacts");
|
||||
let starred_dir = root.join("Takeout/Contacts/Starred in Android");
|
||||
fs::create_dir_all(&all_dir).unwrap();
|
||||
fs::create_dir_all(&my_dir).unwrap();
|
||||
fs::create_dir_all(&starred_dir).unwrap();
|
||||
|
||||
let all = synthetic_vcards_with_and_without_uid();
|
||||
fs::write(all_dir.join("All Contacts.vcf"), &all).unwrap();
|
||||
fs::write(my_dir.join("My Contacts.vcf"), &all).unwrap();
|
||||
fs::write(starred_dir.join("Starred in Android.vcf"), "").unwrap();
|
||||
fs::write(
|
||||
all_dir.join("Alice Example.jpg"),
|
||||
[0xff_u8, 0xd8, 0xff, 0xe0],
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
my_dir.join("Alice Example.jpg"),
|
||||
[0xff_u8, 0xd8, 0xff, 0xe0],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn write_noise_files(root: &Path) {
|
||||
fs::write(root.join("Takeout/archive_browser.html"), b"<html/>").unwrap();
|
||||
fs::write(root.join("Takeout/user-generated-memory.json"), b"{}").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn takeout_message(n: u64, labels: &str, subject: &str) -> String {
|
||||
format!(
|
||||
"From {n}@xxx Thu Nov 13 20:20:33 +0000 2025\n\
|
||||
X-GM-THRID: {n}\n\
|
||||
X-Gmail-Labels: {labels}\n\
|
||||
Message-ID: <synthetic-{n}@example.com>\n\
|
||||
From: \"Anon\" <[email protected]>\n\
|
||||
To: \"Anon\" <[email protected]>\n\
|
||||
Subject: {subject}\n\
|
||||
Date: Thu, 13 Nov 2025 20:20:33 +0000\n\
|
||||
Content-Type: text/plain; charset=us-ascii\n\
|
||||
\n\
|
||||
Synthetic body for message {n}.\n\
|
||||
\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn synthetic_vcards_with_and_without_uid() -> String {
|
||||
let mut buf = String::new();
|
||||
buf.push_str(
|
||||
"BEGIN:VCARD\r\n\
|
||||
VERSION:3.0\r\n\
|
||||
UID:synthetic-uid-1\r\n\
|
||||
FN:Alice Example\r\n\
|
||||
N:Example;Alice;;;\r\n\
|
||||
EMAIL;TYPE=INTERNET:[email protected]\r\n\
|
||||
TEL;TYPE=CELL:+1-555-0100\r\n\
|
||||
END:VCARD\r\n",
|
||||
);
|
||||
buf.push_str(
|
||||
"BEGIN:VCARD\r\n\
|
||||
VERSION:3.0\r\n\
|
||||
UID:synthetic-uid-2\r\n\
|
||||
FN:Bob Example\r\n\
|
||||
N:Example;Bob;;;\r\n\
|
||||
EMAIL;TYPE=INTERNET:[email protected]\r\n\
|
||||
END:VCARD\r\n",
|
||||
);
|
||||
buf.push_str(
|
||||
"BEGIN:VCARD\r\n\
|
||||
VERSION:3.0\r\n\
|
||||
FN:Carol Example\r\n\
|
||||
N:Example;Carol;;;\r\n\
|
||||
EMAIL;TYPE=INTERNET:[email protected]\r\n\
|
||||
END:VCARD\r\n",
|
||||
);
|
||||
buf.push_str(
|
||||
"BEGIN:VCARD\r\n\
|
||||
VERSION:3.0\r\n\
|
||||
item1.EMAIL;TYPE=INTERNET:[email protected]\r\n\
|
||||
item1.X-ABLabel:\r\n\
|
||||
END:VCARD\r\n",
|
||||
);
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_directory_fails_with_usage() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let archive = td.path().join("a.sqlite");
|
||||
let err = run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap_err();
|
||||
assert!(format!("{err}").contains("no .mbox"));
|
||||
assert!(matches!(err, vandelay::error::Error::Usage(_)));
|
||||
assert_eq!(err.exit_code(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonexistent_path_fails_with_usage() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let archive = td.path().join("a.sqlite");
|
||||
let missing = td.path().join("does-not-exist");
|
||||
let err = run(common_for(&archive), config_for(missing)).unwrap_err();
|
||||
assert!(matches!(err, vandelay::error::Error::Usage(_)));
|
||||
assert_eq!(err.exit_code(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_takeout_imports_expected_row_counts() {
|
||||
let td = TempDir::new().unwrap();
|
||||
SyntheticTakeout::build(td.path());
|
||||
let archive = td.path().join("a.sqlite");
|
||||
let summary = run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert_eq!(count(&conn, "emails"), 9);
|
||||
assert_eq!(count(&conn, "calendars"), 2);
|
||||
assert!(count(&conn, "calendar_events") >= 2);
|
||||
assert_eq!(count(&conn, "address_books"), 1);
|
||||
assert_eq!(
|
||||
count(&conn, "contact_cards"),
|
||||
6,
|
||||
"2 UID-bearing cards dedup across the two .vcf files (=2); \
|
||||
2 UID-less cards get distinct synthetic UIDs per file (=4); total 6"
|
||||
);
|
||||
|
||||
let by = |t: &str| {
|
||||
summary
|
||||
.per_type
|
||||
.iter()
|
||||
.find(|(n, _)| *n == t)
|
||||
.unwrap()
|
||||
.1
|
||||
.clone()
|
||||
};
|
||||
assert!(by("mailbox").created >= 6);
|
||||
assert_eq!(by("email").created, 9);
|
||||
assert_eq!(by("email").failed, 0);
|
||||
assert_eq!(by("calendarevent").failed, 0);
|
||||
assert_eq!(by("contactcard").failed, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_mapping_produces_expected_mailbox_tree() {
|
||||
let td = TempDir::new().unwrap();
|
||||
SyntheticTakeout::build(td.path());
|
||||
let archive = td.path().join("a.sqlite");
|
||||
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
let names = mailbox_names(&conn);
|
||||
for required in [
|
||||
"Inbox",
|
||||
"Sent",
|
||||
"Archive",
|
||||
"Trash",
|
||||
"WorkProject",
|
||||
"2026-Q1",
|
||||
"Github",
|
||||
] {
|
||||
assert!(
|
||||
names.contains(required),
|
||||
"missing mailbox {required:?}: have {names:?}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
names.iter().all(|n| !n.starts_with("Category")),
|
||||
"Category tokens must be dropped: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
!names.contains("Starred"),
|
||||
"Starred is a keyword, not a mailbox: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
!names.contains("Important"),
|
||||
"Important is a keyword, not a mailbox: {names:?}"
|
||||
);
|
||||
|
||||
let nested_parent_id: i64 = conn
|
||||
.query_row(
|
||||
"SELECT parent_id FROM mailboxes WHERE name = '2026-Q1'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
let parent_name: String = conn
|
||||
.query_row(
|
||||
"SELECT name FROM mailboxes WHERE id = ?1",
|
||||
[nested_parent_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(parent_name, "WorkProject");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_roles_assigned_when_automap_on() {
|
||||
let td = TempDir::new().unwrap();
|
||||
SyntheticTakeout::build(td.path());
|
||||
let archive = td.path().join("a.sqlite");
|
||||
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
let expected = [
|
||||
("Inbox", "inbox"),
|
||||
("Sent", "sent"),
|
||||
("Trash", "trash"),
|
||||
("Archive", "archive"),
|
||||
];
|
||||
for (name, role) in expected {
|
||||
let got: String = conn
|
||||
.query_row("SELECT role FROM mailboxes WHERE name = ?1", [name], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap_or_else(|_| panic!("mailbox {name} missing"));
|
||||
assert_eq!(got, role, "{name} should have role {role}");
|
||||
}
|
||||
let custom_role: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT role FROM mailboxes WHERE name = 'WorkProject'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(custom_role.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_translation_matches_label_table() {
|
||||
let td = TempDir::new().unwrap();
|
||||
SyntheticTakeout::build(td.path());
|
||||
let archive = td.path().join("a.sqlite");
|
||||
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
let starred: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM emails WHERE keywords LIKE '%$flagged%'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(starred, 1);
|
||||
let important: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM emails WHERE keywords LIKE '%$important%'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(important, 1);
|
||||
let seen: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM emails WHERE keywords LIKE '%$seen%'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(seen, 8);
|
||||
let unseen: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM emails WHERE keywords NOT LIKE '%$seen%'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(unseen, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_xwr_calname_honored_else_falls_back_to_imported() {
|
||||
let td = TempDir::new().unwrap();
|
||||
SyntheticTakeout::build(td.path());
|
||||
let archive = td.path().join("a.sqlite");
|
||||
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
let names: HashSet<String> = {
|
||||
let mut s = conn.prepare("SELECT name FROM calendars").unwrap();
|
||||
s.query_map([], |r| r.get::<_, String>(0))
|
||||
.unwrap()
|
||||
.map(|r| r.unwrap())
|
||||
.collect()
|
||||
};
|
||||
assert!(
|
||||
names.contains("weekly"),
|
||||
"weekly from icals/000.ics: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains("Imported"),
|
||||
"Imported fallback for icals/002.ics: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contacts_dedup_by_uid_across_two_vcf_files() {
|
||||
let td = TempDir::new().unwrap();
|
||||
SyntheticTakeout::build(td.path());
|
||||
let archive = td.path().join("a.sqlite");
|
||||
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert_eq!(count(&conn, "address_books"), 1);
|
||||
let with_uid: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM contact_cards WHERE uid IN ('synthetic-uid-1', 'synthetic-uid-2')",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(with_uid, 2);
|
||||
let synthetic_uids: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM contact_cards WHERE uid LIKE 'vandelay-syn-%'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
synthetic_uids >= 2,
|
||||
"expected synthetic UIDs for the 2 UID-less cards * 2 files"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerun_against_unchanged_source_is_idempotent() {
|
||||
let td = TempDir::new().unwrap();
|
||||
SyntheticTakeout::build(td.path());
|
||||
let archive = td.path().join("a.sqlite");
|
||||
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
|
||||
|
||||
let s2 = run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
|
||||
let email_counts = &s2.per_type.iter().find(|(n, _)| *n == "email").unwrap().1;
|
||||
assert_eq!(email_counts.created, 0);
|
||||
assert_eq!(email_counts.failed, 0);
|
||||
assert_eq!(email_counts.skipped, 9);
|
||||
|
||||
let event_counts = &s2
|
||||
.per_type
|
||||
.iter()
|
||||
.find(|(n, _)| *n == "calendarevent")
|
||||
.unwrap()
|
||||
.1;
|
||||
assert_eq!(event_counts.created, 0);
|
||||
assert_eq!(event_counts.failed, 0);
|
||||
assert!(event_counts.skipped > 0);
|
||||
|
||||
let card_counts = &s2
|
||||
.per_type
|
||||
.iter()
|
||||
.find(|(n, _)| *n == "contactcard")
|
||||
.unwrap()
|
||||
.1;
|
||||
assert_eq!(card_counts.created, 0);
|
||||
assert_eq!(card_counts.failed, 0);
|
||||
assert!(card_counts.skipped > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_without_xgmail_labels_lands_in_filename_mailbox() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let mail_dir = td.path().join("Takeout/Mail");
|
||||
fs::create_dir_all(&mail_dir).unwrap();
|
||||
fs::write(
|
||||
mail_dir.join("ImportedFromThunderbird.mbox"),
|
||||
b"From a@b Thu Nov 13 20:20:33 +0000 2025\nFrom: a@b\nSubject: nolabels\n\nbody\n",
|
||||
)
|
||||
.unwrap();
|
||||
let archive = td.path().join("a.sqlite");
|
||||
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert!(mailbox_names(&conn).contains("ImportedFromThunderbird"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonmatching_siblings_are_ignored_silently() {
|
||||
let td = TempDir::new().unwrap();
|
||||
SyntheticTakeout::build(td.path());
|
||||
let archive = td.path().join("a.sqlite");
|
||||
let summary = run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
|
||||
assert_eq!(
|
||||
summary
|
||||
.per_type
|
||||
.iter()
|
||||
.find(|(n, _)| *n == "email")
|
||||
.unwrap()
|
||||
.1
|
||||
.failed,
|
||||
0,
|
||||
"the .json / .html / .jpg sidecars must not cause failures"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_change_protection_refuses_different_path() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let a = td.path().join("a");
|
||||
let b = td.path().join("b");
|
||||
fs::create_dir_all(&a).unwrap();
|
||||
fs::create_dir_all(&b).unwrap();
|
||||
SyntheticTakeout::build(&a);
|
||||
SyntheticTakeout::build(&b);
|
||||
|
||||
let archive = td.path().join("a.sqlite");
|
||||
run(common_for(&archive), config_for(a.clone())).unwrap();
|
||||
let err = run(common_for(&archive), config_for(b.clone())).unwrap_err();
|
||||
assert!(matches!(err, vandelay::error::Error::SourceChange(_)));
|
||||
|
||||
let mut cfg2 = config_for(b);
|
||||
cfg2.allow_source_change = true;
|
||||
run(common_for(&archive), cfg2).expect("--allow-source-change permits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_extraction_to_same_canonical_path_converges() {
|
||||
let td = TempDir::new().unwrap();
|
||||
SyntheticTakeout::build(td.path());
|
||||
let archive = td.path().join("a.sqlite");
|
||||
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
|
||||
let initial: i64 = {
|
||||
let c = Connection::open(&archive).unwrap();
|
||||
count(&c, "emails")
|
||||
};
|
||||
|
||||
fs::remove_dir_all(td.path().join("Takeout")).unwrap();
|
||||
SyntheticTakeout::build(td.path());
|
||||
|
||||
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
|
||||
let c = Connection::open(&archive).unwrap();
|
||||
assert_eq!(count(&c, "emails"), initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noautomap_disables_role_assignment() {
|
||||
let td = TempDir::new().unwrap();
|
||||
SyntheticTakeout::build(td.path());
|
||||
let archive = td.path().join("a.sqlite");
|
||||
let mut cfg = config_for(td.path().to_path_buf());
|
||||
cfg.automap = false;
|
||||
run(common_for(&archive), cfg).unwrap();
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
let with_role: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM mailboxes WHERE role IS NOT NULL",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
with_role, 0,
|
||||
"no mailbox should have a role under --noautomap"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_takeout_layout_mirrors_real_takeout_directory_shape() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let synth = SyntheticTakeout::build(td.path());
|
||||
let root = synth.path();
|
||||
assert!(
|
||||
root.join("Takeout/Mail/All Mail Including Spam and Trash.mbox")
|
||||
.exists()
|
||||
);
|
||||
assert!(root.join("Takeout/Mail/Github.mbox").exists());
|
||||
assert!(root.join("Takeout/Calendar/Weekly.ics").exists());
|
||||
assert!(root.join("Takeout/Calendar/no_calname.ics").exists());
|
||||
assert!(root.join("Takeout/Calendar/meet_settings.json").exists());
|
||||
assert!(
|
||||
root.join("Takeout/Contacts/All Contacts/All Contacts.vcf")
|
||||
.exists()
|
||||
);
|
||||
assert!(
|
||||
root.join("Takeout/Contacts/All Contacts/Alice Example.jpg")
|
||||
.exists()
|
||||
);
|
||||
assert!(
|
||||
root.join("Takeout/Contacts/My Contacts/My Contacts.vcf")
|
||||
.exists()
|
||||
);
|
||||
assert!(
|
||||
root.join("Takeout/Contacts/Starred in Android/Starred in Android.vcf")
|
||||
.exists()
|
||||
);
|
||||
assert!(root.join("Takeout/archive_browser.html").exists());
|
||||
assert!(root.join("Takeout/user-generated-memory.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_creates_no_object_rows_in_archive() {
|
||||
let td = TempDir::new().unwrap();
|
||||
SyntheticTakeout::build(td.path());
|
||||
let archive = td.path().join("a.sqlite");
|
||||
let mut common = common_for(&archive);
|
||||
common.dry_run = true;
|
||||
let summary = run(common, config_for(td.path().to_path_buf())).unwrap();
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert_eq!(count(&conn, "emails"), 0);
|
||||
assert_eq!(count(&conn, "mailboxes"), 0);
|
||||
assert_eq!(count(&conn, "calendars"), 0);
|
||||
assert_eq!(count(&conn, "calendar_events"), 0);
|
||||
assert_eq!(count(&conn, "address_books"), 0);
|
||||
assert_eq!(count(&conn, "contact_cards"), 0);
|
||||
assert_eq!(count(&conn, "blobs"), 0);
|
||||
for (_, counts) in &summary.per_type {
|
||||
assert_eq!(counts.created, 0);
|
||||
assert_eq!(counts.fetched, 0);
|
||||
assert_eq!(counts.failed, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_identical_message_in_two_mbox_files_dedupes_to_one_row() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let mail_dir = td.path().join("Takeout/Mail");
|
||||
fs::create_dir_all(&mail_dir).unwrap();
|
||||
let body = takeout_message(1, "Inbox,Opened,Github", "shared subject");
|
||||
fs::write(mail_dir.join("All.mbox"), &body).unwrap();
|
||||
fs::write(mail_dir.join("Github.mbox"), &body).unwrap();
|
||||
|
||||
let archive = td.path().join("a.sqlite");
|
||||
let summary = run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert_eq!(
|
||||
count(&conn, "emails"),
|
||||
1,
|
||||
"the same bytes across two .mbox files must dedupe to one emails row"
|
||||
);
|
||||
assert_eq!(count(&conn, "blobs"), 1, "blob layer dedupes too");
|
||||
let email = &summary
|
||||
.per_type
|
||||
.iter()
|
||||
.find(|(n, _)| *n == "email")
|
||||
.unwrap()
|
||||
.1;
|
||||
assert_eq!(email.created, 1);
|
||||
assert!(
|
||||
email.skipped + email.fetched >= 1,
|
||||
"the second occurrence must go through the present-row path"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_ics_files_without_calname_merge_into_single_imported_calendar() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let cal_dir = td.path().join("Takeout/Calendar");
|
||||
fs::create_dir_all(&cal_dir).unwrap();
|
||||
fs::copy(
|
||||
Path::new(RESOURCES).join("icals/002.ics"),
|
||||
cal_dir.join("first.ics"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::copy(
|
||||
Path::new(RESOURCES).join("icals/002.ics"),
|
||||
cal_dir.join("second.ics"),
|
||||
)
|
||||
.unwrap();
|
||||
let archive = td.path().join("a.sqlite");
|
||||
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
|
||||
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
let imported_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM calendars WHERE name = 'Imported'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
imported_count, 1,
|
||||
"two .ics files without X-WR-CALNAME must share one Imported calendar"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_mbox_file_imports_with_no_failures() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let mail_dir = td.path().join("Takeout/Mail");
|
||||
fs::create_dir_all(&mail_dir).unwrap();
|
||||
fs::write(mail_dir.join("Empty.mbox"), b"").unwrap();
|
||||
let archive = td.path().join("a.sqlite");
|
||||
let summary = run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
|
||||
let email = &summary
|
||||
.per_type
|
||||
.iter()
|
||||
.find(|(n, _)| *n == "email")
|
||||
.unwrap()
|
||||
.1;
|
||||
assert_eq!(email.created, 0);
|
||||
assert_eq!(email.failed, 0);
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert_eq!(count(&conn, "emails"), 0);
|
||||
assert_eq!(count(&conn, "mailboxes"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_label_creates_chat_mailbox_with_no_role() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let mail_dir = td.path().join("Takeout/Mail");
|
||||
fs::create_dir_all(&mail_dir).unwrap();
|
||||
fs::write(
|
||||
mail_dir.join("All.mbox"),
|
||||
takeout_message(1, "Chat,Opened", "hello"),
|
||||
)
|
||||
.unwrap();
|
||||
let archive = td.path().join("a.sqlite");
|
||||
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
assert!(mailbox_names(&conn).contains("Chat"));
|
||||
let role: Option<String> = conn
|
||||
.query_row("SELECT role FROM mailboxes WHERE name = 'Chat'", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(role.is_none(), "Chat is a system label with no JMAP role");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noautomap_leaves_nested_mailboxes_role_null() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let mail_dir = td.path().join("Takeout/Mail");
|
||||
fs::create_dir_all(&mail_dir).unwrap();
|
||||
fs::write(
|
||||
mail_dir.join("All.mbox"),
|
||||
takeout_message(1, "Inbox,Opened,Project/2026-Q1", "nested"),
|
||||
)
|
||||
.unwrap();
|
||||
let archive = td.path().join("a.sqlite");
|
||||
let mut cfg = config_for(td.path().to_path_buf());
|
||||
cfg.automap = false;
|
||||
run(common_for(&archive), cfg).unwrap();
|
||||
let conn = Connection::open(&archive).unwrap();
|
||||
let with_role: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM mailboxes WHERE role IS NOT NULL",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(with_role, 0);
|
||||
let names = mailbox_names(&conn);
|
||||
assert!(names.contains("Inbox"));
|
||||
assert!(names.contains("Project"));
|
||||
assert!(names.contains("2026-Q1"));
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* 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");
|
||||
}
|
||||
Reference in New Issue
Block a user