Files
inbuxa-migrate/tests/mock_exchange_ews.rs
T
jcoffey-dev 5931b06392 Rename to inbuxa-migrate
The crate, binary, library path and every name the tool writes or reads
become inbuxa-migrate: the CLI name and about text, the HTTP user agent,
the credential variables (INBUXA_MIGRATE_PASSWORD, _TOKEN,
_EWS_CLIENT_SECRET and _GRAPH_TOKEN, with no fallback to the old names),
thread names, synthesized UIDs and calendar addresses
(urn:x-inbuxa-migrate:attendee:), and the Maildir file suffix used by the
export script. This is a new tool, so archives written by Vandelay are not
read back.

The version moves to the date scheme the release tags use (2026.9.30), and
the package metadata points at the inbuxa-migrate repository. The cargo-dist
and MSI metadata goes with the release tooling it served.

Modified files carry an added copyright line; the original notices stay.
2026-09-30 10:09:10 -07:00

942 lines
39 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 John Coffey <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use encodify::base64::{LENIENT, STANDARD};
use inbuxa_migrate::exchange_ews::EwsClient;
use inbuxa_migrate::exchange_ews::autodiscover::{DiscoverySource, discover};
use inbuxa_migrate::exchange_ews::error::EwsError;
use inbuxa_migrate::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 inbuxa_migrate::exchange_ews::types::{
FolderClass, FolderId, ItemId, ResponseCode, ServerVersion,
};
use inbuxa_migrate::exchange_ews::xml::{
FolderRef, ItemShape, Traversal, find_folder_body, find_item_body, get_attachment_body,
get_item_body, sync_folder_items_body,
};
use inbuxa_migrate::jmap::http::{Auth, RetryPolicy};
use mockito::Matcher;
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!(inbuxa_migrate::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(), "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(
inbuxa_migrate::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(
inbuxa_migrate::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";
let encoded = 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(), "GetItemResponseMessage").unwrap();
let item = inbuxa_migrate::exchange_ews::parse::parse_message_item(&r[0].inner_xml).unwrap();
let s = item.mime_content.unwrap();
let bytes = LENIENT.decode(&s).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 = "<inbuxa-migrate-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></inbuxa-migrate-inner>";
let item = inbuxa_migrate::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 organizer_and_attendee_addresses_survive_an_ex_routing_type() {
let body = "<inbuxa-migrate-inner xmlns:t=\"http://schemas.microsoft.com/exchange/services/2006/types\">\
<t:CalendarItem>\
<t:ItemId Id=\"M3\" ChangeKey=\"K1\"/>\
<t:Subject>Review</t:Subject>\
<t:UID>uid-3</t:UID>\
<t:Start>2025-06-15T14:00:00Z</t:Start>\
<t:End>2025-06-15T15:00:00Z</t:End>\
<t:Organizer><t:Mailbox><t:Name>Alice</t:Name>\
<t:EmailAddress>[email protected]</t:EmailAddress>\
<t:RoutingType>EX</t:RoutingType><t:MailboxType>Mailbox</t:MailboxType></t:Mailbox></t:Organizer>\
<t:RequiredAttendees><t:Attendee><t:Mailbox><t:Name>Kristina Morgental</t:Name>\
<t:EmailAddress>/o=ExchangeLabs/ou=Exchange Administrative Group (FYDIBOHF23SPDLT)/cn=Recipients/cn=bdc77b18152647a29d28ce1188376dc9-kristina</t:EmailAddress>\
<t:RoutingType>EX</t:RoutingType></t:Mailbox><t:ResponseType>Unknown</t:ResponseType></t:Attendee></t:RequiredAttendees>\
</t:CalendarItem></inbuxa-migrate-inner>";
let item = inbuxa_migrate::exchange_ews::parse::parse_calendar_item(body).unwrap();
let event = inbuxa_migrate::exchange_ews::calendar_map::to_jscalendar(&item);
assert_eq!(
event.data["organizerCalendarAddress"], "mailto:[email protected]",
"a usable address must be kept even when RoutingType says EX"
);
let participants = event.data["participants"].as_object().unwrap();
let organizer = participants
.values()
.find(|p| p["calendarAddress"] == "mailto:[email protected]")
.expect("organizer participant");
assert_eq!(organizer["email"], "[email protected]");
let attendee = participants
.values()
.find(|p| p["name"] == "Kristina Morgental")
.expect("attendee participant");
assert!(
attendee["calendarAddress"]
.as_str()
.unwrap()
.starts_with("urn:x-inbuxa-migrate:attendee:"),
"a legacy directory reference cannot be resolved and stays synthetic"
);
assert!(
attendee.get("email").is_none(),
"no email is invented for a directory reference"
);
}
#[test]
fn recurrence_end_date_with_a_timezone_offset_yields_a_bounded_series() {
let body = "<inbuxa-migrate-inner xmlns:t=\"http://schemas.microsoft.com/exchange/services/2006/types\">\
<t:CalendarItem>\
<t:ItemId Id=\"M2\" ChangeKey=\"K1\"/>\
<t:Subject>Biweekly</t:Subject>\
<t:UID>uid-2</t:UID>\
<t:Start>2021-08-04T21:15:00Z</t:Start>\
<t:End>2021-08-04T22:15:00Z</t:End>\
<t:CalendarItemType>RecurringMaster</t:CalendarItemType>\
<t:Recurrence>\
<t:WeeklyRecurrence><t:Interval>2</t:Interval><t:DaysOfWeek>Wednesday</t:DaysOfWeek></t:WeeklyRecurrence>\
<t:EndDateRecurrence><t:StartDate>2021-08-04-06:00</t:StartDate><t:EndDate>2021-09-30-06:00</t:EndDate></t:EndDateRecurrence>\
</t:Recurrence>\
</t:CalendarItem></inbuxa-migrate-inner>";
let item = inbuxa_migrate::exchange_ews::parse::parse_calendar_item(body).unwrap();
let event = inbuxa_migrate::exchange_ews::calendar_map::to_jscalendar(&item);
let rule = &event.data["recurrenceRule"];
assert_eq!(rule["frequency"], "weekly");
assert_eq!(
rule["until"], "2021-09-30T23:59:59",
"an EndDate carrying a UTC offset must still produce a valid LocalDateTime"
);
}
#[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 inbuxa_migrate::db::exchange_ews_ids::ItemRow;
use inbuxa_migrate::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 inbuxa_migrate::db;
use inbuxa_migrate::db::sources::SourceKey;
use rusqlite::Connection;
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 inbuxa_migrate::db;
use inbuxa_migrate::db::sources::SourceKey;
use rusqlite::Connection;
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(
inbuxa_migrate::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(
inbuxa_migrate::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(
inbuxa_migrate::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(
inbuxa_migrate::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(
inbuxa_migrate::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 inbuxa_migrate::logging::Logger;
use inbuxa_migrate::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(), "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(), "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);
}