//! 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 { 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) { 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) -> (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 \r\nTo: Jan \r\nSubject: Testowa wiadomość e2e {i}\r\nMessage-ID: \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 = 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#"jan@test.localhttp://schemas.microsoft.com/exchange/autodiscover/mobilesync/responseschema/2006"#) .send() .await .unwrap() .text() .await .unwrap(); assert!( ad.contains("MobileSync") && ad.contains("Microsoft-Server-ActiveSync"), "{ad}" ); eprintln!("e2e OK"); }