Files
inbuxa-migrate/tests/mock_exchange_ews.rs
T
2026-06-10 22:19:28 +02:00

870 lines
36 KiB
Rust

/*
* 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::{FolderClass, 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!(
"<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_folder_classifies_workmail_folder_class_set() {
let body = format!(
"<m:FindFolderResponse{NS}><m:ResponseMessages><m:FindFolderResponseMessage ResponseClass=\"Success\">\
<m:ResponseCode>NoError</m:ResponseCode>\
<m:RootFolder TotalItemsInView=\"7\" IncludesLastItemInRange=\"true\"><t:Folders>\
<t:Folder><t:FolderId Id=\"INBOX\"/><t:FolderClass>IPF.Note</t:FolderClass><t:DisplayName>Inbox</t:DisplayName></t:Folder>\
<t:Folder><t:FolderId Id=\"CUSTOM\"/><t:DisplayName>SyncFolder</t:DisplayName></t:Folder>\
<t:Folder><t:FolderId Id=\"RSS\"/><t:FolderClass>IPF.Note.OutlookHomepage</t:FolderClass><t:DisplayName>RSS Feeds</t:DisplayName></t:Folder>\
<t:Folder><t:FolderId Id=\"CFG\"/><t:FolderClass>IPF.Configuration</t:FolderClass><t:DisplayName>Quick Step Settings</t:DisplayName></t:Folder>\
<t:TasksFolder><t:FolderId Id=\"TASKS\"/><t:FolderClass>IPF.Task</t:FolderClass><t:DisplayName>Tasks</t:DisplayName></t:TasksFolder>\
<t:CalendarFolder><t:FolderId Id=\"CAL\"/><t:FolderClass>IPF.Appointment</t:FolderClass><t:DisplayName>Calendar</t:DisplayName></t:CalendarFolder>\
<t:ContactsFolder><t:FolderId Id=\"CON\"/><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();
let by_id = |id: &str| {
parsed
.folders
.iter()
.find(|f| f.folder_id.id == id)
.map(|f| FolderClass::from_ipf(&f.folder_class))
.unwrap()
};
assert_eq!(by_id("INBOX"), FolderClass::Mail);
assert_eq!(
by_id("CUSTOM"),
FolderClass::Mail,
"a user folder with no FolderClass must import as mail, never be dropped"
);
assert_eq!(
by_id("RSS"),
FolderClass::Skipped,
"RSS Feeds (IPF.Note.OutlookHomepage) holds feed posts, not mail"
);
assert_eq!(by_id("CFG"), FolderClass::Skipped);
assert_eq!(by_id("TASKS"), FolderClass::Skipped);
assert_eq!(by_id("CAL"), FolderClass::Calendar);
assert_eq!(by_id("CON"), FolderClass::Contacts);
}
#[test]
fn find_item_distribution_list_does_not_clobber_preceding_contact() {
let body = envelope(&format!(
"<m:FindItemResponse{NS}><m:ResponseMessages><m:FindItemResponseMessage ResponseClass=\"Success\">\
<m:ResponseCode>NoError</m:ResponseCode>\
<m:RootFolder TotalItemsInView=\"3\" IncludesLastItemInRange=\"true\"><t:Items>\
<t:Contact><t:ItemId Id=\"C1\" ChangeKey=\"k1\"/></t:Contact>\
<t:Contact><t:ItemId Id=\"C2\" ChangeKey=\"k2\"/></t:Contact>\
<t:DistributionList><t:ItemId Id=\"DL1\" ChangeKey=\"k3\"/></t:DistributionList>\
</t:Items></m:RootFolder></m:FindItemResponseMessage></m:ResponseMessages></m:FindItemResponse>"
));
let parsed = parse_find_item_response(body.as_bytes()).unwrap();
assert_eq!(
parsed.items.len(),
3,
"DistributionList must be its own item, not clobber C2"
);
let ids: Vec<&str> = parsed.items.iter().map(|i| i.id.id.as_str()).collect();
assert_eq!(ids, ["C1", "C2", "DL1"]);
assert_eq!(
parsed.items[2].element.to_ascii_lowercase(),
"distributionlist"
);
}
#[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, ServerVersion::Exchange2013Sp1);
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, ServerVersion::Exchange2013Sp1);
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(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:Envelope{NS}><soap:Body><soap:Fault>\
<faultcode>soap:Server</faultcode>\
<faultstring>The specified server version is invalid.</faultstring>\
<detail><ResponseCode xmlns=\"http://schemas.microsoft.com/exchange/services/2006/types\">ErrorInvalidServerVersion</ResponseCode></detail>\
</soap:Fault></soap:Body></soap:Envelope>"
);
let ok_body = format!(
"<soap:Envelope{NS}><soap:Header><t:ServerVersionInfo MajorVersion=\"14\" MinorVersion=\"3\"/></soap:Header>\
<soap:Body><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></soap:Body></soap:Envelope>"
);
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:Envelope{NS}><soap:Body><soap:Fault>\
<faultcode>soap:Server</faultcode>\
<faultstring>The specified server version is invalid.</faultstring>\
<detail><ResponseCode xmlns=\"http://schemas.microsoft.com/exchange/services/2006/types\">ErrorInvalidServerVersion</ResponseCode></detail>\
</soap:Fault></soap:Body></soap:Envelope>"
);
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!(
"<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 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 for_each_fetched_item_streams_every_id_across_windows() {
use vandelay::logging::Logger;
use vandelay::sync::import_exchange_ews::items::{ItemRunCtx, for_each_fetched_item};
let mut server = mockito::Server::new();
let url = format!("{}/EWS/Exchange.asmx", server.url());
let one_message = 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:Message></m:Items>\
</m:GetItemResponseMessage></m:ResponseMessages></m:GetItemResponse>"
));
let _m = server
.mock("POST", "/EWS/Exchange.asmx")
.with_status(200)
.with_header("content-type", TXT_XML)
.with_body(&one_message)
.expect(5)
.create();
let c = client(0);
let ctx = ItemRunCtx {
client: &c,
url: &url,
source_id: 1,
batch_size: 1,
attachment_batch: 1,
connections: 2,
use_syncfolderitems: false,
sync_batch: 512,
logger: Logger::new(0),
};
let ids: Vec<ItemId> = (0..5).map(|i| ItemId::new(format!("I{i}"), "K")).collect();
let mut delivered = 0usize;
let failed = for_each_fetched_item(&ctx, ItemShape::Message, &ids, |msg| {
assert!(msg.success);
delivered += 1;
Ok(())
})
.expect("streaming fetch should succeed");
assert_eq!(delivered, 5, "every id must be delivered exactly once");
assert_eq!(failed, 0);
_m.assert();
}
#[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);
}