/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: Apache-2.0 OR MIT */ use std::sync::Once; use base64::Engine; use mockito::{Matcher, Server}; use serde_json::json; use vandelay::exchange_graph::api::{Endpoints, collect_all_ids, paged_collect}; use vandelay::exchange_graph::calendar_map::{EventType, classify_event_type, convert_event}; use vandelay::exchange_graph::client::{Accept, GraphClient}; use vandelay::exchange_graph::contact_map::convert_contact; use vandelay::exchange_graph::error::GraphError; use vandelay::exchange_graph::oauth::{ DeviceCodeResponse, TokenResponse, parse_device_code_response, parse_token_response, run_device_code_polling_against, }; use vandelay::exchange_graph::recurrence::convert_patterned_recurrence; use vandelay::exchange_graph::retry::{HttpClass, classify_http_status}; use vandelay::jmap::http::RetryPolicy; static INIT: Once = Once::new(); fn client_with_retries(retries: u32) -> GraphClient { INIT.call_once(|| {}); GraphClient::new("BEARER".to_owned(), RetryPolicy::new(retries), false) } fn url_message_collection(server_url: &str, folder: &str, top: usize) -> String { let e = Endpoints::for_me(server_url); e.folder_messages_ids(folder, top) } fn make_jwt(exp: u64, upn: &str) -> String { use base64::engine::general_purpose::URL_SAFE_NO_PAD; let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"none\"}"); let claims = format!(r#"{{"tid":"tenant-1","upn":"{upn}","exp":{exp}}}"#); let payload = URL_SAFE_NO_PAD.encode(claims.as_bytes()); format!("{header}.{payload}.") } #[test] fn http_status_classifier_matches_spec_table() { assert_eq!(classify_http_status(200), HttpClass::Success); assert_eq!(classify_http_status(401), HttpClass::Auth); assert_eq!(classify_http_status(403), HttpClass::Auth); assert_eq!(classify_http_status(404), HttpClass::Vanished); assert_eq!(classify_http_status(410), HttpClass::Vanished); assert_eq!(classify_http_status(429), HttpClass::Retryable); assert_eq!(classify_http_status(503), HttpClass::Retryable); assert_eq!(classify_http_status(504), HttpClass::Retryable); assert_eq!(classify_http_status(507), HttpClass::Fatal); assert_eq!(classify_http_status(400), HttpClass::Fatal); assert_eq!(classify_http_status(405), HttpClass::Fatal); assert_eq!(classify_http_status(422), HttpClass::Fatal); } #[test] fn device_code_response_parses_required_fields() { let body = json!({ "device_code": "ABCDEF", "user_code": "BR4S-7XYZ", "verification_uri": "https://microsoft.com/devicelogin", "interval": 5, "expires_in": 900, "message": "Open the URL and enter the code." }); let parsed = parse_device_code_response(&body).unwrap(); assert_eq!(parsed.device_code, "ABCDEF"); assert_eq!(parsed.user_code, "BR4S-7XYZ"); assert_eq!(parsed.interval, 5); assert_eq!(parsed.expires_in, 900); } #[test] fn token_response_handles_pending_and_success() { let pending = parse_token_response(400, &json!({"error": "authorization_pending"})); assert!(matches!(pending, TokenResponse::Pending)); let success = parse_token_response( 200, &json!({"access_token": make_jwt(9999999999, "alice@x.com"), "expires_in": 3599}), ); match success { TokenResponse::Ok(t) => { assert!(t.access_token.contains('.')); assert_eq!(t.expires_in, Some(3599)); assert_eq!(t.upn.as_deref(), Some("alice@x.com")); } other => panic!("expected Ok, got {other:?}"), } } #[test] fn token_response_declined_and_bad_code() { let declined = parse_token_response(400, &json!({"error": "authorization_declined"})); assert!(matches!(declined, TokenResponse::Declined)); let bad = parse_token_response(400, &json!({"error": "bad_verification_code"})); assert!(matches!(bad, TokenResponse::BadCode)); } #[test] fn device_code_polling_loop_converges_on_success() { let response = DeviceCodeResponse { device_code: "DC".to_owned(), user_code: "U".to_owned(), verification_uri: "x".to_owned(), interval: 0, expires_in: 5, message: None, }; let mut attempt = 0; let token = run_device_code_polling_against( "https://login.x/common", "client-id", &response, |_endpoint, _body| { attempt += 1; if attempt < 3 { Ok((400, json!({"error": "authorization_pending"}))) } else { Ok(( 200, json!({ "access_token": make_jwt(9999999999, "alice@x"), "expires_in": 3599 }), )) } }, ) .unwrap(); assert_eq!(attempt, 3); assert_eq!(token.upn.as_deref(), Some("alice@x")); } #[test] fn device_code_polling_loop_aborts_on_decline() { let response = DeviceCodeResponse { device_code: "DC".to_owned(), user_code: "U".to_owned(), verification_uri: "x".to_owned(), interval: 0, expires_in: 5, message: None, }; let err = run_device_code_polling_against( "https://login.x/common", "client-id", &response, |_, _| Ok((400, json!({"error": "authorization_declined"}))), ) .unwrap_err(); let msg = err.to_string(); assert!(msg.contains("declined")); } #[test] fn pagination_collects_ids_across_three_pages_with_one_empty() { let mut server = Server::new(); let base = server.url(); let next2 = format!("{base}/page2"); let next3 = format!("{base}/page3"); let page1 = json!({ "value": [ {"id": "M1"}, {"id": "M2"} ], "@odata.nextLink": next2 }) .to_string(); let page2 = json!({ "value": [], "@odata.nextLink": next3 }) .to_string(); let page3 = json!({ "value": [ {"id": "M3"} ] }) .to_string(); let _m1 = server .mock("GET", "/me/mailFolders/F1/messages?$top=100&$select=id") .with_status(200) .with_header("content-type", "application/json") .with_body(page1) .create(); let _m2 = server .mock("GET", "/page2") .match_header("authorization", "Bearer BEARER") .match_header("prefer", "IdType=\"ImmutableId\"") .with_status(200) .with_header("content-type", "application/json") .with_body(page2) .create(); let _m3 = server .mock("GET", "/page3") .match_header("authorization", "Bearer BEARER") .match_header("prefer", "IdType=\"ImmutableId\"") .with_status(200) .with_header("content-type", "application/json") .with_body(page3) .create(); let client = client_with_retries(0); let url = url_message_collection(&base, "F1", 100); let ids = collect_all_ids(&client, &url, &[]).unwrap(); assert_eq!(ids, vec!["M1".to_owned(), "M2".to_owned(), "M3".to_owned()]); } #[test] fn pagination_terminates_when_no_next_link_present() { let mut server = Server::new(); let base = server.url(); let body = json!({"value": [{"id": "A"}, {"id": "B"}]}).to_string(); let _m = server .mock("GET", "/me/mailFolders/F/messages?$top=100&$select=id") .with_status(200) .with_header("content-type", "application/json") .with_body(body) .create(); let client = client_with_retries(0); let url = url_message_collection(&base, "F", 100); let mut pages = 0; paged_collect(&client, &url, &[], |_| { pages += 1; Ok(()) }) .unwrap(); assert_eq!(pages, 1); } #[test] fn header_redelivery_is_asserted_via_mockito_match() { let mut server = Server::new(); let base = server.url(); let next = format!("{base}/page-two-different-segment"); let page1 = json!({ "value": [{"id": "OnlyOnPageOne"}], "@odata.nextLink": next }) .to_string(); let page2 = json!({"value": [{"id": "OnlyOnPageTwo"}]}).to_string(); let _m1 = server .mock("GET", "/me/mailFolders/F/messages?$top=100&$select=id") .match_header("prefer", "IdType=\"ImmutableId\"") .match_header("authorization", "Bearer BEARER") .with_status(200) .with_header("content-type", "application/json") .with_body(page1) .create(); let _m2 = server .mock("GET", "/page-two-different-segment") .match_header("prefer", "IdType=\"ImmutableId\"") .match_header("authorization", "Bearer BEARER") .with_status(200) .with_header("content-type", "application/json") .with_body(page2) .create(); let c = client_with_retries(0); let url = url_message_collection(&base, "F", 100); let ids = collect_all_ids(&c, &url, &[]).unwrap(); assert_eq!(ids.len(), 2); } #[test] fn case_sensitive_immutable_id_round_trip() { let mut server = Server::new(); let id = "AAkAAGFsaWNlAAA="; let body = json!({"value": [{"id": id}]}).to_string(); let _m = server .mock("GET", "/me/mailFolders/F/messages?$top=100&$select=id") .with_status(200) .with_header("content-type", "application/json") .with_body(body) .create(); let base = server.url(); let client = client_with_retries(0); let url = url_message_collection(&base, "F", 100); let ids = collect_all_ids(&client, &url, &[]).unwrap(); assert_eq!(ids[0], id); assert!(ids[0].contains('=')); } #[test] fn rate_limit_with_retry_after_is_honoured_then_succeeds() { let mut server = Server::new(); let path = "/me?$select=id,userPrincipalName,displayName,mail"; let _m1 = server .mock("GET", path) .with_status(429) .with_header("retry-after", "1") .with_header("content-type", "application/json") .with_body(r#"{"error":{"code":"TooManyRequests"}}"#) .expect(1) .create(); let _m2 = server .mock("GET", path) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"uid","userPrincipalName":"alice@x.com"}"#) .expect(1) .create(); let base = server.url(); let client = client_with_retries(3); let started = std::time::Instant::now(); let resp = client .get( &format!("{base}/me?$select=id,userPrincipalName,displayName,mail"), Accept::Json, ) .unwrap(); let elapsed = started.elapsed(); assert_eq!(resp.status, 200); assert!(elapsed >= std::time::Duration::from_millis(800)); assert!(client.retries_observed() >= 1); assert!(client.retry_after_sleeps() >= 1); } #[test] fn rate_limit_without_retry_after_uses_shared_escalation() { let mut server = Server::new(); let path = "/me?$select=id,userPrincipalName,displayName,mail"; let _m1 = server .mock("GET", path) .with_status(429) .with_header("content-type", "application/json") .with_body(r#"{"error":{"code":"TooManyRequests"}}"#) .expect(1) .create(); let _m2 = server .mock("GET", path) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"uid","userPrincipalName":"alice@x.com"}"#) .expect(1) .create(); let base = server.url(); let client = client_with_retries(3); let r = client .get( &format!("{base}/me?$select=id,userPrincipalName,displayName,mail"), Accept::Json, ) .unwrap(); assert_eq!(r.status, 200); assert!(client.retries_observed() >= 1); } #[test] fn service_unavailable_503_is_retried() { let mut server = Server::new(); let _m1 = server .mock("GET", "/me") .with_status(503) .expect(1) .create(); let _m2 = server .mock("GET", "/me") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"u","userPrincipalName":"a@b"}"#) .expect(1) .create(); let base = server.url(); let c = client_with_retries(3); let r = c.get(&format!("{base}/me"), Accept::Json).unwrap(); assert_eq!(r.status, 200); } #[test] fn http_404_on_per_item_get_is_treated_as_vanished() { let mut server = Server::new(); let _m = server .mock("GET", "/me/messages/GONE/$value") .with_status(404) .with_body("gone") .create(); let base = server.url(); let c = client_with_retries(0); let url = format!("{base}/me/messages/GONE/$value"); let err = c.get(&url, Accept::Text).unwrap_err(); assert!(matches!(err, GraphError::Vanished)); } #[test] fn http_401_is_auth_error() { let mut server = Server::new(); let _m = server .mock("GET", "/me") .with_status(401) .with_body("unauthenticated") .create(); let base = server.url(); let c = client_with_retries(0); let url = format!("{base}/me"); let err = c.get(&url, Accept::Json).unwrap_err(); assert!(matches!(err, GraphError::Auth(_))); } #[test] fn malformed_4xx_is_fatal() { let mut server = Server::new(); let _m = server .mock("GET", "/me") .with_status(422) .with_body("malformed payload") .create(); let base = server.url(); let c = client_with_retries(0); let err = c.get(&format!("{base}/me"), Accept::Json).unwrap_err(); assert!(matches!(err, GraphError::HttpStatus { status: 422, .. })); } #[test] fn mime_value_endpoint_returns_raw_rfc5322_bytes() { let mut server = Server::new(); let mime = "From: a@x\r\nTo: b@x\r\nSubject: hi\r\nDate: Tue, 27 May 2026 10:00:00 +0000\r\nMessage-ID: \r\n\r\nbody"; let _m = server .mock("GET", "/me/messages/MID/$value") .match_header("accept", "text/plain") .match_header("authorization", "Bearer BEARER") .match_header("prefer", "IdType=\"ImmutableId\"") .with_status(200) .with_header("content-type", "text/plain") .with_body(mime) .create(); let base = server.url(); let c = client_with_retries(0); let url = format!("{base}/me/messages/MID/$value"); let resp = c.get(&url, Accept::Text).unwrap(); assert_eq!(resp.body, mime.as_bytes()); } #[test] fn recurrence_daily_with_interval_round_trips() { let pr = json!({ "pattern": {"type": "daily", "interval": 2}, "range": {"type": "noEnd"} }); let out = convert_patterned_recurrence(&pr).unwrap(); assert_eq!(out["frequency"], "daily"); assert_eq!(out["interval"], 2); } #[test] fn recurrence_weekly_with_days_of_week_and_count() { let pr = json!({ "pattern": { "type": "weekly", "interval": 1, "daysOfWeek": ["monday", "wednesday", "friday"] }, "range": {"type": "numbered", "numberOfOccurrences": 12} }); let out = convert_patterned_recurrence(&pr).unwrap(); assert_eq!(out["frequency"], "weekly"); assert_eq!(out["count"], 12); assert_eq!(out["byDay"][0]["day"], "mo"); assert_eq!(out["byDay"][2]["day"], "fr"); } #[test] fn recurrence_relative_monthly_emits_setpos() { let pr = json!({ "pattern": { "type": "relativeMonthly", "interval": 1, "daysOfWeek": ["thursday"], "index": "third" }, "range": {"type": "noEnd"} }); let out = convert_patterned_recurrence(&pr).unwrap(); assert_eq!(out["frequency"], "monthly"); assert_eq!(out["bySetPosition"][0], 3); assert_eq!(out["byDay"][0]["day"], "th"); } #[test] fn recurrence_yearly_with_end_date() { let pr = json!({ "pattern": {"type": "absoluteYearly", "interval": 1, "month": 12, "dayOfMonth": 25}, "range": {"type": "endDate", "endDate": "2030-12-25"} }); let out = convert_patterned_recurrence(&pr).unwrap(); assert_eq!(out["frequency"], "yearly"); assert_eq!(out["until"], "2030-12-25T23:59:59"); } #[test] fn occurrence_event_is_classified_for_skip() { let occ = json!({"id": "occ-1", "type": "occurrence", "seriesMasterId": "master-1"}); assert_eq!(classify_event_type(&occ), EventType::Occurrence); } #[test] fn exception_event_carries_series_master_id() { let ex = json!({ "id": "ex-1", "iCalUId": "uid-1", "type": "exception", "seriesMasterId": "master-1", "originalStart": "2026-06-15T10:00:00Z", "subject": "Moved", "start": {"dateTime": "2026-06-15T11:00:00.0000000", "timeZone": "UTC"}, "end": {"dateTime": "2026-06-15T12:00:00.0000000", "timeZone": "UTC"} }); let conv = convert_event(&ex, None).unwrap(); assert_eq!(conv.event_type, EventType::Exception); assert_eq!(conv.series_master_id.as_deref(), Some("master-1")); assert_eq!(conv.original_start.as_deref(), Some("2026-06-15T10:00:00Z")); } #[test] fn contact_minimal_yields_jscontact_card() { let c = json!({ "id": "graph-1", "displayName": "Alice Liddell", "givenName": "Alice", "surname": "Liddell", "emailAddresses": [{"name": "Alice", "address": "alice@x.com"}] }); let out = convert_contact(&c).unwrap(); assert!(out.uid.starts_with("vandelay-graph-")); assert_eq!(out.data["@type"], "Card"); assert_eq!(out.data["name"]["full"], "Alice Liddell"); let emails = out.data["emails"].as_object().unwrap(); assert_eq!(emails["email-1"]["address"], "alice@x.com"); } #[test] fn integration_dry_run_against_mock_server_lists_three_surfaces() { let mut server = Server::new(); let _principal = server .mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned())) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"uid","userPrincipalName":"alice@x.com"}"#) .create(); let _folders = server .mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[{"id":"FMAIL","displayName":"Inbox","isHidden":false}]}"#) .create(); let _children = server .mock( "GET", "/me/mailFolders/FMAIL/childFolders?$top=100&includeHiddenFolders=true", ) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); let _calendars = server .mock("GET", "/me/calendars?$top=100") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[{"id":"CAL1","name":"Calendar","isDefaultCalendar":true}]}"#) .create(); let _contact_folders = server .mock("GET", "/me/contactFolders?$top=100") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[{"id":"CON1","displayName":"Contacts"}]}"#) .create(); let base = server.url(); let common = vandelay::sync::CommonConfig { archive: tempfile::NamedTempFile::new().unwrap().path().to_owned(), threads: 2, dry_run: true, max_retries: 0, allow_invalid_certs: false, logger: vandelay::logging::Logger::new(0), }; let config = vandelay::sync::import_exchange_graph::GraphImportConfig { auth: vandelay::sync::import_exchange_graph::GraphAuth::PreAcquired { token: make_jwt(9999999999, "alice@x.com"), }, api_base: base.clone(), user_target: None, mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary, objects: None, event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text, graph_connections: 2, top: 100, allow_source_change: false, }; let summary = vandelay::sync::import_exchange_graph::run(common, config).unwrap(); let mailbox = summary .per_type .iter() .find(|(t, _)| *t == "mailbox") .map(|(_, c)| c.created) .unwrap_or(0); let calendar = summary .per_type .iter() .find(|(t, _)| *t == "calendar") .map(|(_, c)| c.created) .unwrap_or(0); let addressbook = summary .per_type .iter() .find(|(t, _)| *t == "addressbook") .map(|(_, c)| c.created) .unwrap_or(0); assert_eq!(mailbox, 1); assert_eq!(calendar, 1); assert_eq!(addressbook, 1); } #[test] fn integration_full_run_mail_only_imports_mime_via_value() { let mut server = Server::new(); let _principal = server .mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned())) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"uid-1","userPrincipalName":"alice@x.com"}"#) .create(); let _folders = server .mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[{"id":"FMAIL","displayName":"Inbox","isHidden":false}]}"#) .create(); let _children = server .mock( "GET", "/me/mailFolders/FMAIL/childFolders?$top=100&includeHiddenFolders=true", ) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); let _well_known: Vec = [ "inbox", "drafts", "sentitems", "deleteditems", "junkemail", "archive", ] .iter() .map(|name| { let path = format!("/me/mailFolders/{name}?$select=id"); server .mock("GET", path.as_str()) .with_status(200) .with_header("content-type", "application/json") .with_body(if *name == "inbox" { r#"{"id":"FMAIL"}"# } else { r#"{"id":"OTHER"}"# }) .create() }) .collect(); let _ids = server .mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[{"id":"MSG-1"}]}"#) .create(); let mime = "From: a@x\r\nTo: b@x\r\nSubject: hi\r\nDate: Tue, 27 May 2026 10:00:00 +0000\r\nMessage-ID: \r\n\r\nhello"; let _value = server .mock("GET", "/me/messages/MSG-1/$value") .match_header("accept", "text/plain") .with_status(200) .with_header("content-type", "text/plain") .with_body(mime) .create(); let tmp = tempfile::NamedTempFile::new().unwrap(); let archive_path = tmp.path().to_owned(); let common = vandelay::sync::CommonConfig { archive: archive_path.clone(), threads: 2, dry_run: false, max_retries: 0, allow_invalid_certs: false, logger: vandelay::logging::Logger::new(0), }; let config = vandelay::sync::import_exchange_graph::GraphImportConfig { auth: vandelay::sync::import_exchange_graph::GraphAuth::PreAcquired { token: make_jwt(9999999999, "alice@x.com"), }, api_base: server.url(), user_target: None, mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary, objects: Some(vec![ vandelay::types::ObjectType::Mailbox, vandelay::types::ObjectType::Email, ]), event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text, graph_connections: 2, top: 100, allow_source_change: false, }; drop(tmp); let summary = vandelay::sync::import_exchange_graph::run(common, config).unwrap(); let emails = summary .per_type .iter() .find(|(t, _)| *t == "email") .map(|(_, c)| c.created) .unwrap_or(0); assert_eq!(emails, 1, "expected one email created, got {emails}"); } #[test] fn integration_duplicate_message_id_does_not_abort_run() { let mut server = Server::new(); let _principal = server .mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned())) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"uid-1","userPrincipalName":"alice@x.com"}"#) .create(); let _folders = server .mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[{"id":"FMAIL","displayName":"Inbox","isHidden":false}]}"#) .create(); let _children = server .mock( "GET", "/me/mailFolders/FMAIL/childFolders?$top=100&includeHiddenFolders=true", ) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); let _well_known: Vec = [ "inbox", "drafts", "sentitems", "deleteditems", "junkemail", "archive", ] .iter() .map(|name| { let path = format!("/me/mailFolders/{name}?$select=id"); server .mock("GET", path.as_str()) .with_status(200) .with_header("content-type", "application/json") .with_body(if *name == "inbox" { r#"{"id":"FMAIL"}"# } else { r#"{"id":"OTHER"}"# }) .create() }) .collect(); let _ids = server .mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[{"id":"MSG-1"},{"id":"MSG-1"}]}"#) .create(); let mime = "From: a@x\r\nTo: b@x\r\nSubject: hi\r\nDate: Tue, 27 May 2026 10:00:00 +0000\r\nMessage-ID: \r\n\r\nhello"; let _value = server .mock("GET", "/me/messages/MSG-1/$value") .match_header("accept", "text/plain") .with_status(200) .with_header("content-type", "text/plain") .with_body(mime) .create(); let tmp = tempfile::NamedTempFile::new().unwrap(); let archive_path = tmp.path().to_owned(); let common = vandelay::sync::CommonConfig { archive: archive_path.clone(), threads: 2, dry_run: false, max_retries: 0, allow_invalid_certs: false, logger: vandelay::logging::Logger::new(0), }; let config = vandelay::sync::import_exchange_graph::GraphImportConfig { auth: vandelay::sync::import_exchange_graph::GraphAuth::PreAcquired { token: make_jwt(9999999999, "alice@x.com"), }, api_base: server.url(), user_target: None, mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary, objects: Some(vec![ vandelay::types::ObjectType::Mailbox, vandelay::types::ObjectType::Email, ]), event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text, graph_connections: 2, top: 100, allow_source_change: false, }; drop(tmp); let summary = vandelay::sync::import_exchange_graph::run(common, config).unwrap(); let emails = summary .per_type .iter() .find(|(t, _)| *t == "email") .map(|(_, c)| c.created) .unwrap_or(0); assert_eq!(emails, 1, "expected one email created, got {emails}"); } #[test] fn integration_full_run_is_convergent_on_second_invocation() { let mut server = Server::new(); let _principal = server .mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned())) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"uid-2","userPrincipalName":"alice@x.com"}"#) .expect_at_least(2) .create(); let _folders = server .mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[{"id":"FMAIL","displayName":"Inbox","isHidden":false}]}"#) .expect_at_least(2) .create(); let _children = server .mock( "GET", "/me/mailFolders/FMAIL/childFolders?$top=100&includeHiddenFolders=true", ) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .expect_at_least(2) .create(); for name in [ "inbox", "drafts", "sentitems", "deleteditems", "junkemail", "archive", ] { server .mock("GET", format!("/me/mailFolders/{name}?$select=id").as_str()) .with_status(200) .with_header("content-type", "application/json") .with_body(if name == "inbox" { r#"{"id":"FMAIL"}"# } else { r#"{"id":"OTHER"}"# }) .expect_at_least(2) .create(); } let _ids = server .mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[{"id":"MSG-A"}]}"#) .expect_at_least(2) .create(); let mime = "From: a@x\r\nTo: b@x\r\nSubject: hi\r\nDate: Tue, 27 May 2026 10:00:00 +0000\r\nMessage-ID: \r\n\r\nbody"; let _value = server .mock("GET", "/me/messages/MSG-A/$value") .with_status(200) .with_header("content-type", "text/plain") .with_body(mime) .expect(1) .create(); let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned(); let api_base = server.url(); let make_config = || vandelay::sync::import_exchange_graph::GraphImportConfig { auth: vandelay::sync::import_exchange_graph::GraphAuth::PreAcquired { token: make_jwt(9999999999, "alice@x.com"), }, api_base: api_base.clone(), user_target: None, mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary, objects: Some(vec![ vandelay::types::ObjectType::Mailbox, vandelay::types::ObjectType::Email, ]), event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text, graph_connections: 2, top: 100, allow_source_change: false, }; let make_common = |path: std::path::PathBuf| vandelay::sync::CommonConfig { archive: path, threads: 2, dry_run: false, max_retries: 0, allow_invalid_certs: false, logger: vandelay::logging::Logger::new(0), }; let first = vandelay::sync::import_exchange_graph::run(make_common(archive.clone()), make_config()) .unwrap(); let created_first = first .per_type .iter() .find(|(t, _)| *t == "email") .map(|(_, c)| c.created) .unwrap_or(0); assert_eq!(created_first, 1); let second = vandelay::sync::import_exchange_graph::run(make_common(archive), make_config()).unwrap(); let created_second = second .per_type .iter() .find(|(t, _)| *t == "email") .map(|(_, c)| c.created) .unwrap_or(0); assert_eq!(created_second, 0, "second run should not re-import"); } #[test] fn source_change_protection_refuses_a_different_account() { let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned(); let conn = vandelay::db::init::open(&archive).unwrap(); vandelay::db::sources::upsert_source( &conn, &vandelay::db::sources::SourceKey { kind: "exchange_graph".to_owned(), session_url: "https://login.microsoftonline.com/common|https://graph.microsoft.com/v1.0" .to_owned(), account_id: "old-user-id".to_owned(), }, Some("old@x.com"), "old@x.com", ) .unwrap(); drop(conn); let mut server = Server::new(); let _principal = server .mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned())) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"new-user-id","userPrincipalName":"new@x.com"}"#) .create(); let common = vandelay::sync::CommonConfig { archive, threads: 2, dry_run: false, max_retries: 0, allow_invalid_certs: false, logger: vandelay::logging::Logger::new(0), }; let config = vandelay::sync::import_exchange_graph::GraphImportConfig { auth: vandelay::sync::import_exchange_graph::GraphAuth::PreAcquired { token: make_jwt(9999999999, "new@x.com"), }, api_base: server.url(), user_target: None, mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary, objects: None, event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text, graph_connections: 2, top: 100, allow_source_change: false, }; let err = vandelay::sync::import_exchange_graph::run(common, config).unwrap_err(); assert!( matches!(err, vandelay::error::Error::SourceChange(_)), "expected SourceChange error, got {err:?}" ); } fn stub_well_known_folders(server: &mut Server, inbox_id: &str) { for name in [ "drafts", "sentitems", "deleteditems", "junkemail", "archive", ] { let body = format!(r#"{{"id":"OTHER-{name}"}}"#); server .mock("GET", format!("/me/mailFolders/{name}?$select=id").as_str()) .with_status(200) .with_header("content-type", "application/json") .with_body(body) .expect_at_least(0) .create(); } server .mock("GET", "/me/mailFolders/inbox?$select=id") .with_status(200) .with_header("content-type", "application/json") .with_body(format!(r#"{{"id":"{inbox_id}"}}"#)) .expect_at_least(0) .create(); } fn make_common(archive: std::path::PathBuf) -> vandelay::sync::CommonConfig { vandelay::sync::CommonConfig { archive, threads: 2, dry_run: false, max_retries: 0, allow_invalid_certs: false, logger: vandelay::logging::Logger::new(0), } } fn make_config( api_base: String, user_target: Option, objects: Option>, ) -> vandelay::sync::import_exchange_graph::GraphImportConfig { vandelay::sync::import_exchange_graph::GraphImportConfig { auth: vandelay::sync::import_exchange_graph::GraphAuth::PreAcquired { token: make_jwt(9999999999, "alice@x.com"), }, api_base, user_target, mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary, objects, event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text, graph_connections: 2, top: 100, allow_source_change: false, } } #[test] fn users_upn_routes_through_users_segment_not_me() { let mut server = Server::new(); let _resolve = server .mock( "GET", Matcher::Regex(r"^/users/alice[@%][^/]*\?\$select=id".to_owned()), ) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"user-uuid-7","userPrincipalName":"alice@x.com"}"#) .create(); let _principal = server .mock( "GET", Matcher::Regex(r"^/users/user-uuid-7\?\$select=id".to_owned()), ) .with_status(200) .with_header("content-type", "application/json") .with_body( r#"{"id":"user-uuid-7","userPrincipalName":"alice@x.com","displayName":"Alice"}"#, ) .create(); let _folders = server .mock( "GET", "/users/user-uuid-7/mailFolders?$top=100&includeHiddenFolders=true", ) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .expect_at_least(1) .create(); let _calendars = server .mock("GET", "/users/user-uuid-7/calendars?$top=100") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .expect_at_least(0) .create(); let _contact_folders = server .mock("GET", "/users/user-uuid-7/contactFolders?$top=100") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .expect_at_least(0) .create(); let base = server.url(); let common = vandelay::sync::CommonConfig { archive: tempfile::NamedTempFile::new().unwrap().path().to_owned(), threads: 2, dry_run: true, max_retries: 0, allow_invalid_certs: false, logger: vandelay::logging::Logger::new(0), }; let summary = vandelay::sync::import_exchange_graph::run( common, make_config(base, Some("alice@x.com".to_owned()), None), ) .unwrap(); let mailbox = summary .per_type .iter() .find(|(t, _)| *t == "mailbox") .map(|(_, c)| c.created) .unwrap_or(99); assert_eq!(mailbox, 0); } #[test] fn series_master_with_exception_merges_into_recurrence_overrides() { let mut server = Server::new(); server .mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned())) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"uid","userPrincipalName":"alice@x.com"}"#) .create(); server .mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); server .mock("GET", "/me/mailboxSettings?$select=timeZone") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"timeZone":"UTC"}"#) .create(); server .mock("GET", "/me/calendars?$top=100") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[{"id":"CAL1","name":"Calendar","isDefaultCalendar":true}]}"#) .create(); server .mock("GET", "/me/contactFolders?$top=100") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); server .mock( "GET", "/me/calendars/CAL1/events?$top=100&$select=id,type,seriesMasterId", ) .with_status(200) .with_header("content-type", "application/json") .with_body( r#"{"value":[ {"id":"MASTER","type":"seriesMaster","iCalUId":"uid-master"}, {"id":"EX","type":"exception","seriesMasterId":"MASTER","iCalUId":"uid-master"} ]}"#, ) .create(); server .mock("GET", "/me/events/MASTER") .with_status(200) .with_header("content-type", "application/json") .with_body( r#"{ "id":"MASTER", "iCalUId":"uid-master", "type":"seriesMaster", "subject":"Weekly sync", "start":{"dateTime":"2026-05-04T15:00:00.0000000","timeZone":"UTC"}, "end":{"dateTime":"2026-05-04T16:00:00.0000000","timeZone":"UTC"}, "recurrence":{ "pattern":{"type":"weekly","interval":1,"daysOfWeek":["monday"]}, "range":{"type":"noEnd"} } }"#, ) .create(); server .mock("GET", "/me/events/EX") .with_status(200) .with_header("content-type", "application/json") .with_body( r#"{ "id":"EX", "iCalUId":"uid-master", "type":"exception", "seriesMasterId":"MASTER", "originalStart":"2026-05-11T15:00:00Z", "subject":"Moved sync", "start":{"dateTime":"2026-05-11T16:00:00.0000000","timeZone":"UTC"}, "end":{"dateTime":"2026-05-11T17:00:00.0000000","timeZone":"UTC"} }"#, ) .create(); let base = server.url(); let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned(); let summary = vandelay::sync::import_exchange_graph::run( make_common(archive.clone()), make_config( base, None, Some(vec![ vandelay::types::ObjectType::Calendar, vandelay::types::ObjectType::CalendarEvent, ]), ), ) .unwrap(); let events = summary .per_type .iter() .find(|(t, _)| *t == "calendarevent") .map(|(_, c)| c.created) .unwrap_or(0); assert_eq!(events, 1, "exactly one master row, exception merged in"); let conn = vandelay::db::init::open(&archive).unwrap(); let row: String = conn .query_row("SELECT data FROM calendar_events", [], |row| row.get(0)) .unwrap(); let v: serde_json::Value = serde_json::from_str(&row).unwrap(); let overrides = v .get("recurrenceOverrides") .and_then(|o| o.as_object()) .expect("recurrenceOverrides present"); assert!( overrides.contains_key("2026-05-11T15:00:00"), "override keyed by originalStart as LocalDateTime; got keys: {:?}", overrides.keys().collect::>() ); let override_data = &overrides["2026-05-11T15:00:00"]; assert_eq!(override_data["title"], "Moved sync"); } #[test] fn occurrence_event_is_skipped_no_row_no_id_mapping() { let mut server = Server::new(); server .mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned())) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"uid","userPrincipalName":"alice@x.com"}"#) .create(); server .mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); server .mock("GET", "/me/mailboxSettings?$select=timeZone") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"timeZone":"UTC"}"#) .create(); server .mock("GET", "/me/calendars?$top=100") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[{"id":"CAL1","name":"Calendar","isDefaultCalendar":true}]}"#) .create(); server .mock("GET", "/me/contactFolders?$top=100") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); server .mock( "GET", "/me/calendars/CAL1/events?$top=100&$select=id,type,seriesMasterId", ) .with_status(200) .with_header("content-type", "application/json") .with_body( r#"{"value":[ {"id":"OCC","type":"occurrence","seriesMasterId":"MASTER"} ]}"#, ) .create(); let occ_get = server .mock("GET", "/me/events/OCC") .with_status(500) .with_body("MUST NOT BE CALLED") .expect(0) .create(); let base = server.url(); let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned(); let summary = vandelay::sync::import_exchange_graph::run( make_common(archive.clone()), make_config( base, None, Some(vec![ vandelay::types::ObjectType::Calendar, vandelay::types::ObjectType::CalendarEvent, ]), ), ) .unwrap(); let events = summary .per_type .iter() .find(|(t, _)| *t == "calendarevent") .map(|(_, c)| c.created) .unwrap_or(99); assert_eq!(events, 0, "occurrence must not be fetched or stored"); occ_get.assert(); let conn = vandelay::db::init::open(&archive).unwrap(); let count: i64 = conn .query_row("SELECT COUNT(*) FROM calendar_events", [], |row| row.get(0)) .unwrap(); assert_eq!(count, 0); } #[test] fn hidden_mail_folder_has_is_subscribed_zero() { let mut server = Server::new(); server .mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned())) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"uid","userPrincipalName":"alice@x.com"}"#) .create(); server .mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true") .with_status(200) .with_header("content-type", "application/json") .with_body( r#"{"value":[ {"id":"FVISIBLE","displayName":"Inbox","isHidden":false}, {"id":"FHIDDEN","displayName":"SearchFolders","isHidden":true} ]}"#, ) .create(); server .mock( "GET", "/me/mailFolders/FVISIBLE/childFolders?$top=100&includeHiddenFolders=true", ) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); server .mock( "GET", "/me/mailFolders/FHIDDEN/childFolders?$top=100&includeHiddenFolders=true", ) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); server .mock( "GET", "/me/mailFolders/FVISIBLE/messages?$top=100&$select=id", ) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); server .mock( "GET", "/me/mailFolders/FHIDDEN/messages?$top=100&$select=id", ) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); stub_well_known_folders(&mut server, "FVISIBLE"); let base = server.url(); let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned(); let _ = vandelay::sync::import_exchange_graph::run( make_common(archive.clone()), make_config( base, None, Some(vec![ vandelay::types::ObjectType::Mailbox, vandelay::types::ObjectType::Email, ]), ), ) .unwrap(); let conn = vandelay::db::init::open(&archive).unwrap(); let visible: i64 = conn .query_row( "SELECT is_subscribed FROM mailboxes WHERE name = 'Inbox'", [], |row| row.get(0), ) .unwrap(); let hidden: i64 = conn .query_row( "SELECT is_subscribed FROM mailboxes WHERE name = 'SearchFolders'", [], |row| row.get(0), ) .unwrap(); assert_eq!(visible, 1); assert_eq!(hidden, 0); } #[test] fn well_known_folder_probes_assign_jmap_roles() { let mut server = Server::new(); server .mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned())) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"uid","userPrincipalName":"alice@x.com"}"#) .create(); server .mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true") .with_status(200) .with_header("content-type", "application/json") .with_body( r#"{"value":[ {"id":"INBOX-ID","displayName":"Inbox","isHidden":false}, {"id":"DRAFTS-ID","displayName":"Drafts","isHidden":false}, {"id":"SENT-ID","displayName":"Sent Items","isHidden":false}, {"id":"TRASH-ID","displayName":"Deleted Items","isHidden":false}, {"id":"JUNK-ID","displayName":"Junk Email","isHidden":false}, {"id":"ARCHIVE-ID","displayName":"Archive","isHidden":false} ]}"#, ) .create(); for fid in [ "INBOX-ID", "DRAFTS-ID", "SENT-ID", "TRASH-ID", "JUNK-ID", "ARCHIVE-ID", ] { server .mock( "GET", format!("/me/mailFolders/{fid}/childFolders?$top=100&includeHiddenFolders=true") .as_str(), ) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); server .mock( "GET", format!("/me/mailFolders/{fid}/messages?$top=100&$select=id").as_str(), ) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); } let pairs = [ ("inbox", "INBOX-ID"), ("drafts", "DRAFTS-ID"), ("sentitems", "SENT-ID"), ("deleteditems", "TRASH-ID"), ("junkemail", "JUNK-ID"), ("archive", "ARCHIVE-ID"), ]; for (name, id) in pairs { server .mock("GET", format!("/me/mailFolders/{name}?$select=id").as_str()) .with_status(200) .with_header("content-type", "application/json") .with_body(format!(r#"{{"id":"{id}"}}"#)) .create(); } let base = server.url(); let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned(); let _ = vandelay::sync::import_exchange_graph::run( make_common(archive.clone()), make_config( base, None, Some(vec![ vandelay::types::ObjectType::Mailbox, vandelay::types::ObjectType::Email, ]), ), ) .unwrap(); let conn = vandelay::db::init::open(&archive).unwrap(); let mut stmt = conn .prepare("SELECT name, role FROM mailboxes ORDER BY name") .unwrap(); let rows: Vec<(String, Option)> = stmt .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) .unwrap() .map(Result::unwrap) .collect(); let by_name: std::collections::HashMap> = rows.into_iter().collect(); assert_eq!(by_name["Inbox"].as_deref(), Some("inbox")); assert_eq!(by_name["Drafts"].as_deref(), Some("drafts")); assert_eq!(by_name["Sent Items"].as_deref(), Some("sent")); assert_eq!(by_name["Deleted Items"].as_deref(), Some("trash")); assert_eq!(by_name["Junk Email"].as_deref(), Some("junk")); assert_eq!(by_name["Archive"].as_deref(), Some("archive")); } #[test] fn attachment_message_does_not_trigger_attachments_endpoint() { let mut server = Server::new(); server .mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned())) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"uid","userPrincipalName":"alice@x.com"}"#) .create(); server .mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[{"id":"FMAIL","displayName":"Inbox","isHidden":false}]}"#) .create(); server .mock( "GET", "/me/mailFolders/FMAIL/childFolders?$top=100&includeHiddenFolders=true", ) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); stub_well_known_folders(&mut server, "FMAIL"); server .mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[{"id":"MSG-ATT"}]}"#) .create(); let mime_with_attachment = "From: a@x\r\nTo: b@x\r\nSubject: with attachment\r\n\ Date: Tue, 27 May 2026 10:00:00 +0000\r\nMessage-ID: \r\n\ Content-Type: multipart/mixed; boundary=\"X\"\r\n\r\n\ --X\r\nContent-Type: text/plain\r\n\r\nbody\r\n\ --X\r\nContent-Type: application/octet-stream\r\nContent-Disposition: attachment; filename=\"a.bin\"\r\nContent-Transfer-Encoding: base64\r\n\r\n\ SGVsbG8=\r\n--X--\r\n"; server .mock("GET", "/me/messages/MSG-ATT/$value") .with_status(200) .with_header("content-type", "text/plain") .with_body(mime_with_attachment) .create(); let no_attachments = server .mock( "GET", Matcher::Regex(r"^/me/messages/[^/]+/attachments".to_owned()), ) .with_status(500) .with_body("MUST NOT BE CALLED") .expect(0) .create(); let base = server.url(); let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned(); let summary = vandelay::sync::import_exchange_graph::run( make_common(archive), make_config( base, None, Some(vec![ vandelay::types::ObjectType::Mailbox, vandelay::types::ObjectType::Email, ]), ), ) .unwrap(); let emails = summary .per_type .iter() .find(|(t, _)| *t == "email") .map(|(_, c)| c.created) .unwrap_or(0); assert_eq!(emails, 1); no_attachments.assert(); } #[test] fn event_get_carries_outlook_timezone_and_body_content_type_prefer() { let mut server = Server::new(); server .mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned())) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"uid","userPrincipalName":"alice@x.com"}"#) .create(); server .mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); server .mock("GET", "/me/mailboxSettings?$select=timeZone") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"timeZone":"Pacific Standard Time"}"#) .create(); server .mock("GET", "/me/calendars?$top=100") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[{"id":"CAL1","name":"Calendar"}]}"#) .create(); server .mock("GET", "/me/contactFolders?$top=100") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); let _events_list = server .mock( "GET", "/me/calendars/CAL1/events?$top=100&$select=id,type,seriesMasterId", ) .match_header( "prefer", Matcher::AllOf(vec![ Matcher::Regex(r#"outlook\.timezone="UTC""#.to_owned()), Matcher::Regex(r#"IdType="ImmutableId""#.to_owned()), ]), ) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[{"id":"EVT","type":"singleInstance"}]}"#) .expect(1) .create(); let _event_get = server .mock("GET", "/me/events/EVT") .match_header( "prefer", Matcher::AllOf(vec![ Matcher::Regex(r#"outlook\.body-content-type="text""#.to_owned()), Matcher::Regex(r#"outlook\.timezone="UTC""#.to_owned()), Matcher::Regex(r#"IdType="ImmutableId""#.to_owned()), ]), ) .with_status(200) .with_header("content-type", "application/json") .with_body( r#"{ "id":"EVT","iCalUId":"uid-evt","type":"singleInstance", "subject":"Test","start":{"dateTime":"2026-05-27T10:00:00.0000000","timeZone":"UTC"}, "end":{"dateTime":"2026-05-27T11:00:00.0000000","timeZone":"UTC"} }"#, ) .expect(1) .create(); let base = server.url(); let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned(); let _ = vandelay::sync::import_exchange_graph::run( make_common(archive), make_config( base, None, Some(vec![ vandelay::types::ObjectType::Calendar, vandelay::types::ObjectType::CalendarEvent, ]), ), ) .unwrap(); } #[test] fn graph_client_retries_after_401_when_bearer_is_swapped() { let mut server = Server::new(); let _m1 = server .mock("GET", "/me") .match_header("authorization", "Bearer EXPIRED") .with_status(401) .with_body("expired") .expect(1) .create(); let _m2 = server .mock("GET", "/me") .match_header("authorization", "Bearer FRESH") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"u","userPrincipalName":"a@b"}"#) .expect(1) .create(); let base = server.url(); let client = GraphClient::new("EXPIRED".to_owned(), RetryPolicy::new(0), false); let url = format!("{base}/me"); let err = client.get(&url, Accept::Json).unwrap_err(); assert!(matches!(err, GraphError::Auth(_))); client.set_bearer("FRESH".to_owned()); let resp = client.get(&url, Accept::Json).unwrap(); assert_eq!(resp.status, 200); } #[test] fn full_run_records_graph_id_in_sync_id_exchange_graph_with_padding() { let mut server = Server::new(); let _principal = server .mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned())) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"uid-pad","userPrincipalName":"pad@x.com"}"#) .create(); let _folders = server .mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[{"id":"AAkA-Padded==","displayName":"Inbox","isHidden":false}]}"#) .create(); let _children = server .mock( "GET", "/me/mailFolders/AAkA-Padded%3D%3D/childFolders?$top=100&includeHiddenFolders=true", ) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); for name in [ "inbox", "drafts", "sentitems", "deleteditems", "junkemail", "archive", ] { server .mock("GET", format!("/me/mailFolders/{name}?$select=id").as_str()) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"OTHER"}"#) .create(); } let _ids = server .mock( "GET", "/me/mailFolders/AAkA-Padded%3D%3D/messages?$top=100&$select=id", ) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); let tmp = tempfile::NamedTempFile::new().unwrap(); let archive = tmp.path().to_owned(); let common = vandelay::sync::CommonConfig { archive: archive.clone(), threads: 2, dry_run: false, max_retries: 0, allow_invalid_certs: false, logger: vandelay::logging::Logger::new(0), }; let config = vandelay::sync::import_exchange_graph::GraphImportConfig { auth: vandelay::sync::import_exchange_graph::GraphAuth::PreAcquired { token: make_jwt(9999999999, "pad@x.com"), }, api_base: server.url(), user_target: None, mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary, objects: Some(vec![vandelay::types::ObjectType::Mailbox]), event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text, graph_connections: 2, top: 100, allow_source_change: false, }; drop(tmp); let _ = vandelay::sync::import_exchange_graph::run(common, config).unwrap(); let conn = vandelay::db::init::open(&archive).unwrap(); let (graph_id, type_name): (String, String) = conn .query_row( "SELECT graph_id, type_name FROM sync_id_exchange_graph", [], |row| Ok((row.get(0)?, row.get(1)?)), ) .unwrap(); assert_eq!( graph_id, "AAkA-Padded==", "case-sensitive padded id round-trips verbatim" ); assert_eq!(type_name, "mailbox"); let account_id: String = conn .query_row( "SELECT account_id FROM sources WHERE kind = 'exchange_graph'", [], |row| row.get(0), ) .unwrap(); assert_eq!( account_id, "uid-pad", "primary kind uses directory id verbatim" ); } #[test] fn archive_mailbox_kind_encodes_synthetic_suffix_in_account_id() { let mut server = Server::new(); let _principal = server .mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned())) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"uid-arch","userPrincipalName":"a@x.com"}"#) .create(); let _root = server .mock( "GET", "/me/mailFolders/archive/childFolders?$top=100&includeHiddenFolders=true", ) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); let tmp = tempfile::NamedTempFile::new().unwrap(); let archive = tmp.path().to_owned(); let common = vandelay::sync::CommonConfig { archive: archive.clone(), threads: 2, dry_run: false, max_retries: 0, allow_invalid_certs: false, logger: vandelay::logging::Logger::new(0), }; let config = vandelay::sync::import_exchange_graph::GraphImportConfig { auth: vandelay::sync::import_exchange_graph::GraphAuth::PreAcquired { token: make_jwt(9999999999, "a@x.com"), }, api_base: server.url(), user_target: None, mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Archive, objects: Some(vec![vandelay::types::ObjectType::Mailbox]), event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text, graph_connections: 2, top: 100, allow_source_change: false, }; drop(tmp); let _ = vandelay::sync::import_exchange_graph::run(common, config).unwrap(); let conn = vandelay::db::init::open(&archive).unwrap(); let account_id: String = conn .query_row( "SELECT account_id FROM sources WHERE kind = 'exchange_graph'", [], |row| row.get(0), ) .unwrap(); assert_eq!(account_id, "uid-arch#archive"); } #[test] fn allow_source_change_permits_overwriting_a_different_account() { let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned(); let conn = vandelay::db::init::open(&archive).unwrap(); vandelay::db::sources::upsert_source( &conn, &vandelay::db::sources::SourceKey { kind: "exchange_graph".to_owned(), session_url: "https://login.microsoftonline.com/common|https://x.example".to_owned(), account_id: "different-uid".to_owned(), }, Some("old@x.com"), "old@x.com", ) .unwrap(); drop(conn); let mut server = Server::new(); let _principal = server .mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned())) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"new-uid","userPrincipalName":"new@x.com"}"#) .create(); let _folders = server .mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); let common = vandelay::sync::CommonConfig { archive: archive.clone(), threads: 2, dry_run: false, max_retries: 0, allow_invalid_certs: false, logger: vandelay::logging::Logger::new(0), }; let config = vandelay::sync::import_exchange_graph::GraphImportConfig { auth: vandelay::sync::import_exchange_graph::GraphAuth::PreAcquired { token: make_jwt(9999999999, "new@x.com"), }, api_base: server.url(), user_target: None, mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary, objects: Some(vec![vandelay::types::ObjectType::Mailbox]), event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text, graph_connections: 2, top: 100, allow_source_change: true, }; let result = vandelay::sync::import_exchange_graph::run(common, config); assert!( result.is_ok(), "--allow-source-change must permit a different account, got {result:?}" ); } #[test] fn dry_run_makes_no_per_item_get_and_no_sqlite_writes() { let mut server = Server::new(); let _principal = server .mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned())) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"u","userPrincipalName":"a@x.com"}"#) .create(); let _folders = server .mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[{"id":"FMAIL","displayName":"Inbox","isHidden":false}]}"#) .create(); let _children = server .mock( "GET", "/me/mailFolders/FMAIL/childFolders?$top=100&includeHiddenFolders=true", ) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); let _calendars = server .mock("GET", "/me/calendars?$top=100") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); let _contacts = server .mock("GET", "/me/contactFolders?$top=100") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); let no_messages = server .mock( "GET", Matcher::Regex(r"^/me/mailFolders/.*/messages".to_owned()), ) .expect(0) .create(); let no_well_known = server .mock( "GET", Matcher::Regex(r"^/me/mailFolders/[a-z]+\?".to_owned()), ) .expect(0) .create(); let tmp = tempfile::NamedTempFile::new().unwrap(); let archive = tmp.path().to_owned(); let common = vandelay::sync::CommonConfig { archive: archive.clone(), threads: 2, dry_run: true, max_retries: 0, allow_invalid_certs: false, logger: vandelay::logging::Logger::new(0), }; let config = vandelay::sync::import_exchange_graph::GraphImportConfig { auth: vandelay::sync::import_exchange_graph::GraphAuth::PreAcquired { token: make_jwt(9999999999, "a@x.com"), }, api_base: server.url(), user_target: None, mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary, objects: None, event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text, graph_connections: 2, top: 100, allow_source_change: false, }; drop(tmp); let _ = vandelay::sync::import_exchange_graph::run(common, config).unwrap(); no_messages.assert(); no_well_known.assert(); let conn = vandelay::db::init::open(&archive).unwrap(); let sources: i64 = conn .query_row("SELECT count(*) FROM sources", [], |row| row.get(0)) .unwrap(); assert_eq!(sources, 0, "dry-run must not write the sources row"); let mailboxes: i64 = conn .query_row("SELECT count(*) FROM mailboxes", [], |row| row.get(0)) .unwrap(); assert_eq!(mailboxes, 0, "dry-run must not insert mailbox rows"); } #[test] fn refresh_access_token_failure_message_carries_status() { let other = parse_token_response(401, &json!({"error": "invalid_grant"})); match other { TokenResponse::Other { error, .. } => assert_eq!(error, "invalid_grant"), v => panic!("expected Other for invalid_grant, got {v:?}"), } } #[test] fn token_response_expired_recognised() { let r = parse_token_response(400, &json!({"error": "expired_token"})); assert!(matches!(r, TokenResponse::Expired)); } #[test] fn token_response_bad_code_recognised() { let r = parse_token_response(400, &json!({"error": "bad_verification_code"})); assert!(matches!(r, TokenResponse::BadCode)); } #[test] fn folder_enumeration_failure_skips_vanished_deletion() { let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned(); let setup_conn = vandelay::db::init::open(&archive).unwrap(); let sid = vandelay::db::sources::upsert_source( &setup_conn, &vandelay::db::sources::SourceKey { kind: "exchange_graph".to_owned(), session_url: "stale".to_owned(), account_id: "stale-uid".to_owned(), }, Some("stale@x.com"), "stale@x.com", ) .unwrap(); setup_conn .execute( "INSERT INTO mailboxes (id, name, sort_order, is_subscribed) VALUES (42, 'Inbox', 0, 1)", [], ) .unwrap(); vandelay::db::exchange_graph_ids::insert( &setup_conn, sid, vandelay::db::exchange_graph_ids::MAILBOX, "FMAIL", 42, ) .unwrap(); setup_conn .execute( "INSERT INTO blobs (hash, data) VALUES (x'aa', x'68656c6c6f')", [], ) .unwrap(); let blob_id: i64 = setup_conn .query_row("SELECT id FROM blobs WHERE hash = x'aa'", [], |r| r.get(0)) .unwrap(); setup_conn .execute( "INSERT INTO emails (id, blob_id, received_at, mailbox_ids, keywords, message_match) VALUES (1, ?1, '2024-01-01T00:00:00Z', '[42]', '[]', '[]')", rusqlite::params![blob_id], ) .unwrap(); vandelay::db::exchange_graph_ids::insert( &setup_conn, sid, vandelay::db::exchange_graph_ids::EMAIL, "MSG-LOCAL", 1, ) .unwrap(); setup_conn .execute( "UPDATE sources SET kind = 'exchange_graph', session_url = ?1, account_id = 'uid-flake' WHERE id = ?2", rusqlite::params![ "https://login.microsoftonline.com/common|".to_owned() + "https://graph.microsoft.com/v1.0", sid ], ) .unwrap(); drop(setup_conn); let mut server = Server::new(); let _principal = server .mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned())) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"uid-flake","userPrincipalName":"flake@x.com"}"#) .create(); let _folders = server .mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[{"id":"FMAIL","displayName":"Inbox","isHidden":false}]}"#) .create(); let _children = server .mock( "GET", "/me/mailFolders/FMAIL/childFolders?$top=100&includeHiddenFolders=true", ) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"value":[]}"#) .create(); for name in [ "inbox", "drafts", "sentitems", "deleteditems", "junkemail", "archive", ] { server .mock("GET", format!("/me/mailFolders/{name}?$select=id").as_str()) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"FMAIL"}"#) .create(); } let _enum_fail = server .mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id") .with_status(503) .with_body("transient outage") .create(); let common = vandelay::sync::CommonConfig { archive: archive.clone(), threads: 2, dry_run: false, max_retries: 0, allow_invalid_certs: false, logger: vandelay::logging::Logger::new(0), }; let config = vandelay::sync::import_exchange_graph::GraphImportConfig { auth: vandelay::sync::import_exchange_graph::GraphAuth::PreAcquired { token: make_jwt(9999999999, "flake@x.com"), }, api_base: server.url(), user_target: None, mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary, objects: Some(vec![ vandelay::types::ObjectType::Mailbox, vandelay::types::ObjectType::Email, ]), event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text, graph_connections: 2, top: 100, allow_source_change: true, }; let _ = vandelay::sync::import_exchange_graph::run(common, config).unwrap(); let conn = vandelay::db::init::open(&archive).unwrap(); let remaining: i64 = conn .query_row("SELECT count(*) FROM emails WHERE id = 1", [], |row| { row.get(0) }) .unwrap(); assert_eq!( remaining, 1, "transient folder-enumeration failure must NOT delete the locally-known message" ); }