Files
ActivePush/tests/e2e.rs
T
2026-11-05 12:52:32 +01:00

635 lines
23 KiB
Rust

//! End-to-end test against a running ActivePush with the dev backends
//! (`tools/dev/test-backends.sh` + `tools/dev/test-settings.json`).
//!
//! AP_E2E_URL=http://127.0.0.1:18081 cargo test --test e2e -- --nocapture --test-threads=1
//!
//! Skipped when AP_E2E_URL is not set.
use std::time::Duration;
use active_push::backend::imap::ImapConn;
use active_push::config::{ImapSettings, TlsMode};
use active_push::probe::Probe;
use active_push::wbxml::Element;
const USER: &str = "jan@test.local";
const PASS: &str = "Test-Password-123";
fn url() -> Option<String> {
std::env::var("AP_E2E_URL")
.ok()
.map(|u| format!("{}/Microsoft-Server-ActiveSync", u.trim_end_matches('/')))
}
async fn imap() -> ImapConn {
let cfg = ImapSettings {
host: "127.0.0.1".into(),
port: 11143,
security: TlsMode::None,
..Default::default()
};
let mut c = ImapConn::connect(&cfg).await.unwrap();
assert!(c.login(USER, PASS).await.unwrap());
c
}
struct Folders {
list: Vec<(String, String, String, String)>, // id, parent, name, type
}
impl Folders {
fn by_type(&self, t: &str) -> String {
self.list
.iter()
.find(|f| f.3 == t)
.map(|f| f.0.clone())
.unwrap_or_else(|| panic!("no folder of type {t}"))
}
fn by_name(&self, n: &str) -> (String, String) {
self.list
.iter()
.find(|f| f.2 == n)
.map(|f| (f.0.clone(), f.1.clone()))
.unwrap_or_else(|| panic!("no folder {n}"))
}
}
fn col(r: &Element) -> &Element {
r.path(&["AirSync:Collections", "AirSync:Collection"])
.expect("collection in sync response")
}
async fn full_sync(p: &Probe, id: &str) -> (String, Vec<Element>) {
let r = p.sync(id, "0", 0, None).await.unwrap();
let mut key = col(&r).child_text("AirSync:SyncKey").unwrap();
let mut items = Vec::new();
for _ in 0..50 {
let r = p.sync(id, &key, 100, None).await.unwrap();
let c = col(&r);
assert_eq!(
c.child_text("AirSync:Status").as_deref(),
Some("1"),
"sync status: {}",
r.to_xml()
);
key = c.child_text("AirSync:SyncKey").unwrap();
if let Some(cmds) = c.child("AirSync:Commands") {
items.extend(cmds.children.iter().cloned());
}
if !c.has("AirSync:MoreAvailable") {
break;
}
}
(key, items)
}
async fn changes(p: &Probe, id: &str, key: &str, cmds: Option<Element>) -> (String, Element) {
let r = p.sync(id, key, 100, cmds).await.unwrap();
let c = col(&r).clone();
(c.child_text("AirSync:SyncKey").unwrap(), c)
}
#[tokio::test]
async fn e2e_full_flow() {
let Some(url) = url() else {
eprintln!("AP_E2E_URL not set - skipping end-to-end test");
return;
};
let dev = format!("e2e{}", std::process::id());
// seed fresh messages with an attachment
{
let mut im = imap().await;
for f in ["Zmieniony", "Nowy folder &AXw-"] {
let _ = im.delete(f).await;
}
for i in 1..=3 {
let m = format!("From: Anna Nowak <anna@test.local>\r\nTo: Jan <jan@test.local>\r\nSubject: Testowa wiadomość e2e {i}\r\nMessage-ID: <e2e{i}.{dev}@test.local>\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=b1\r\n\r\n--b1\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nTreść wiadomości numer {i}.\r\n--b1\r\nContent-Type: application/pdf; name=\"raport.pdf\"\r\nContent-Disposition: attachment; filename=\"raport.pdf\"\r\nContent-Transfer-Encoding: base64\r\n\r\nJVBERi0xLjQKJcOkw7zDtsOfCg==\r\n--b1--\r\n");
im.append("INBOX", "", m.as_bytes()).await.unwrap();
}
}
let mut p = Probe::new(&url, USER, PASS, &dev, true).unwrap();
// ---- auth failure gives 401
let bad = Probe::new(&url, USER, "wrong-password", &dev, true).unwrap();
assert_eq!(bad.options().await.unwrap().0, 401);
// ---- provisioning is enforced
let (st, _) = p
.cmd(
"FolderSync",
Some(
Element::new("FolderHierarchy:FolderSync")
.with_text("FolderHierarchy:SyncKey", "0"),
),
)
.await
.unwrap();
assert!(
st == 449 || st == 200,
"unprovisioned FolderSync answered {st}"
);
p.provision().await.unwrap();
// ---- hierarchy
let fs = p.folder_sync("0").await.unwrap();
let fkey = fs.child_text("FolderHierarchy:SyncKey").unwrap();
let mut list = Vec::new();
for a in fs
.child("FolderHierarchy:Changes")
.unwrap()
.children_named("FolderHierarchy:Add")
{
list.push((
a.child_text("FolderHierarchy:ServerId").unwrap(),
a.child_text("FolderHierarchy:ParentId").unwrap(),
a.child_text("FolderHierarchy:DisplayName").unwrap(),
a.child_text("FolderHierarchy:Type").unwrap(),
));
}
let f = Folders { list };
let inbox = f.by_type("2");
let trash = f.by_type("4");
let sent = f.by_type("5");
let cal = f.by_type("8");
let tasks = f.by_type("7");
let contacts = f.by_type("9");
let (projekty, _) = f.by_name("Projekty");
let (_, alfa_parent) = f.by_name("Alfa");
assert_eq!(alfa_parent, projekty, "subfolder parent");
// unchanged hierarchy keeps key
let fs2 = p.folder_sync(&fkey).await.unwrap();
assert_eq!(
fs2.path(&["FolderHierarchy:Changes", "FolderHierarchy:Count"])
.unwrap()
.text_value(),
"0"
);
// ---- Z-Push leftovers: a collection id unknown here must be deleted on the device
let zr = p
.sync(
"a6271970",
"{11111111-2222-3333-4444-555555555555}7",
10,
None,
)
.await
.unwrap();
assert_eq!(col(&zr).child_text("AirSync:Status").as_deref(), Some("12"));
let fs3 = p.folder_sync(&fkey).await.unwrap();
let ch3 = fs3.child("FolderHierarchy:Changes").expect("changes");
assert!(
ch3.children_named("FolderHierarchy:Delete")
.any(|d| d.child_text("FolderHierarchy:ServerId").as_deref() == Some("a6271970")),
"stale collection deleted: {}",
fs3.to_xml()
);
let fkey = fs3.child_text("FolderHierarchy:SyncKey").unwrap();
// ---- mail
let (mut ikey, mails) = full_sync(&p, &inbox).await;
assert!(mails.len() >= 3, "expected mails, got {}", mails.len());
let mails: Vec<Element> = mails
.into_iter()
.filter(|m| {
m.child("AirSync:ApplicationData")
.and_then(|a| a.child_text("Email:Subject"))
.map(|s| s.starts_with("Testowa wiadomo"))
.unwrap_or(false)
})
.collect();
assert!(mails.len() >= 3, "seeded test mails present");
let first = &mails[0];
let app = first.child("AirSync:ApplicationData").unwrap();
assert!(app
.child_text("Email:Subject")
.unwrap()
.starts_with("Testowa wiadomo"));
let fref = app
.path(&["AirSyncBase:Attachments", "AirSyncBase:Attachment"])
.and_then(|a| a.child_text("AirSyncBase:FileReference"))
.expect("attachment");
// attachment via ItemOperations
let io = Element::new("ItemOperations:ItemOperations").with(
Element::new("ItemOperations:Fetch")
.with_text("ItemOperations:Store", "Mailbox")
.with_text("AirSyncBase:FileReference", fref.clone()),
);
let (_, r) = p.cmd("ItemOperations", Some(io)).await.unwrap();
let r = r.unwrap();
let data = r
.path(&[
"ItemOperations:Response",
"ItemOperations:Fetch",
"ItemOperations:Properties",
])
.and_then(|x| x.child_text("ItemOperations:Data"))
.expect("attachment data");
assert_eq!(data, "JVBERi0xLjQKJcOkw7zDtsOfCg==");
// client marks read + flag
let sid = first.child_text("AirSync:ServerId").unwrap();
let ch = Element::new("AirSync:Commands").with(
Element::new("AirSync:Change")
.with_text("AirSync:ServerId", sid.clone())
.with(
Element::new("AirSync:ApplicationData")
.with_text("Email:Read", "1")
.with(Element::new("Email:Flag").with_text("Email:FlagStatus", "2")),
),
);
let (k, c) = changes(&p, &inbox, &ikey, Some(ch)).await;
ikey = k;
assert!(
c.child("AirSync:Commands").is_none(),
"own change must not be echoed: {}",
c.to_xml()
);
let uid: u32 = sid.split(':').nth(1).unwrap().parse().unwrap();
let mut im = imap().await;
im.select("INBOX").await.unwrap();
let fl = im.uid_fetch_flags(&uid.to_string()).await.unwrap();
assert!(
fl[0].flags.iter().any(|x| x == "\\Seen") && fl[0].flags.iter().any(|x| x == "\\Flagged")
);
// server side change -> device gets Change
im.uid_store(uid, "-", "\\Flagged").await.unwrap();
let (k, c) = changes(&p, &inbox, &ikey, None).await;
ikey = k;
let cmds = c.child("AirSync:Commands").expect("server change");
assert!(cmds.children.iter().any(|x| x.is("AirSync:Change")));
// new mail arrives -> Add
im.append(
"INBOX",
"",
b"From: x@y.pl\r\nTo: jan@test.local\r\nSubject: Nowa\r\n\r\nhej\r\n",
)
.await
.unwrap();
let (k, c) = changes(&p, &inbox, &ikey, None).await;
ikey = k;
assert!(c
.child("AirSync:Commands")
.unwrap()
.children
.iter()
.any(|x| x.is("AirSync:Add")));
// delete from device -> Trash
let ch = Element::new("AirSync:Commands")
.with(Element::new("AirSync:Delete").with_text("AirSync:ServerId", sid.clone()));
let (k, _) = changes(&p, &inbox, &ikey, Some(ch)).await;
ikey = k;
im.select("Trash").await.unwrap();
assert!(
!im.uid_search("ALL").await.unwrap().is_empty(),
"message moved to Trash"
);
// GetItemEstimate (nothing pending)
let gie = Element::new("GetItemEstimate:GetItemEstimate").with(
Element::new("GetItemEstimate:Collections").with(
Element::new("GetItemEstimate:Collection")
.with_text("AirSync:SyncKey", ikey.clone())
.with_text("GetItemEstimate:CollectionId", inbox.clone()),
),
);
let (_, r) = p.cmd("GetItemEstimate", Some(gie)).await.unwrap();
let r = r.unwrap();
let est_dbg = r.to_xml();
let est = r
.path(&["GetItemEstimate:Response", "GetItemEstimate:Collection"])
.unwrap()
.child_text("GetItemEstimate:Estimate")
.unwrap();
if est != "0" {
let (_, c) = changes(&p, &inbox, &ikey, None).await;
panic!("estimate {est}: {est_dbg}\npending: {}", c.to_xml());
}
// MoveItems inbox -> Projekty
let (_, c) = changes(&p, &inbox, &ikey, None).await;
let _ = c;
let mv_sid = mails[1].child_text("AirSync:ServerId").unwrap();
let mv = Element::new("Move:MoveItems").with(
Element::new("Move:Move")
.with_text("Move:SrcMsgId", mv_sid)
.with_text("Move:SrcFldId", inbox.clone())
.with_text("Move:DstFldId", projekty.clone()),
);
let (_, r) = p.cmd("MoveItems", Some(mv)).await.unwrap();
let r = r.unwrap();
let resp = r.child("Move:Response").unwrap();
assert_eq!(
resp.child_text("Move:Status").as_deref(),
Some("3"),
"{}",
r.to_xml()
);
assert!(resp
.child_text("Move:DstMsgId")
.unwrap()
.starts_with(&format!("{projekty}:")));
// ---- SendMail + Sent copy
let mime = format!("From: Jan <{USER}>\r\nTo: anna@test.local\r\nBcc: tajny@test.local\r\nSubject: E2E wysyłka\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nCześć Anno!\r\n");
let sm = Element::new("ComposeMail:SendMail")
.with_text("ComposeMail:ClientId", "c1")
.with_empty("ComposeMail:SaveInSentItems")
.with(Element::opaque("ComposeMail:MIME", mime.into_bytes()));
let (st, r) = p.cmd("SendMail", Some(sm)).await.unwrap();
assert_eq!(st, 200);
assert!(
r.is_none(),
"SendMail success returns empty body: {:?}",
r.map(|x| x.to_xml())
);
im.select("Sent").await.unwrap();
assert!(
!im.uid_search("SUBJECT \"E2E\"").await.unwrap().is_empty(),
"copy in Sent"
);
// SmartReply quoting original
let reply_src = mails[2].child_text("AirSync:ServerId").unwrap();
let mime = format!("From: {USER}\r\nTo: anna@test.local\r\nSubject: Re: test\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nOdpowiedź\r\n");
let sr = Element::new("ComposeMail:SmartReply")
.with_text("ComposeMail:ClientId", "c2")
.with(
Element::new("ComposeMail:Source")
.with_text("ComposeMail:FolderId", inbox.clone())
.with_text("ComposeMail:ItemId", reply_src),
)
.with(Element::opaque("ComposeMail:MIME", mime.into_bytes()));
let (st, _) = p.cmd("SmartReply", Some(sr)).await.unwrap();
assert_eq!(st, 200);
tokio::time::sleep(Duration::from_millis(300)).await;
let sink = std::fs::read_dir("data/test-backends/sink")
.map(|d| d.count())
.unwrap_or(0);
assert!(sink >= 2, "SMTP sink got messages");
// ---- calendar
let (ckey, evs) = full_sync(&p, &cal).await;
let ev = evs
.iter()
.filter_map(|e| e.child("AirSync:ApplicationData"))
.find(|a| a.child_text("Calendar:Subject").as_deref() == Some("Spotkanie zespołu"))
.expect("seeded event");
assert_eq!(
ev.child_text("Calendar:Subject").unwrap(),
"Spotkanie zespołu"
);
assert_eq!(
ev.path(&["Calendar:Recurrence", "Calendar:Type"])
.unwrap()
.text_value(),
"1"
);
assert_eq!(ev.child_text("Calendar:Reminder").unwrap(), "15");
// device adds an event
let add = Element::new("AirSync:Commands").with(
Element::new("AirSync:Add")
.with_text("AirSync:ClientId", "ev-new")
.with(
Element::new("AirSync:ApplicationData")
.with_text(
"Calendar:TimeZone",
active_push::backend::tz::blob_for(&chrono_tz::Europe::Warsaw, 2026),
)
.with_text("Calendar:AllDayEvent", "0")
.with_text("Calendar:StartTime", "20261120T090000Z")
.with_text("Calendar:EndTime", "20261120T100000Z")
.with_text("Calendar:Subject", "Z telefonu")
.with_text("Calendar:Reminder", "30")
.with_text("Calendar:BusyStatus", "2"),
),
);
let (ckey2, c) = changes(&p, &cal, &ckey, Some(add)).await;
let resp = c.path(&["AirSync:Responses", "AirSync:Add"]).unwrap();
assert_eq!(resp.child_text("AirSync:Status").as_deref(), Some("1"));
let new_sid = resp.child_text("AirSync:ServerId").unwrap();
// idempotent retry with the same key returns the same id
let add_again = Element::new("AirSync:Commands").with(
Element::new("AirSync:Add")
.with_text("AirSync:ClientId", "ev-new")
.with(Element::new("AirSync:ApplicationData").with_text("Calendar:Subject", "dup")),
);
let (_, c2) = changes(&p, &cal, &ckey2, Some(add_again)).await;
assert_eq!(
c2.path(&["AirSync:Responses", "AirSync:Add"])
.unwrap()
.child_text("AirSync:ServerId")
.unwrap(),
new_sid
);
// stored in Radicale
let body = reqwest::Client::new()
.request(
reqwest::Method::from_bytes(b"PROPFIND").unwrap(),
"http://127.0.0.1:15232/jan@test.local/kalendarz/",
)
.basic_auth(USER, Some(PASS))
.header("Depth", "1")
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(
body.matches(".ics").count() >= 3,
"new event stored on CalDAV"
);
// ---- tasks + contacts
let (_, ts) = full_sync(&p, &tasks).await;
assert!(ts.iter().any(|t| t
.child("AirSync:ApplicationData")
.and_then(|a| a.child_text("Tasks:Subject"))
.as_deref()
== Some("Kupić mleko")));
let (kkey, cs) = full_sync(&p, &contacts).await;
assert_eq!(cs.len(), 1);
let c0 = cs[0].child("AirSync:ApplicationData").unwrap();
assert_eq!(
c0.child_text("Contacts:MobilePhoneNumber").unwrap(),
"+48 600 100 200"
);
let csid = cs[0].child_text("AirSync:ServerId").unwrap();
let mut changed = c0.clone();
changed.children.retain(|x| !x.is("Contacts:JobTitle"));
changed.push_text("Contacts:JobTitle", "Kierownik");
let ch = Element::new("AirSync:Commands").with(
Element::new("AirSync:Change")
.with_text("AirSync:ServerId", csid)
.with(changed),
);
let (_, c) = changes(&p, &contacts, &kkey, Some(ch)).await;
assert!(
c.child("AirSync:Responses").is_none(),
"contact change ok: {}",
c.to_xml()
);
// ---- search
let gal = Element::new("Search:Search").with(
Element::new("Search:Store")
.with_text("Search:Name", "GAL")
.with_text("Search:Query", "Nowak")
.with(Element::new("Search:Options").with_text("Search:Range", "0-9")),
);
let (_, r) = p.cmd("Search", Some(gal)).await.unwrap();
let r = r.unwrap();
assert_eq!(
r.path(&["Search:Response", "Search:Store"])
.unwrap()
.child_text("Search:Total")
.as_deref(),
Some("1"),
"{}",
r.to_xml()
);
let ms = Element::new("Search:Search").with(
Element::new("Search:Store")
.with_text("Search:Name", "Mailbox")
.with(
Element::new("Search:Query").with(
Element::new("Search:And")
.with_text("AirSync:Class", "Email")
.with_text("AirSync:CollectionId", inbox.clone())
.with_text("Search:FreeText", "wiadomości numer"),
),
)
.with(Element::new("Search:Options").with_text("Search:Range", "0-9")),
);
let (_, r) = p.cmd("Search", Some(ms)).await.unwrap();
let r = r.unwrap();
let total: u32 = r
.path(&["Search:Response", "Search:Store"])
.unwrap()
.child_text("Search:Total")
.unwrap()
.parse()
.unwrap();
assert!(total >= 1, "mailbox search: {}", r.to_xml());
// ---- folder create / rename / delete
let fc = Element::new("FolderHierarchy:FolderCreate")
.with_text("FolderHierarchy:SyncKey", fkey.clone())
.with_text("FolderHierarchy:ParentId", "0")
.with_text("FolderHierarchy:DisplayName", "Nowy folder ż")
.with_text("FolderHierarchy:Type", "12");
let (_, r) = p.cmd("FolderCreate", Some(fc)).await.unwrap();
let r = r.unwrap();
assert_eq!(
r.child_text("FolderHierarchy:Status").as_deref(),
Some("1"),
"{}",
r.to_xml()
);
let nkey = r.child_text("FolderHierarchy:SyncKey").unwrap();
let nid = r.child_text("FolderHierarchy:ServerId").unwrap();
let fu = Element::new("FolderHierarchy:FolderUpdate")
.with_text("FolderHierarchy:SyncKey", nkey)
.with_text("FolderHierarchy:ServerId", nid.clone())
.with_text("FolderHierarchy:ParentId", "0")
.with_text("FolderHierarchy:DisplayName", "Zmieniony");
let (_, r) = p.cmd("FolderUpdate", Some(fu)).await.unwrap();
let r = r.unwrap();
assert_eq!(
r.child_text("FolderHierarchy:Status").as_deref(),
Some("1"),
"{}",
r.to_xml()
);
let fd = Element::new("FolderHierarchy:FolderDelete")
.with_text(
"FolderHierarchy:SyncKey",
r.child_text("FolderHierarchy:SyncKey").unwrap(),
)
.with_text("FolderHierarchy:ServerId", nid);
let (_, r) = p.cmd("FolderDelete", Some(fd)).await.unwrap();
assert_eq!(
r.unwrap().child_text("FolderHierarchy:Status").as_deref(),
Some("1")
);
// ---- settings / user information
let s = Element::new("Settings:Settings")
.with(Element::new("Settings:UserInformation").with_empty("Settings:Get"));
let (_, r) = p.cmd("Settings", Some(s)).await.unwrap();
assert!(r.unwrap().to_xml().contains(USER));
// ---- ping: new mail during heartbeat
let (fresh_key, _) = changes(&p, &inbox, &ikey, None).await;
let _ = fresh_key;
let ping = Element::new("Ping:Ping")
.with_text("Ping:HeartbeatInterval", "60")
.with(
Element::new("Ping:Folders").with(
Element::new("Ping:Folder")
.with_text("Ping:Id", inbox.clone())
.with_text("Ping:Class", "Email"),
),
);
let started = std::time::Instant::now();
let pinger = tokio::spawn({
let p2 = Probe::new(&url, USER, PASS, &dev, true).unwrap();
let key = p.policy_key.clone();
async move {
let mut p2 = p2;
p2.policy_key = key;
p2.cmd("Ping", Some(ping)).await
}
});
tokio::time::sleep(Duration::from_secs(3)).await;
im.append(
"INBOX",
"",
b"From: x@y.pl\r\nTo: jan@test.local\r\nSubject: Push!\r\n\r\npush\r\n",
)
.await
.unwrap();
let (_, r) = pinger.await.unwrap().unwrap();
let r = r.unwrap();
assert_eq!(
r.child_text("Ping:Status").as_deref(),
Some("2"),
"{}",
r.to_xml()
);
assert!(
started.elapsed() < Duration::from_secs(20),
"push latency {:?}",
started.elapsed()
);
eprintln!("ping reported change after {:?}", started.elapsed());
// ---- sent folder sync works too
let (_, sent_items) = full_sync(&p, &sent).await;
assert!(!sent_items.is_empty());
let (_, trash_items) = full_sync(&p, &trash).await;
assert!(!trash_items.is_empty());
// ---- autodiscover
let base = std::env::var("AP_E2E_URL").unwrap();
let ad = reqwest::Client::new()
.post(format!("{base}/autodiscover/autodiscover.xml"))
.basic_auth(USER, Some(PASS))
.body(r#"<?xml version="1.0"?><Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/mobilesync/requestschema/2006"><Request><EMailAddress>jan@test.local</EMailAddress><AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/mobilesync/responseschema/2006</AcceptableResponseSchema></Request></Autodiscover>"#)
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(
ad.contains("<Type>MobileSync</Type>") && ad.contains("Microsoft-Server-ActiveSync"),
"{ad}"
);
eprintln!("e2e OK");
}