/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * 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, ServerVersion}; 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!( "{inner}" ) } #[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(), "alice@contoso.com".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!( "\ NoError\ \ \ \ IPF.NoteInbox\ \ IPF.AppointmentCalendar\ \ IPF.ContactContacts\ " ); 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!( "\ NoError\ \ \ \ " )); let page2 = envelope(&format!( "\ NoError\ \ \ \ " )); 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!( "\ \ NoError\ \ S\ SGVsbG8=\ \ \ ErrorItemNotFound\ not found\ \ " )); 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:Serverbusy\ ErrorServerBusy\ 200\ " ); let ok_body = envelope(&format!( "\ NoError\ \ " )); 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!( "\ \ NoError\ \ hi\ {encoded}\ \ " )); 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!( "\ \ NoError\ \ p.png\ image/png\ true\ iVBORw0KGgo=\ " )); 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 = "\ \ \ Daily\ uid-1\ 2025-06-15T14:00:00Z\ 2025-06-15T15:00:00Z\ RecurringMaster\ \ 1\ 2025-06-153\ \ \ 2025-06-16T15:00:00Z2025-06-16T16:30:00Z2025-06-16T14:00:00Z\ \ \ 2025-06-17T14:00:00Z\ \ "; 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!( "\ NoError\ STATE-1\ true\ \ \ \ \ \ true\ " )); 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:Serverbad state\ ErrorInvalidSyncStateData\ " ); 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: "alice@contoso.com".into(), }; db::sources::upsert_source(&conn, &key, None, "alice@contoso.com").unwrap(); let conflict = db::sources::conflicting_source( &conn, "exchange_ews", "https://outlook.office365.com/EWS/Exchange.asmx", "bob@contoso.com", ) .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: "alice@contoso.com".into(), }; let archive = SourceKey { kind: "exchange_ews".into(), session_url: "https://x/EWS/Exchange.asmx".into(), account_id: "alice@contoso.com#archive".into(), }; let public = SourceKey { kind: "exchange_ews".into(), session_url: "https://x/EWS/Exchange.asmx".into(), account_id: "__public_folders__@contoso.com".into(), }; let p = db::sources::upsert_source(&conn, &primary, None, "alice@contoso.com").unwrap(); let a = db::sources::upsert_source(&conn, &archive, None, "alice@contoso.com").unwrap(); let q = db::sources::upsert_source(&conn, &public, None, "alice@contoso.com").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 = (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, ServerVersion::Exchange2013Sp1); assert!(body.contains("")); 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, ServerVersion::Exchange2013Sp1); assert!(body.contains("OPAQUE")); assert!(body.contains("512")); assert!(body.contains("NormalItems")); assert!(body.contains("")); } #[test] fn get_attachment_body_carries_inline_request() { let body = get_attachment_body(&["A1", "A2"]); assert!(body.contains("")); assert!(body.contains("")); assert!(body.contains("true")); } #[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:Serverbusy\ ErrorServerBusy\ 100\ " ); let ok_body = envelope(&format!( "\ NoError\ \ " )); let _m1 = server .mock("POST", "/EWS/Exchange.asmx") .with_status(500) .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 invalid_server_version_fault_downgrades_and_succeeds() { let mut server = mockito::Server::new(); let url = format!("{}/EWS/Exchange.asmx", server.url()); let version_fault = format!( "\ soap:Server\ The specified server version is invalid.\ ErrorInvalidServerVersion\ " ); let ok_body = format!( "\ \ NoError\ \ " ); let m_sp1 = server .mock("POST", "/EWS/Exchange.asmx") .match_body(Matcher::Regex("Exchange2013_SP1".into())) .with_status(500) .with_header("content-type", TXT_XML) .with_body(&version_fault) .expect(1) .create(); let m_2013 = server .mock("POST", "/EWS/Exchange.asmx") .match_body(Matcher::Regex("Exchange2013\"".into())) .with_status(500) .with_header("content-type", TXT_XML) .with_body(&version_fault) .expect(1) .create(); let m_2010 = server .mock("POST", "/EWS/Exchange.asmx") .match_body(Matcher::Regex("Exchange2010_SP2".into())) .with_status(200) .with_header("content-type", TXT_XML) .with_body(&ok_body) .expect_at_least(1) .create(); let c = client(0); assert_eq!(c.server_version(), ServerVersion::Exchange2013Sp1); 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 walk the version ladder down to a version the server accepts"); let parsed = parse_find_folder_response(&r.body).unwrap(); assert!(parsed.folders.is_empty()); assert_eq!(c.server_version(), ServerVersion::Exchange2010Sp2); assert_eq!(c.retries_observed(), 0); m_sp1.assert(); m_2013.assert(); m_2010.assert(); } #[test] fn unsupported_version_floor_surfaces_as_soap_fault() { let mut server = mockito::Server::new(); let url = format!("{}/EWS/Exchange.asmx", server.url()); let version_fault = format!( "\ soap:Server\ The specified server version is invalid.\ ErrorInvalidServerVersion\ " ); let _m = server .mock("POST", "/EWS/Exchange.asmx") .with_status(500) .with_header("content-type", TXT_XML) .with_body(&version_fault) .expect_at_least(1) .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::SoapFault { code: ResponseCode::InvalidServerVersion, .. } ), "got {err:?}" ); assert_eq!(c.server_version(), ServerVersion::Exchange2007); } #[test] fn server_affinity_cookie_is_captured_and_resent() { let mut server = mockito::Server::new(); let url = format!("{}/EWS/Exchange.asmx", server.url()); let ok_body = envelope(&format!( "\ NoError\ \ " )); let first = server .mock("POST", "/EWS/Exchange.asmx") .match_header("x-preferserveraffinity", "True") .match_header("x-backendoverridecookie", Matcher::Missing) .with_status(200) .with_header("content-type", TXT_XML) .with_header( "set-cookie", "X-BackEndOverrideCookie=AFFIN123; path=/; HttpOnly", ) .with_body(&ok_body) .expect(1) .create(); let second = server .mock("POST", "/EWS/Exchange.asmx") .match_header("x-backendoverridecookie", "AFFIN123") .with_status(200) .with_header("content-type", TXT_XML) .with_body(&ok_body) .expect(1) .create(); let c = client(0); let body = find_folder_body( FolderRef::Distinguished( vandelay::exchange_ews::types::DistinguishedFolderId::MsgFolderRoot, ), Traversal::Deep, ); c.call(&url, "FindFolder", &body).expect("first call ok"); c.call(&url, "FindFolder", &body).expect("second call ok"); first.assert(); second.assert(); } #[test] fn http_456_surfaces_as_account_locked_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(456) .with_body("Account locked. Unlock at https://unlock.example/") .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(); match err { EwsError::Auth(m) => assert!(m.contains("locked"), "got {m}"), other => panic!("expected Auth error, got {other:?}"), } } #[test] fn warning_response_class_is_treated_as_success_in_mock() { let body = envelope(&format!( "\ \ NoError\ \ " )); 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 = "\ \ \ \ NoError\ \ Inbox\ \ \ "; 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!( "\ \ NoError\ Inbox\ \ \ ErrorAccessDenied\ \ \ NoError\ Drafts\ \ " )); 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); }