Files
inbuxa-migrate/tests/mock_export_dry_run.rs
jcoffey-dev e47c074d43
ci / test (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m30s
ci / announce (pull_request) Skipped
export: a dry run that predicts failures and exits as the real run would
export --dry-run counted what it would create and printed a table, but it
missed the failures it could see coming and always exited 0.

It now checks, before anything is written:
- a message larger than the target's maxSizeUpload;
- an object too large for one request under maxSizeRequest, as a contact
  with its photo inlined can be;
- a Sieve script the target would reject, with SieveScript/validate, after
  the vnd.stalwart names are renamed, so what is checked is what would be
  uploaded. Sieve scripts are uploaded as blobs for that check, and nothing
  else is; a target without the method gets one warning.

The counts are kept instead of being dropped, so a dry run in which
anything would fail exits 5, like the real run. The report is now a plan in
plain words -- per type, what would be created, updated, left unchanged,
deleted and would fail -- followed by each predicted failure and why.
2026-09-30 12:46:38 -07:00

326 lines
11 KiB
Rust

/*
* SPDX-FileCopyrightText: 2026 John Coffey <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
//! `export --dry-run` predicts what a real run would fail on: a message
//! larger than the target accepts, an object too big for one request, and a
//! Sieve script the target rejects. It keeps the counts, so the run exits
//! non-zero just as the real one would, and it writes nothing.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use inbuxa_migrate::db;
use inbuxa_migrate::jmap::account::AccountSelector;
use inbuxa_migrate::jmap::http::Auth;
use inbuxa_migrate::logging::Logger;
use inbuxa_migrate::sync::{self, CommonConfig, ConnectConfig, ExportConfig, Summary, TypeCounts};
use mockito::Matcher;
use serde_json::json;
const API: &str = "/jmap/api";
fn tmp() -> PathBuf {
static SEQ: AtomicUsize = AtomicUsize::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let mut p = std::env::temp_dir();
p.push(format!(
"inbuxa-migrate-exportdry-{}-{:?}-{n}.sqlite",
std::process::id(),
std::thread::current().id(),
));
let _ = std::fs::remove_file(&p);
p
}
fn session(base: &str, max_size_upload: u64, max_size_request: u64, sieve: &[&str]) -> String {
json!({
"apiUrl": format!("{base}{API}"),
"uploadUrl": format!("{base}/jmap/upload/{{accountId}}/"),
"downloadUrl": format!("{base}/jmap/dl/{{accountId}}/{{blobId}}/{{type}}/{{name}}"),
"capabilities": { "urn:ietf:params:jmap:core": {
"maxObjectsInGet": 500, "maxObjectsInSet": 500, "maxCallsInRequest": 16,
"maxConcurrentRequests": 4, "maxConcurrentUpload": 4,
"maxSizeRequest": max_size_request, "maxSizeUpload": max_size_upload
} },
"accounts": { "w": { "name": "alice",
"accountCapabilities": {
"urn:ietf:params:jmap:mail": {},
"urn:ietf:params:jmap:contacts": {},
"urn:ietf:params:jmap:sieve": { "sieveExtensions": sieve }
} } }
})
.to_string()
}
fn dry_run(archive: &Path, base: &str) -> Summary {
sync::export::run(
CommonConfig {
archive: archive.to_path_buf(),
threads: 2,
dry_run: true,
max_retries: 0,
allow_invalid_certs: false,
logger: Logger::from_flags(true, 0),
},
ExportConfig {
connect: ConnectConfig {
url: base.to_owned(),
auth: Auth::Basic {
user: "u".into(),
password: "p".into(),
},
account: AccountSelector::Id("w".into()),
},
objects: None,
prune: false,
yes: true,
},
)
.expect("a dry run returns its plan")
}
fn counts(summary: &Summary, ty: &str) -> TypeCounts {
summary
.per_type
.iter()
.find(|(t, _)| *t == ty)
.map(|(_, c)| c.clone())
.unwrap_or_else(|| panic!("no counts for {ty}: {summary:?}"))
}
fn root_and_session(server: &mut mockito::ServerGuard, body: String) -> Vec<mockito::Mock> {
vec![
server.mock("GET", "/").with_status(404).create(),
server
.mock("GET", "/.well-known/jmap")
.with_body(body)
.create(),
]
}
/// Nothing that writes to the account: no `/set`, no import. Returns the
/// mocks, each expecting no calls.
fn no_writes(server: &mut mockito::ServerGuard) -> Vec<mockito::Mock> {
["/set\"", "Email/import"]
.into_iter()
.map(|m| {
server
.mock("POST", API)
.match_body(Matcher::Regex(m.into()))
.expect(0)
.create()
})
.collect()
}
fn empty(
server: &mut mockito::ServerGuard,
method: &str,
reply: serde_json::Value,
) -> mockito::Mock {
server
.mock("POST", API)
.match_body(Matcher::Regex(method.into()))
.with_body(json!({ "methodResponses": [[method, reply, "x"]] }).to_string())
.create()
}
#[test]
fn a_message_too_large_to_upload_is_predicted_to_fail() {
let mut server = mockito::Server::new();
let base = server.url();
let archive = tmp();
{
let conn = db::init::open(&archive).unwrap();
conn.execute(
"INSERT INTO mailboxes (id,name,parent_id,role) VALUES (1,'Inbox',NULL,'inbox')",
[],
)
.unwrap();
for body in ["short".to_owned(), "x".repeat(4000)] {
let raw = format!(
"From: a@x\r\nSubject: s\r\nMessage-ID: <{}@h>\r\n\r\n{body}",
body.len()
);
let blob = db::blobs::intern_blob(&conn, raw.as_bytes()).unwrap();
conn.execute(
"INSERT INTO emails (blob_id,received_at,mailbox_ids,keywords)
VALUES (?1,'2020-01-01T00:00:00Z','[1]','[]')",
rusqlite::params![blob],
)
.unwrap();
}
}
let _s = root_and_session(&mut server, session(&base, 1000, 10_000_000, &[]));
let _mq = empty(
&mut server,
"Mailbox/query",
json!({"accountId":"w","ids":[]}),
);
let _eq = empty(
&mut server,
"Email/query",
json!({"accountId":"w","ids":[]}),
);
let no_upload = server
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
.expect(0)
.create();
let writes = no_writes(&mut server);
let summary = dry_run(&archive, &base);
let email = counts(&summary, "Email");
assert_eq!(email.created, 1, "the short one would be created");
assert_eq!(email.failed, 1, "the long one is over maxSizeUpload");
assert!(
summary.any_failed(),
"so the dry run exits non-zero, like a real run"
);
no_upload.assert();
for w in writes {
w.assert();
}
let _ = std::fs::remove_file(&archive);
}
#[test]
fn a_contact_too_large_for_one_request_is_predicted_to_fail() {
let mut server = mockito::Server::new();
let base = server.url();
let archive = tmp();
{
let conn = db::init::open(&archive).unwrap();
conn.execute(
"INSERT INTO address_books (id,name,is_default) VALUES (1,'Personal',1)",
[],
)
.unwrap();
let photo = db::blobs::intern_blob(&conn, &vec![b'P'; 8000]).unwrap();
let huge = json!({ "@type": "Card", "name": { "full": "Photo Person" },
"media": { "photo": { "@type": "Media", "kind": "photo",
"@blob": photo, "mediaType": "image/png" } } })
.to_string();
let small = json!({ "@type": "Card", "name": { "full": "Small Person" } }).to_string();
for (id, uid, data) in [(1, "huge-card", &huge), (2, "small-card", &small)] {
conn.execute(
"INSERT INTO contact_cards (id,uid,address_book_ids,data) VALUES (?1,?2,'[1]',?3)",
rusqlite::params![id, uid, data],
)
.unwrap();
}
}
let _s = root_and_session(&mut server, session(&base, 50_000_000, 4000, &[]));
let _ab = empty(
&mut server,
"AddressBook/get",
json!({"accountId":"w","list":[],"notFound":[]}),
);
let _cq = empty(
&mut server,
"ContactCard/query",
json!({"accountId":"w","ids":[]}),
);
let writes = no_writes(&mut server);
let summary = dry_run(&archive, &base);
let cards = counts(&summary, "ContactCard");
assert_eq!(cards.created, 1, "the small card would be created");
assert_eq!(
cards.failed, 1,
"the card with the photo inlined is over maxSizeRequest"
);
assert!(summary.any_failed());
for w in writes {
w.assert();
}
let _ = std::fs::remove_file(&archive);
}
/// One active script with Stalwart's `vnd.stalwart.while`, against a target
/// that names it `vnd.inbuxa.while`; `validate` is the answer to
/// `SieveScript/validate`. Checks that the script sent for validation is the
/// renamed one, and returns the run's counts.
fn dry_run_one_script(validate: serde_json::Value) -> Summary {
let mut server = mockito::Server::new();
let base = server.url();
let archive = tmp();
{
let conn = db::init::open(&archive).unwrap();
let blob = db::blobs::intern_blob(
&conn,
b"require [\"fileinto\", \"vnd.stalwart.while\"];\nkeep;\n",
)
.unwrap();
conn.execute(
"INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (1,'main',1,?1)",
rusqlite::params![blob],
)
.unwrap();
}
let _s = root_and_session(
&mut server,
session(
&base,
50_000_000,
10_000_000,
&["fileinto", "vnd.inbuxa.while"],
),
);
let _get = empty(
&mut server,
"SieveScript/get",
json!({"accountId":"w","list":[],"notFound":[]}),
);
let upload = server
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
.match_body(Matcher::Regex("vnd\\.inbuxa\\.while".into()))
.with_body(json!({"blobId":"TMP"}).to_string())
.expect(1)
.create();
let _validate = server
.mock("POST", API)
.match_body(Matcher::Regex("SieveScript/validate".into()))
.with_body(json!({"methodResponses":[validate]}).to_string())
.create();
let writes = no_writes(&mut server);
let summary = dry_run(&archive, &base);
for w in writes {
w.assert();
}
upload.assert();
let _ = std::fs::remove_file(&archive);
summary
}
#[test]
fn a_sieve_script_the_target_rejects_is_predicted_to_fail() {
let summary = dry_run_one_script(json!(["SieveScript/validate",
{"accountId":"w","error":{"type":"invalidScript","description":"unknown test"}},"v"]));
let sieve = counts(&summary, "SieveScript");
assert_eq!(sieve.failed, 1);
assert_eq!(sieve.created, 0);
assert!(summary.any_failed());
}
#[test]
fn a_valid_sieve_script_is_validated_after_renaming_and_nothing_is_written() {
let summary = dry_run_one_script(json!(["SieveScript/validate",
{"accountId":"w","error":null},"v"]));
let sieve = counts(&summary, "SieveScript");
assert_eq!(sieve.created, 1, "it would be created");
assert_eq!(sieve.failed, 0);
assert!(!summary.any_failed());
}
#[test]
fn a_target_without_validate_still_gets_a_plan() {
let summary = dry_run_one_script(json!(["error",
{"type":"unknownMethod"},"v"]));
let sieve = counts(&summary, "SieveScript");
assert_eq!(sieve.created, 1, "not checked, so planned as written");
assert_eq!(sieve.failed, 0);
}