MS Exchange Graph: Import the default Contacts folder, recover series exceptions (fixes #39)

This commit is contained in:
Maurus Decimus
2026-08-27 19:15:03 +02:00
parent 423b761617
commit e8d98abccb
20 changed files with 1370 additions and 120 deletions
+427 -34
View File
@@ -186,7 +186,7 @@ fn pagination_collects_ids_across_three_pages_with_one_empty() {
.to_string();
let _m1 = server
.mock("GET", "/me/mailFolders/F1/messages?$top=100&$select=id")
.mock("GET", "/me/mailFolders/F1/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(page1)
@@ -220,7 +220,7 @@ fn pagination_terminates_when_no_next_link_present() {
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")
.mock("GET", "/me/mailFolders/F/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(body)
@@ -248,7 +248,7 @@ fn header_redelivery_is_asserted_via_mockito_match() {
.to_string();
let page2 = json!({"value": [{"id": "OnlyOnPageTwo"}]}).to_string();
let _m1 = server
.mock("GET", "/me/mailFolders/F/messages?$top=100&$select=id")
.mock("GET", "/me/mailFolders/F/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
.match_header("prefer", "IdType=\"ImmutableId\"")
.match_header("authorization", "Bearer BEARER")
.with_status(200)
@@ -275,7 +275,7 @@ fn case_sensitive_immutable_id_round_trip() {
let id = "AAkAAGFsaWNlAAA=";
let body = json!({"value": [{"id": id}]}).to_string();
let _m = server
.mock("GET", "/me/mailFolders/F/messages?$top=100&$select=id")
.mock("GET", "/me/mailFolders/F/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(body)
@@ -589,6 +589,7 @@ fn integration_dry_run_against_mock_server_lists_three_surfaces() {
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
exception_window_years: 5,
allow_source_change: false,
};
let summary = vandelay::sync::import_exchange_graph::run(common, config).unwrap();
@@ -663,7 +664,7 @@ fn integration_full_run_mail_only_imports_mime_via_value() {
})
.collect();
let _ids = server
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id")
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[{"id":"MSG-1"}]}"#)
@@ -698,6 +699,7 @@ fn integration_full_run_mail_only_imports_mime_via_value() {
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
exception_window_years: 5,
allow_source_change: false,
};
drop(tmp);
@@ -759,7 +761,7 @@ fn integration_duplicate_message_id_does_not_abort_run() {
})
.collect();
let _ids = server
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id")
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[{"id":"MSG-1"},{"id":"MSG-1"}]}"#)
@@ -794,6 +796,7 @@ fn integration_duplicate_message_id_does_not_abort_run() {
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
exception_window_years: 5,
allow_source_change: false,
};
drop(tmp);
@@ -855,7 +858,7 @@ fn integration_full_run_is_convergent_on_second_invocation() {
.create();
}
let _ids = server
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id")
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[{"id":"MSG-A"}]}"#)
@@ -883,6 +886,7 @@ fn integration_full_run_is_convergent_on_second_invocation() {
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
exception_window_years: 5,
allow_source_change: false,
};
let make_common = |path: std::path::PathBuf| vandelay::sync::CommonConfig {
@@ -962,6 +966,7 @@ fn source_change_protection_refuses_a_different_account() {
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
exception_window_years: 5,
allow_source_change: false,
};
let err = vandelay::sync::import_exchange_graph::run(common, config).unwrap_err();
@@ -1028,6 +1033,7 @@ fn make_config(
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
exception_window_years: 5,
allow_source_change: false,
}
}
@@ -1144,11 +1150,31 @@ fn series_master_with_exception_merges_into_recurrence_overrides() {
.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"}
{"id":"MASTER","type":"seriesMaster","iCalUId":"uid-master"}
]}"#,
)
.create();
server
.mock(
"GET",
Matcher::Regex(r"^/me/calendars/CAL1/calendarView\?startDateTime=".to_owned()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"value":[{
"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"}
}]}"#,
)
.expect_at_least(1)
.create();
server
.mock("GET", "/me/events/MASTER")
.with_status(200)
@@ -1168,23 +1194,6 @@ fn series_master_with_exception_merges_into_recurrence_overrides() {
}"#,
)
.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(
@@ -1354,7 +1363,7 @@ fn hidden_mail_folder_has_is_subscribed_zero() {
server
.mock(
"GET",
"/me/mailFolders/FVISIBLE/messages?$top=100&$select=id",
"/me/mailFolders/FVISIBLE/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories",
)
.with_status(200)
.with_header("content-type", "application/json")
@@ -1363,7 +1372,7 @@ fn hidden_mail_folder_has_is_subscribed_zero() {
server
.mock(
"GET",
"/me/mailFolders/FHIDDEN/messages?$top=100&$select=id",
"/me/mailFolders/FHIDDEN/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories",
)
.with_status(200)
.with_header("content-type", "application/json")
@@ -1441,7 +1450,7 @@ fn well_known_folder_probes_assign_jmap_roles() {
server
.mock(
"GET",
format!("/me/mailFolders/{fid}/messages?$top=100&$select=id").as_str(),
format!("/me/mailFolders/{fid}/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories").as_str(),
)
.with_status(200)
.with_header("content-type", "application/json")
@@ -1515,7 +1524,7 @@ fn attachment_message_does_not_trigger_attachments_endpoint() {
.create();
stub_well_known_folders(&mut server, "FMAIL");
server
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id")
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[{"id":"MSG-ATT"}]}"#)
@@ -1708,7 +1717,7 @@ fn full_run_records_graph_id_in_sync_id_exchange_graph_with_padding() {
let _ids = server
.mock(
"GET",
"/me/mailFolders/AAkA-Padded%3D%3D/messages?$top=100&$select=id",
"/me/mailFolders/AAkA-Padded%3D%3D/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories",
)
.with_status(200)
.with_header("content-type", "application/json")
@@ -1736,6 +1745,7 @@ fn full_run_records_graph_id_in_sync_id_exchange_graph_with_padding() {
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
exception_window_years: 5,
allow_source_change: false,
};
drop(tmp);
@@ -1806,6 +1816,7 @@ fn archive_mailbox_kind_encodes_synthetic_suffix_in_account_id() {
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
exception_window_years: 5,
allow_source_change: false,
};
drop(tmp);
@@ -1871,6 +1882,7 @@ fn allow_source_change_permits_overwriting_a_different_account() {
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
exception_window_years: 5,
allow_source_change: true,
};
let result = vandelay::sync::import_exchange_graph::run(common, config);
@@ -1952,6 +1964,7 @@ fn dry_run_makes_no_per_item_get_and_no_sqlite_writes() {
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
exception_window_years: 5,
allow_source_change: false,
};
drop(tmp);
@@ -2096,7 +2109,7 @@ fn folder_enumeration_failure_skips_vanished_deletion() {
.create();
}
let _enum_fail = server
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id")
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
.with_status(503)
.with_body("transient outage")
.create();
@@ -2120,6 +2133,7 @@ fn folder_enumeration_failure_skips_vanished_deletion() {
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
exception_window_years: 5,
allow_source_change: true,
};
let _ = vandelay::sync::import_exchange_graph::run(common, config).unwrap();
@@ -2147,7 +2161,14 @@ fn stub_contacts_surface(server: &mut Server) {
.mock("GET", "/me/contactFolders?$top=100")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[{"id":"CON1","displayName":"Contacts"}]}"#)
.with_body(r#"{"value":[]}"#)
.expect_at_least(0)
.create();
server
.mock("GET", "/me/contactFolders/contacts")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"CON1","displayName":"Contacts","wellKnownName":"contacts"}"#)
.expect_at_least(0)
.create();
server
@@ -2199,7 +2220,7 @@ fn stub_mail_surface(server: &mut Server) {
.create();
stub_well_known_folders(server, "FMAIL");
server
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id")
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[{"id":"MSG-S"}]}"#)
@@ -2342,3 +2363,375 @@ fn mail_surface_imports_only_mailboxes_and_emails() {
assert_eq!(row_count(&conn, "calendars"), 0);
assert_eq!(row_count(&conn, "calendar_events"), 0);
}
fn stub_principal(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();
}
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();
}
#[test]
fn default_contact_folder_is_imported_although_contactfolders_omits_it() {
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"},{"id":"C2"}]}"#,
);
json_mock(
&mut server,
"/me/contacts/C1",
r#"{"id":"C1","displayName":"Alice"}"#,
);
json_mock(
&mut server,
"/me/contacts/C2",
r#"{"id":"C2","displayName":"Bob"}"#,
);
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, surfaces("contacts")),
)
.unwrap();
let count = |name: &str| {
summary
.per_type
.iter()
.find(|(t, _)| *t == name)
.map(|(_, c)| c.created)
.unwrap_or(0)
};
assert_eq!(
count("addressbook"),
1,
"the default Contacts folder must become an address book even though \
/me/contactFolders never lists it"
);
assert_eq!(
count("contactcard"),
2,
"both default-folder contacts import"
);
let conn = vandelay::db::init::open(&archive).unwrap();
let is_default: i64 = conn
.query_row("SELECT is_default FROM address_books", [], |row| row.get(0))
.unwrap();
assert_eq!(is_default, 1, "the well-known folder is the default book");
}
#[test]
fn default_contact_folder_falls_back_to_parent_of_an_existing_contact() {
let mut server = Server::new();
stub_principal(&mut server);
json_mock(
&mut server,
"/me/contactFolders?$top=100",
r#"{"value":[]}"#,
);
server
.mock("GET", "/me/contactFolders/contacts")
.with_status(404)
.with_body(r#"{"error":{"code":"ErrorItemNotFound"}}"#)
.expect_at_least(1)
.create();
json_mock(
&mut server,
"/me/contacts?$top=1&$select=id,parentFolderId",
r#"{"value":[{"id":"C1","parentFolderId":"DERIVED"}]}"#,
);
json_mock(
&mut server,
"/me/contactFolders/DERIVED",
r#"{"id":"DERIVED","displayName":"Kontakte"}"#,
);
json_mock(
&mut server,
"/me/contactFolders/DERIVED/childFolders?$top=100",
r#"{"value":[]}"#,
);
json_mock(
&mut server,
"/me/contactFolders/DERIVED/contacts?$top=100&$select=id",
r#"{"value":[{"id":"C1"}]}"#,
);
json_mock(
&mut server,
"/me/contacts/C1",
r#"{"id":"C1","displayName":"Alice"}"#,
);
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("contacts")),
)
.unwrap();
let conn = vandelay::db::init::open(&archive).unwrap();
let name: String = conn
.query_row("SELECT name FROM address_books", [], |row| row.get(0))
.unwrap();
assert_eq!(
name, "Kontakte",
"when the well-known name is unavailable the folder is derived from a contact's parent"
);
}
#[test]
fn contact_folder_reachable_by_two_paths_is_inserted_once() {
let mut server = Server::new();
stub_principal(&mut server);
json_mock(
&mut server,
"/me/contactFolders?$top=100",
r#"{"value":[{"id":"CHILD","displayName":"Work","parentFolderId":"DEFAULT"}]}"#,
);
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":[{"id":"CHILD","displayName":"Work","parentFolderId":"DEFAULT"}]}"#,
);
json_mock(
&mut server,
"/me/contactFolders/CHILD/childFolders?$top=100",
r#"{"value":[]}"#,
);
for folder in ["DEFAULT", "CHILD"] {
json_mock(
&mut server,
&format!("/me/contactFolders/{folder}/contacts?$top=100&$select=id"),
r#"{"value":[]}"#,
);
}
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, surfaces("contacts")),
)
.expect("a folder reachable by two paths must not violate the id-mapping uniqueness");
let created = summary
.per_type
.iter()
.find(|(t, _)| *t == "addressbook")
.map(|(_, c)| c.created)
.unwrap_or(0);
assert_eq!(
created, 2,
"the default folder and its one child, each once"
);
}
#[test]
fn message_state_becomes_jmap_keywords() {
let mut server = Server::new();
stub_principal(&mut server);
json_mock(
&mut server,
"/me/mailFolders?$top=100&includeHiddenFolders=true",
r#"{"value":[{"id":"F1","displayName":"Inbox","isHidden":false}]}"#,
);
json_mock(
&mut 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(
&mut server,
"/me/mailFolders/F1/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories",
r#"{"value":[
{"id":"M1","isRead":true,"isDraft":false,"isReadReceiptRequested":true,
"flag":{"flagStatus":"flagged"},"categories":["Red Category","VIP"]},
{"id":"M2","isRead":false,"isDraft":true,
"flag":{"flagStatus":"notFlagged"},"categories":[]}
]}"#,
);
for id in ["M1", "M2"] {
server
.mock("GET", format!("/me/messages/{id}/$value").as_str())
.with_status(200)
.with_header("content-type", "text/plain")
.with_body(format!(
"From: [email protected]\r\nTo: [email protected]\r\nSubject: {id}\r\nMessage-ID: <{id}@x>\r\n\r\nbody\r\n"
))
.expect_at_least(0)
.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("mail")),
)
.unwrap();
let conn = vandelay::db::init::open(&archive).unwrap();
let mut stmt = conn
.prepare("SELECT keywords FROM emails ORDER BY id")
.unwrap();
let rows: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.map(|r| r.unwrap())
.collect();
let all = rows.join(" ");
for expected in [
"$seen",
"$notified",
"$flagged",
"red category",
"vip",
"$draft",
] {
assert!(all.contains(expected), "missing {expected} in {all}");
}
}
#[test]
fn drive_items_import_as_file_nodes_and_skip_facetless_items() {
let mut server = Server::new();
stub_principal(&mut server);
json_mock(
&mut server,
"/me/drive/root?$select=id,name",
r#"{"id":"ROOT","name":"root"}"#,
);
let select = "id,name,size,folder,file,package,remoteItem,createdDateTime,lastModifiedDateTime";
json_mock(
&mut server,
&format!("/me/drive/items/ROOT/children?$top=100&$select={select}"),
r#"{"value":[
{"id":"D1","name":"Docs","folder":{"childCount":1},
"createdDateTime":"2026-01-01T00:00:00Z","lastModifiedDateTime":"2026-01-02T00:00:00Z"},
{"id":"V1","name":"Personal Vault","remoteItem":{},
"createdDateTime":"2026-01-01T00:00:00Z"},
{"id":"F1","name":"top.txt","size":5,"file":{"mimeType":"text/plain"},
"createdDateTime":"2026-01-01T00:00:00Z","lastModifiedDateTime":"2026-01-02T00:00:00Z"}
]}"#,
);
json_mock(
&mut server,
&format!("/me/drive/items/D1/children?$top=100&$select={select}"),
r#"{"value":[
{"id":"F2","name":"inner.bin","size":3,"file":{"mimeType":"application/octet-stream"},
"createdDateTime":"2026-01-01T00:00:00Z"}
]}"#,
);
for (id, body) in [("F1", "hello"), ("F2", "abc")] {
server
.mock("GET", format!("/me/drive/items/{id}/content").as_str())
.with_status(200)
.with_header("content-type", "application/octet-stream")
.with_body(body)
.expect_at_least(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, surfaces("files")),
)
.unwrap();
let created = summary
.per_type
.iter()
.find(|(t, _)| *t == "filenode")
.map(|(_, c)| c.created)
.unwrap_or(0);
assert_eq!(
created, 3,
"one directory and two files; the remoteItem has no file or folder facet and is skipped"
);
let conn = vandelay::db::init::open(&archive).unwrap();
let vault: i64 = conn
.query_row(
"SELECT count(*) FROM file_nodes WHERE name = 'Personal Vault'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(vault, 0, "a facetless drive item must never become a node");
let nested: i64 = conn
.query_row(
"SELECT count(*) FROM file_nodes child JOIN file_nodes parent
ON child.parent_id = parent.id
WHERE child.name = 'inner.bin' AND parent.name = 'Docs'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(nested, 1, "child files hang off their directory");
let body: Vec<u8> = conn
.query_row(
"SELECT b.data FROM file_nodes f JOIN blobs b ON b.id = f.blob_id
WHERE f.name = 'top.txt'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(body, b"hello", "file content is stored verbatim");
}