Fix MS Exchange EWS: add support for version negotiation and other enhancements (closes #6)

This commit is contained in:
Maurus Decimus
2026-06-10 11:43:09 +02:00
parent 49f344660a
commit bf0ef2083b
14 changed files with 888 additions and 99 deletions
+174 -2
View File
@@ -13,7 +13,7 @@ use vandelay::exchange_ews::parse::{
parse_get_attachment_inline, parse_response_messages, parse_sync_folder_items_response,
read_envelope_summary,
};
use vandelay::exchange_ews::types::{FolderId, ItemId, ResponseCode};
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,
@@ -503,7 +503,7 @@ fn http_500_with_server_busy_body_is_treated_as_fault_and_retried() {
));
let _m1 = server
.mock("POST", "/EWS/Exchange.asmx")
.with_status(200)
.with_status(500)
.with_header("content-type", TXT_XML)
.with_body(busy_body)
.expect(1)
@@ -527,6 +527,178 @@ fn http_500_with_server_busy_body_is_treated_as_fault_and_retried() {
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 warning_response_class_is_treated_as_success_in_mock() {
let body = envelope(&format!(
+4 -1
View File
@@ -928,7 +928,10 @@ fn coordinator_special_use_drives_role() {
#[test]
fn coordinator_omits_special_use_when_unadvertised() {
let control: Script = Box::new(|conn: &mut MockConn| -> std::io::Result<()> {
auth_preamble(conn, "IMAP4rev2 LIST-EXTENDED LIST-STATUS LITERAL+ AUTH=PLAIN")?;
auth_preamble(
conn,
"IMAP4rev2 LIST-EXTENDED LIST-STATUS LITERAL+ AUTH=PLAIN",
)?;
let (tag, cmd) = conn.read_command()?;
if cmd.contains("SPECIAL-USE") {
conn.write_line(&format!(