MS Exchange Graph fixes
This commit is contained in:
@@ -590,6 +590,8 @@ fn integration_dry_run_against_mock_server_lists_three_surfaces() {
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
exception_window_years: 5,
|
||||
contact_photos: false,
|
||||
event_attachments: true,
|
||||
allow_source_change: false,
|
||||
};
|
||||
let summary = vandelay::sync::import_exchange_graph::run(common, config).unwrap();
|
||||
@@ -700,6 +702,8 @@ fn integration_full_run_mail_only_imports_mime_via_value() {
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
exception_window_years: 5,
|
||||
contact_photos: false,
|
||||
event_attachments: true,
|
||||
allow_source_change: false,
|
||||
};
|
||||
drop(tmp);
|
||||
@@ -797,6 +801,8 @@ fn integration_duplicate_message_id_does_not_abort_run() {
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
exception_window_years: 5,
|
||||
contact_photos: false,
|
||||
event_attachments: true,
|
||||
allow_source_change: false,
|
||||
};
|
||||
drop(tmp);
|
||||
@@ -887,6 +893,8 @@ fn integration_full_run_is_convergent_on_second_invocation() {
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
exception_window_years: 5,
|
||||
contact_photos: false,
|
||||
event_attachments: true,
|
||||
allow_source_change: false,
|
||||
};
|
||||
let make_common = |path: std::path::PathBuf| vandelay::sync::CommonConfig {
|
||||
@@ -967,6 +975,8 @@ fn source_change_protection_refuses_a_different_account() {
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
exception_window_years: 5,
|
||||
contact_photos: false,
|
||||
event_attachments: true,
|
||||
allow_source_change: false,
|
||||
};
|
||||
let err = vandelay::sync::import_exchange_graph::run(common, config).unwrap_err();
|
||||
@@ -1034,6 +1044,8 @@ fn make_config(
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
exception_window_years: 5,
|
||||
contact_photos: false,
|
||||
event_attachments: true,
|
||||
allow_source_change: false,
|
||||
}
|
||||
}
|
||||
@@ -1746,6 +1758,8 @@ fn full_run_records_graph_id_in_sync_id_exchange_graph_with_padding() {
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
exception_window_years: 5,
|
||||
contact_photos: false,
|
||||
event_attachments: true,
|
||||
allow_source_change: false,
|
||||
};
|
||||
drop(tmp);
|
||||
@@ -1817,6 +1831,8 @@ fn archive_mailbox_kind_encodes_synthetic_suffix_in_account_id() {
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
exception_window_years: 5,
|
||||
contact_photos: false,
|
||||
event_attachments: true,
|
||||
allow_source_change: false,
|
||||
};
|
||||
drop(tmp);
|
||||
@@ -1883,6 +1899,8 @@ fn allow_source_change_permits_overwriting_a_different_account() {
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
exception_window_years: 5,
|
||||
contact_photos: false,
|
||||
event_attachments: true,
|
||||
allow_source_change: true,
|
||||
};
|
||||
let result = vandelay::sync::import_exchange_graph::run(common, config);
|
||||
@@ -1965,6 +1983,8 @@ fn dry_run_makes_no_per_item_get_and_no_sqlite_writes() {
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
exception_window_years: 5,
|
||||
contact_photos: false,
|
||||
event_attachments: true,
|
||||
allow_source_change: false,
|
||||
};
|
||||
drop(tmp);
|
||||
@@ -2134,6 +2154,8 @@ fn folder_enumeration_failure_skips_vanished_deletion() {
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
exception_window_years: 5,
|
||||
contact_photos: false,
|
||||
event_attachments: true,
|
||||
allow_source_change: true,
|
||||
};
|
||||
let _ = vandelay::sync::import_exchange_graph::run(common, config).unwrap();
|
||||
@@ -2735,3 +2757,255 @@ fn drive_items_import_as_file_nodes_and_skip_facetless_items() {
|
||||
.unwrap();
|
||||
assert_eq!(body, b"hello", "file content is stored verbatim");
|
||||
}
|
||||
|
||||
fn stub_empty_calendar_scaffold(server: &mut Server) {
|
||||
stub_principal(server);
|
||||
json_mock(
|
||||
server,
|
||||
"/me/mailFolders?$top=100&includeHiddenFolders=true",
|
||||
r#"{"value":[]}"#,
|
||||
);
|
||||
json_mock(
|
||||
server,
|
||||
"/me/mailboxSettings?$select=timeZone",
|
||||
r#"{"timeZone":"UTC"}"#,
|
||||
);
|
||||
json_mock(server, "/me/contactFolders?$top=100", r#"{"value":[]}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deleted_occurrence_becomes_an_excluded_override() {
|
||||
let mut server = Server::new();
|
||||
stub_empty_calendar_scaffold(&mut server);
|
||||
json_mock(
|
||||
&mut server,
|
||||
"/me/calendars?$top=100",
|
||||
r#"{"value":[{"id":"CAL1","name":"Calendar","isDefaultCalendar":true}]}"#,
|
||||
);
|
||||
json_mock(
|
||||
&mut server,
|
||||
"/me/calendars/CAL1/events?$top=100&$select=id,type,seriesMasterId",
|
||||
r#"{"value":[{"id":"MASTER","type":"seriesMaster","iCalUId":"uid-master"}]}"#,
|
||||
);
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
let start = today + chrono::Duration::days(10);
|
||||
json_mock(
|
||||
&mut server,
|
||||
"/me/events/MASTER",
|
||||
&format!(
|
||||
r#"{{
|
||||
"id":"MASTER","iCalUId":"uid-master","type":"seriesMaster",
|
||||
"subject":"Daily standup",
|
||||
"start":{{"dateTime":"{start}T09:00:00.0000000","timeZone":"UTC"}},
|
||||
"end":{{"dateTime":"{start}T09:30:00.0000000","timeZone":"UTC"}},
|
||||
"recurrence":{{
|
||||
"pattern":{{"type":"daily","interval":1}},
|
||||
"range":{{"type":"numbered","startDate":"{start}",
|
||||
"numberOfOccurrences":4}}
|
||||
}}
|
||||
}}"#,
|
||||
start = start.format("%Y-%m-%d")
|
||||
),
|
||||
);
|
||||
server
|
||||
.mock(
|
||||
"GET",
|
||||
Matcher::Regex(r"calendarView.*type%20eq%20%27exception%27".to_owned()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"value":[]}"#)
|
||||
.expect_at_least(1)
|
||||
.create();
|
||||
// Graph returns three of the four occurrences: the third was deleted, and a
|
||||
// deleted occurrence is simply absent rather than reported as cancelled.
|
||||
let occurrences = format!(
|
||||
r#"{{"value":[
|
||||
{{"id":"O1","type":"occurrence","seriesMasterId":"MASTER",
|
||||
"start":{{"dateTime":"{d0}T09:00:00.0000000","timeZone":"UTC"}}}},
|
||||
{{"id":"O2","type":"occurrence","seriesMasterId":"MASTER",
|
||||
"start":{{"dateTime":"{d1}T09:00:00.0000000","timeZone":"UTC"}}}},
|
||||
{{"id":"O4","type":"occurrence","seriesMasterId":"MASTER",
|
||||
"start":{{"dateTime":"{d3}T09:00:00.0000000","timeZone":"UTC"}}}}
|
||||
]}}"#,
|
||||
d0 = start.format("%Y-%m-%d"),
|
||||
d1 = (start + chrono::Duration::days(1)).format("%Y-%m-%d"),
|
||||
d3 = (start + chrono::Duration::days(3)).format("%Y-%m-%d"),
|
||||
);
|
||||
server
|
||||
.mock(
|
||||
"GET",
|
||||
Matcher::Regex(r"calendarView.*type%20eq%20%27occurrence%27".to_owned()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(occurrences)
|
||||
.expect_at_least(1)
|
||||
.create();
|
||||
|
||||
let base = server.url();
|
||||
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
|
||||
vandelay::sync::import_exchange_graph::run(
|
||||
make_common(archive.clone()),
|
||||
make_config(base, None, surfaces("calendar")),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
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["recurrenceOverrides"]
|
||||
.as_object()
|
||||
.expect("the missing occurrence produces an override");
|
||||
let missing = format!(
|
||||
"{}T09:00:00",
|
||||
(start + chrono::Duration::days(2)).format("%Y-%m-%d")
|
||||
);
|
||||
assert_eq!(
|
||||
overrides.len(),
|
||||
1,
|
||||
"only the absent occurrence is excluded; got {:?}",
|
||||
overrides.keys().collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
overrides[&missing]["excluded"], true,
|
||||
"v1.0 cannot report a cancelled occurrence, so it is inferred from the gap \
|
||||
between the expanded rule and the occurrences Graph returned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_file_attachments_become_enclosure_links() {
|
||||
let mut server = Server::new();
|
||||
stub_empty_calendar_scaffold(&mut server);
|
||||
json_mock(
|
||||
&mut server,
|
||||
"/me/calendars?$top=100",
|
||||
r#"{"value":[{"id":"CAL1","name":"Calendar","isDefaultCalendar":true}]}"#,
|
||||
);
|
||||
json_mock(
|
||||
&mut server,
|
||||
"/me/calendars/CAL1/events?$top=100&$select=id,type,seriesMasterId",
|
||||
r#"{"value":[{"id":"EV","type":"singleInstance","iCalUId":"uid-ev"}]}"#,
|
||||
);
|
||||
json_mock(
|
||||
&mut server,
|
||||
"/me/events/EV",
|
||||
r#"{"id":"EV","iCalUId":"uid-ev","type":"singleInstance","subject":"With a file",
|
||||
"hasAttachments":true,
|
||||
"start":{"dateTime":"2026-05-04T15:00:00.0000000","timeZone":"UTC"},
|
||||
"end":{"dateTime":"2026-05-04T16:00:00.0000000","timeZone":"UTC"}}"#,
|
||||
);
|
||||
json_mock(
|
||||
&mut server,
|
||||
"/me/events/EV/attachments",
|
||||
r##"{"value":[
|
||||
{"@odata.type":"#microsoft.graph.fileAttachment","id":"A1","name":"agenda.txt",
|
||||
"contentType":"text/plain","size":5,"contentBytes":"aGVsbG8="},
|
||||
{"@odata.type":"#microsoft.graph.itemAttachment","id":"A2","name":"nested.msg"},
|
||||
{"@odata.type":"#microsoft.graph.referenceAttachment","id":"A3","name":"link"}
|
||||
]}"##,
|
||||
);
|
||||
|
||||
let base = server.url();
|
||||
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
|
||||
vandelay::sync::import_exchange_graph::run(
|
||||
make_common(archive.clone()),
|
||||
make_config(base, None, surfaces("calendar")),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
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 links = v["links"].as_object().expect("links present");
|
||||
assert_eq!(
|
||||
links.len(),
|
||||
1,
|
||||
"only fileAttachment carries bytes; item and reference attachments are skipped"
|
||||
);
|
||||
let link = &links["1"];
|
||||
assert_eq!(link["rel"], "enclosure");
|
||||
assert_eq!(link["title"], "agenda.txt");
|
||||
assert_eq!(link["contentType"], "text/plain");
|
||||
let blob: Vec<u8> = conn
|
||||
.query_row(
|
||||
"SELECT b.data FROM blobs b WHERE b.id = ?1",
|
||||
[link["@blob"].as_i64().unwrap()],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(blob, b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contact_photo_categories_and_im_addresses_are_imported() {
|
||||
let mut server = Server::new();
|
||||
stub_principal(&mut server);
|
||||
json_mock(
|
||||
&mut server,
|
||||
"/me/contactFolders?$top=100",
|
||||
r#"{"value":[]}"#,
|
||||
);
|
||||
json_mock(
|
||||
&mut server,
|
||||
"/me/contactFolders/contacts",
|
||||
r#"{"id":"DEFAULT","displayName":"Contacts","wellKnownName":"contacts"}"#,
|
||||
);
|
||||
json_mock(
|
||||
&mut server,
|
||||
"/me/contactFolders/DEFAULT/childFolders?$top=100",
|
||||
r#"{"value":[]}"#,
|
||||
);
|
||||
json_mock(
|
||||
&mut server,
|
||||
"/me/contactFolders/DEFAULT/contacts?$top=100&$select=id",
|
||||
r#"{"value":[{"id":"C1"}]}"#,
|
||||
);
|
||||
json_mock(
|
||||
&mut server,
|
||||
"/me/contacts/C1",
|
||||
r#"{"id":"C1","displayName":"Alice","categories":["Red Category","VIP"],
|
||||
"imAddresses":["sip:[email protected]",""]}"#,
|
||||
);
|
||||
// Graph reports image/jpeg for every contact photo regardless of the real bytes,
|
||||
// so the stored mediaType has to come from the content itself.
|
||||
server
|
||||
.mock("GET", "/me/contacts/C1/photo/$value")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "image/jpeg")
|
||||
.with_body(b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice())
|
||||
.expect_at_least(1)
|
||||
.create();
|
||||
|
||||
let base = server.url();
|
||||
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
|
||||
let mut config = make_config(base, None, surfaces("contacts"));
|
||||
config.contact_photos = true;
|
||||
vandelay::sync::import_exchange_graph::run(make_common(archive.clone()), config).unwrap();
|
||||
|
||||
let conn = vandelay::db::init::open(&archive).unwrap();
|
||||
let row: String = conn
|
||||
.query_row("SELECT data FROM contact_cards", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&row).unwrap();
|
||||
assert_eq!(v["keywords"]["red category"], true);
|
||||
assert_eq!(v["keywords"]["vip"], true);
|
||||
assert_eq!(
|
||||
v["onlineServices"]["1"]["uri"], "sip:[email protected]",
|
||||
"RFC 9553 2.3.2: a URI-shaped IM address belongs in uri, not user"
|
||||
);
|
||||
assert!(
|
||||
v["onlineServices"].as_object().unwrap().len() == 1,
|
||||
"the empty IM entry is dropped"
|
||||
);
|
||||
assert_eq!(v["media"]["photo"]["kind"], "photo");
|
||||
assert_eq!(
|
||||
v["media"]["photo"]["mediaType"], "image/png",
|
||||
"the type is sniffed from the bytes, not taken from Graph's jpeg header"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
mod integration;
|
||||
mod seeder;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use integration::stalwart::shared as shared_stalwart;
|
||||
use mockito::{Matcher, Server};
|
||||
use serde_json::{Value, json};
|
||||
use vandelay::exchange_graph::types::{EventBodyFormat, MailboxKind, Surfaces};
|
||||
use vandelay::jmap::account::AccountSelector;
|
||||
use vandelay::jmap::http::Auth;
|
||||
use vandelay::logging::Logger;
|
||||
use vandelay::sync::import_exchange_graph::{GraphAuth, GraphImportConfig};
|
||||
use vandelay::sync::{self, CommonConfig, ConnectConfig, ExportConfig};
|
||||
|
||||
fn base_url() -> &'static str {
|
||||
shared_stalwart().base_url()
|
||||
}
|
||||
|
||||
fn tmp_archive(tag: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!(
|
||||
"vandelay-{tag}-{}-{}.sqlite",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let _ = std::fs::remove_file(&p);
|
||||
p
|
||||
}
|
||||
|
||||
fn json_mock(server: &mut Server, path: &str, body: &str) {
|
||||
server
|
||||
.mock("GET", path)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(body)
|
||||
.expect_at_least(0)
|
||||
.create();
|
||||
}
|
||||
|
||||
fn graph_fixture(server: &mut Server) {
|
||||
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":"[email protected]"}"#)
|
||||
.expect_at_least(0)
|
||||
.create();
|
||||
json_mock(
|
||||
server,
|
||||
"/me/mailboxSettings?$select=timeZone",
|
||||
r#"{"timeZone":"UTC"}"#,
|
||||
);
|
||||
|
||||
json_mock(
|
||||
server,
|
||||
"/me/mailFolders?$top=100&includeHiddenFolders=true",
|
||||
r#"{"value":[{"id":"F1","displayName":"Graph Inbox","isHidden":false}]}"#,
|
||||
);
|
||||
json_mock(
|
||||
server,
|
||||
"/me/mailFolders/F1/childFolders?$top=100&includeHiddenFolders=true",
|
||||
r#"{"value":[]}"#,
|
||||
);
|
||||
for name in [
|
||||
"inbox",
|
||||
"drafts",
|
||||
"sentitems",
|
||||
"deleteditems",
|
||||
"junkemail",
|
||||
"archive",
|
||||
] {
|
||||
server
|
||||
.mock("GET", format!("/me/mailFolders/{name}?$select=id").as_str())
|
||||
.with_status(404)
|
||||
.expect_at_least(0)
|
||||
.create();
|
||||
}
|
||||
json_mock(
|
||||
server,
|
||||
"/me/mailFolders/F1/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories",
|
||||
r#"{"value":[{"id":"M1","isRead":true,"isDraft":false,
|
||||
"flag":{"flagStatus":"flagged"},"categories":["Work"]}]}"#,
|
||||
);
|
||||
server
|
||||
.mock("GET", "/me/messages/M1/$value")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "text/plain")
|
||||
.with_body(
|
||||
"From: [email protected]\r\nTo: [email protected]\r\n\
|
||||
Subject: Graph round trip\r\nDate: Tue, 01 Sep 2026 10:00:00 +0000\r\n\
|
||||
Message-ID: <[email protected]>\r\n\
|
||||
MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n\
|
||||
Grüße aus Köln 🎉\r\n",
|
||||
)
|
||||
.expect_at_least(0)
|
||||
.create();
|
||||
|
||||
json_mock(
|
||||
server,
|
||||
"/me/calendars?$top=100",
|
||||
r##"{"value":[{"id":"CAL1","name":"Graph Calendar","isDefaultCalendar":false,
|
||||
"hexColor":"#FF0000"}]}"##,
|
||||
);
|
||||
json_mock(
|
||||
server,
|
||||
"/me/calendars/CAL1/events?$top=100&$select=id,type,seriesMasterId",
|
||||
r#"{"value":[
|
||||
{"id":"EV","type":"singleInstance","iCalUId":"uid-ev"},
|
||||
{"id":"MASTER","type":"seriesMaster","iCalUId":"uid-master"}
|
||||
]}"#,
|
||||
);
|
||||
json_mock(
|
||||
server,
|
||||
"/me/events/EV",
|
||||
r#"{"id":"EV","iCalUId":"uid-ev","type":"singleInstance",
|
||||
"subject":"Event with an enclosure","hasAttachments":true,
|
||||
"start":{"dateTime":"2026-05-04T15:00:00.0000000","timeZone":"UTC"},
|
||||
"end":{"dateTime":"2026-05-04T16:00:00.0000000","timeZone":"UTC"}}"#,
|
||||
);
|
||||
server
|
||||
.mock("GET", "/me/events/EV/attachments")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r##"{"value":[{"@odata.type":"#microsoft.graph.fileAttachment","id":"A1",
|
||||
"name":"agenda.txt","contentType":"text/plain","size":5,
|
||||
"contentBytes":"aGVsbG8="}]}"##,
|
||||
)
|
||||
.expect_at_least(0)
|
||||
.create();
|
||||
|
||||
let start = (chrono::Utc::now().date_naive() + chrono::Duration::days(10))
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
let d = |n: i64| {
|
||||
(chrono::Utc::now().date_naive() + chrono::Duration::days(10 + n))
|
||||
.format("%Y-%m-%d")
|
||||
.to_string()
|
||||
};
|
||||
json_mock(
|
||||
server,
|
||||
"/me/events/MASTER",
|
||||
&format!(
|
||||
r#"{{"id":"MASTER","iCalUId":"uid-master","type":"seriesMaster",
|
||||
"subject":"Daily standup","hasAttachments":false,
|
||||
"start":{{"dateTime":"{start}T09:00:00.0000000","timeZone":"UTC"}},
|
||||
"end":{{"dateTime":"{start}T09:30:00.0000000","timeZone":"UTC"}},
|
||||
"recurrence":{{"pattern":{{"type":"daily","interval":1}},
|
||||
"range":{{"type":"numbered","startDate":"{start}",
|
||||
"numberOfOccurrences":4}}}}}}"#
|
||||
),
|
||||
);
|
||||
server
|
||||
.mock(
|
||||
"GET",
|
||||
Matcher::Regex(r"calendarView.*type%20eq%20%27exception%27".to_owned()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(format!(
|
||||
r#"{{"value":[{{"id":"EX","iCalUId":"uid-master","type":"exception",
|
||||
"seriesMasterId":"MASTER","originalStart":"{}T09:00:00Z",
|
||||
"subject":"Moved standup",
|
||||
"start":{{"dateTime":"{}T14:00:00.0000000","timeZone":"UTC"}},
|
||||
"end":{{"dateTime":"{}T14:30:00.0000000","timeZone":"UTC"}}}}]}}"#,
|
||||
d(1),
|
||||
d(1),
|
||||
d(1)
|
||||
))
|
||||
.expect_at_least(1)
|
||||
.create();
|
||||
server
|
||||
.mock(
|
||||
"GET",
|
||||
Matcher::Regex(r"calendarView.*type%20eq%20%27occurrence%27".to_owned()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(format!(
|
||||
r#"{{"value":[
|
||||
{{"id":"O1","type":"occurrence","seriesMasterId":"MASTER",
|
||||
"start":{{"dateTime":"{}T09:00:00.0000000","timeZone":"UTC"}}}},
|
||||
{{"id":"O4","type":"occurrence","seriesMasterId":"MASTER",
|
||||
"start":{{"dateTime":"{}T09:00:00.0000000","timeZone":"UTC"}}}}
|
||||
]}}"#,
|
||||
d(0),
|
||||
d(3)
|
||||
))
|
||||
.expect_at_least(1)
|
||||
.create();
|
||||
|
||||
json_mock(server, "/me/contactFolders?$top=100", r#"{"value":[]}"#);
|
||||
json_mock(
|
||||
server,
|
||||
"/me/contactFolders/contacts",
|
||||
r#"{"id":"DEFAULT","displayName":"Contacts","wellKnownName":"contacts"}"#,
|
||||
);
|
||||
json_mock(
|
||||
server,
|
||||
"/me/contactFolders/DEFAULT/childFolders?$top=100",
|
||||
r#"{"value":[]}"#,
|
||||
);
|
||||
json_mock(
|
||||
server,
|
||||
"/me/contactFolders/DEFAULT/contacts?$top=100&$select=id",
|
||||
r#"{"value":[{"id":"C1"}]}"#,
|
||||
);
|
||||
json_mock(
|
||||
server,
|
||||
"/me/contacts/C1",
|
||||
r#"{"id":"C1","displayName":"Graph Contact","givenName":"Graph","surname":"Contact",
|
||||
"categories":["Work","VIP"],"imAddresses":["sip:[email protected]"],
|
||||
"emailAddresses":[{"address":"[email protected]"}]}"#,
|
||||
);
|
||||
server
|
||||
.mock("GET", "/me/contacts/C1/photo/$value")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "image/jpeg")
|
||||
.with_body(
|
||||
[
|
||||
0x89u8, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49,
|
||||
0x48, 0x44, 0x52,
|
||||
]
|
||||
.as_slice(),
|
||||
)
|
||||
.expect_at_least(1)
|
||||
.create();
|
||||
|
||||
json_mock(server, "/me/drive/root?$select=id,name", r#"{"id":"ROOT"}"#);
|
||||
let sel = "id,name,size,folder,file,package,remoteItem,createdDateTime,lastModifiedDateTime";
|
||||
json_mock(
|
||||
server,
|
||||
&format!("/me/drive/items/ROOT/children?$top=100&$select={sel}"),
|
||||
r#"{"value":[
|
||||
{"id":"D1","name":"Graph Files","folder":{"childCount":1},
|
||||
"createdDateTime":"2026-01-01T00:00:00Z"},
|
||||
{"id":"V1","name":"Personal Vault","remoteItem":{},
|
||||
"createdDateTime":"2026-01-01T00:00:00Z"}
|
||||
]}"#,
|
||||
);
|
||||
json_mock(
|
||||
server,
|
||||
&format!("/me/drive/items/D1/children?$top=100&$select={sel}"),
|
||||
r#"{"value":[{"id":"FL1","name":"report.txt","size":6,
|
||||
"file":{"mimeType":"text/plain"},
|
||||
"createdDateTime":"2026-01-01T00:00:00Z"}]}"#,
|
||||
);
|
||||
server
|
||||
.mock("GET", "/me/drive/items/FL1/content")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "text/plain")
|
||||
.with_body("hello\n")
|
||||
.expect_at_least(0)
|
||||
.create();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn graph_import_survives_a_full_export_to_stalwart() {
|
||||
let mut graph = Server::new();
|
||||
graph_fixture(&mut graph);
|
||||
let archive = tmp_archive("graph-roundtrip");
|
||||
|
||||
let import = sync::import_exchange_graph::run(
|
||||
CommonConfig {
|
||||
archive: archive.clone(),
|
||||
threads: 4,
|
||||
dry_run: false,
|
||||
max_retries: 2,
|
||||
allow_invalid_certs: true,
|
||||
logger: Logger::from_flags(true, 0),
|
||||
},
|
||||
GraphImportConfig {
|
||||
auth: GraphAuth::PreAcquired {
|
||||
token: "TOKEN".to_owned(),
|
||||
},
|
||||
api_base: graph.url(),
|
||||
user_target: None,
|
||||
mailbox_kind: MailboxKind::Primary,
|
||||
surfaces: Surfaces::ALL,
|
||||
event_body_format: EventBodyFormat::Text,
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
exception_window_years: 5,
|
||||
contact_photos: true,
|
||||
event_attachments: true,
|
||||
allow_source_change: false,
|
||||
},
|
||||
)
|
||||
.expect("graph import");
|
||||
assert!(!import.any_failed(), "import reported failures");
|
||||
|
||||
let fx = seeder::provision(base_url()).expect("provision");
|
||||
assert_eq!(fx.domain, seeder::DOMAIN);
|
||||
assert!(!fx.domain_id.is_empty(), "domain id resolved");
|
||||
assert_eq!(
|
||||
fx.admin_login,
|
||||
(
|
||||
seeder::ADMIN_USER.to_owned(),
|
||||
seeder::ADMIN_PASSWORD.to_owned()
|
||||
)
|
||||
);
|
||||
let reference = fx
|
||||
.account(seeder::SYNC_IN[0])
|
||||
.expect("a seeded reference account");
|
||||
let seeded = reference.seeded.as_ref().expect("reference seed stats");
|
||||
assert!(seeded.emails > 0, "provisioning seeded mail");
|
||||
assert!(
|
||||
seeded.mailboxes_created > 0,
|
||||
"provisioning seeded a mailbox tree"
|
||||
);
|
||||
assert!(seeded.file_nodes > 0, "provisioning seeded file nodes");
|
||||
assert!(seeded.contacts > 0, "provisioning seeded contacts");
|
||||
assert!(seeded.events > 0, "provisioning seeded events");
|
||||
assert!(
|
||||
seeded.address_books > 0,
|
||||
"provisioning seeded address books"
|
||||
);
|
||||
assert!(seeded.calendars > 0, "provisioning seeded calendars");
|
||||
assert!(seeded.identity, "provisioning seeded an identity");
|
||||
assert!(
|
||||
seeded.sieve_active.is_some(),
|
||||
"provisioning seeded a sieve script"
|
||||
);
|
||||
|
||||
let account = fx.account(seeder::SYNC_OUT[0]).expect("a sync-out target");
|
||||
assert!(!account.admin_role, "the export target is a regular user");
|
||||
assert!(
|
||||
account.seeded.is_none(),
|
||||
"the export target must start empty so every assertion below is ours"
|
||||
);
|
||||
|
||||
let export = sync::export::run(
|
||||
CommonConfig {
|
||||
archive: archive.clone(),
|
||||
threads: 4,
|
||||
dry_run: false,
|
||||
max_retries: 5,
|
||||
allow_invalid_certs: true,
|
||||
logger: Logger::from_flags(true, 0),
|
||||
},
|
||||
ExportConfig {
|
||||
connect: ConnectConfig {
|
||||
url: base_url().to_owned(),
|
||||
auth: Auth::Basic {
|
||||
user: account.email.clone(),
|
||||
password: account.password.clone(),
|
||||
},
|
||||
account: AccountSelector::Id(account.account_id.clone()),
|
||||
},
|
||||
objects: None,
|
||||
prune: false,
|
||||
yes: true,
|
||||
},
|
||||
)
|
||||
.expect("export");
|
||||
assert!(!export.any_failed(), "export reported failures");
|
||||
|
||||
let jmap = seeder::jmap::Jmap::connect(&fx.base_url, &account.email, &account.password)
|
||||
.expect("session");
|
||||
let acc = account.account_id.clone();
|
||||
|
||||
let cards = query_all(&jmap, &acc, "urn:ietf:params:jmap:contacts", "ContactCard");
|
||||
let card = cards
|
||||
.iter()
|
||||
.find(|c| c["name"]["full"] == "Graph Contact")
|
||||
.expect("the imported card reached the target");
|
||||
|
||||
let uri = card["media"]
|
||||
.as_object()
|
||||
.and_then(|m| m.values().next())
|
||||
.and_then(|m| m["uri"].as_str())
|
||||
.expect("the contact photo survived as a Media resource");
|
||||
assert!(
|
||||
uri.starts_with("data:image/png;base64,"),
|
||||
"Graph reports image/jpeg for every contact photo, so the media type must come \
|
||||
from the bytes; got {}",
|
||||
&uri[..uri.len().min(40)]
|
||||
);
|
||||
assert_eq!(card["keywords"]["work"], true, "categories became keywords");
|
||||
assert_eq!(
|
||||
card["onlineServices"]
|
||||
.as_object()
|
||||
.and_then(|m| m.values().next())
|
||||
.and_then(|s| s["uri"].as_str()),
|
||||
Some("sip:[email protected]"),
|
||||
"a URI-shaped IM address must land in uri: Stalwart drops an OnlineService \
|
||||
that carries only user, even though RFC 9553 permits it"
|
||||
);
|
||||
|
||||
let events = query_all(
|
||||
&jmap,
|
||||
&acc,
|
||||
"urn:ietf:params:jmap:calendars",
|
||||
"CalendarEvent",
|
||||
);
|
||||
let enclosure = events
|
||||
.iter()
|
||||
.find(|e| e["title"] == "Event with an enclosure")
|
||||
.expect("the event with an attachment reached the target");
|
||||
let href = enclosure["links"]
|
||||
.as_object()
|
||||
.and_then(|m| m.values().next())
|
||||
.and_then(|l| l["href"].as_str())
|
||||
.expect("the attachment survived as a Link");
|
||||
assert!(
|
||||
href.starts_with("data:text/plain;base64,"),
|
||||
"got {}",
|
||||
&href[..href.len().min(40)]
|
||||
);
|
||||
|
||||
let series = events
|
||||
.iter()
|
||||
.find(|e| e["title"] == "Daily standup")
|
||||
.expect("the series reached the target");
|
||||
let overrides = series["recurrenceOverrides"]
|
||||
.as_object()
|
||||
.expect("recurrenceOverrides survived");
|
||||
assert_eq!(
|
||||
overrides.len(),
|
||||
2,
|
||||
"one modified occurrence and one deleted one; got {:?}",
|
||||
overrides.keys().collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
overrides
|
||||
.values()
|
||||
.any(|v| v["excluded"] == json!(true) && v.as_object().unwrap().len() == 1),
|
||||
"the deleted occurrence is a single-member excluded patch \
|
||||
(jscalendarbis 3.3.4); got {overrides:?}"
|
||||
);
|
||||
assert!(
|
||||
overrides.values().any(|v| v["title"] == "Moved standup"),
|
||||
"the modified occurrence kept its patch; got {overrides:?}"
|
||||
);
|
||||
|
||||
let files = query_all(&jmap, &acc, "urn:ietf:params:jmap:filenode", "FileNode");
|
||||
assert!(
|
||||
files
|
||||
.iter()
|
||||
.any(|f| f["name"] == "report.txt" && f["blobId"].is_string()),
|
||||
"the drive file reached the target with a blob"
|
||||
);
|
||||
assert!(
|
||||
!files.iter().any(|f| f["name"] == "Personal Vault"),
|
||||
"a facetless drive item must never reach the target"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
|
||||
fn query_all(jmap: &seeder::jmap::Jmap, account: &str, using: &str, ty: &str) -> Vec<Value> {
|
||||
let resp = jmap
|
||||
.request(
|
||||
&["urn:ietf:params:jmap:core", using],
|
||||
json!([
|
||||
[format!("{ty}/query"), {"accountId": account}, "q"],
|
||||
[
|
||||
format!("{ty}/get"),
|
||||
{
|
||||
"accountId": account,
|
||||
"#ids": {"resultOf": "q", "name": format!("{ty}/query"), "path": "/ids"}
|
||||
},
|
||||
"g"
|
||||
]
|
||||
]),
|
||||
)
|
||||
.expect("query and get");
|
||||
resp["methodResponses"]
|
||||
.as_array()
|
||||
.and_then(|calls| calls.iter().find(|c| c[0] == format!("{ty}/get")))
|
||||
.and_then(|c| c[1]["list"].as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
Reference in New Issue
Block a user