Bump JMAP File Storage to draft-ietf-jmap-filenode-14

This commit is contained in:
Maurus Decimus
2026-05-16 10:44:10 +02:00
parent b9794315aa
commit 7790645421
20 changed files with 1087 additions and 170 deletions

View File

@@ -481,7 +481,87 @@ pub async fn test(test: &TestServer) {
.await
.updated(&john_folder_id);
// Verify Jane can delete the folder
// FileNode/copy: Jane copies a node from her own account into John's shared folder
let jane_folder_id = jane
.jmap_create(
MethodObject::FileNode,
[json!({"name": "jane-src"})],
Vec::<(&str, &str)>::new(),
)
.await
.created(0)
.id()
.to_string();
let copied = jane
.jmap_copy(
jane,
john,
MethodObject::FileNode,
[(
&jane_folder_id,
json!({ "parentId": &john_folder_id, "name": "copied-here" }),
)],
false,
)
.await;
let copied_id = copied.copied(&jane_folder_id).id().to_string();
assert_ne!(copied_id, jane_folder_id);
jane.jmap_get_account(
john,
MethodObject::FileNode,
[
FileNodeProperty::Id,
FileNodeProperty::Name,
FileNodeProperty::ParentId,
],
[copied_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": &copied_id,
"name": "copied-here",
"parentId": &john_folder_id,
}));
// Original still exists in Jane's account (onSuccessDestroyOriginal=false)
jane.jmap_get(
MethodObject::FileNode,
[FileNodeProperty::Id],
[jane_folder_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({ "id": &jane_folder_id }));
// onExists=rename on copy: colliding into John's folder again must echo the new name
let renamed_copy = jane
.jmap_method_calls(json!([[
"FileNode/copy",
{
"fromAccountId": jane.id_string(),
"accountId": john.id_string(),
"onExists": "rename",
"create": {
&jane_folder_id: { "parentId": &john_folder_id, "name": "copied-here" }
}
},
"0"
]]))
.await;
let renamed_entry = renamed_copy.copied(&jane_folder_id);
let renamed_copy_id = renamed_entry.id().to_string();
assert_eq!(renamed_entry.text_field("name"), "copied-here (2)");
jane.jmap_destroy(
MethodObject::FileNode,
[&jane_folder_id],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.for_each(drop);
// Verify Jane can delete the folder (and the node copied into it)
assert_eq!(
jane.jmap_destroy_account(
john,
@@ -491,8 +571,14 @@ pub async fn test(test: &TestServer) {
)
.await
.destroyed()
.collect::<Vec<_>>(),
[john_folder_id.as_str()]
.collect::<std::collections::HashSet<_>>(),
[
john_folder_id.as_str(),
copied_id.as_str(),
renamed_copy_id.as_str()
]
.into_iter()
.collect::<std::collections::HashSet<_>>()
);
// Destroy all mailboxes

View File

@@ -468,6 +468,216 @@ pub async fn test(test: &TestServer) {
.destroyed()
.for_each(drop);
// Pending+Reject: two creates with the same name in one batch, default onExists
let response = account
.jmap_create(
MethodObject::FileNode,
[
json!({"name": "twin-reject"}),
json!({"name": "twin-reject"}),
],
Vec::<(&str, &str)>::new(),
)
.await;
let twin_first = response.created(0).id().to_string();
let err = response.not_created(1);
assert_eq!(err.typ(), "alreadyExists");
assert!(
err.pointer("/existingId").is_none(),
"Pending Create collision has no committed existingId, got {err:?}"
);
account
.jmap_destroy(
MethodObject::FileNode,
[&twin_first],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.for_each(drop);
// Pending+Rename: second create within the batch should auto-rename
let response = account
.jmap_create(
MethodObject::FileNode,
[
json!({"name": "twin-rename"}),
json!({"name": "twin-rename"}),
],
[("onExists", "rename")],
)
.await;
let twin_a = response.created(0).id().to_string();
let twin_b_entry = response.created(1);
let twin_b = twin_b_entry.id().to_string();
assert_eq!(twin_b_entry.text_field("name"), "twin-rename (2)");
account
.jmap_destroy(
MethodObject::FileNode,
[&twin_a, &twin_b],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.for_each(drop);
// Pending+Replace: within-batch replace is intentionally not supported; second is rejected
let response = account
.jmap_create(
MethodObject::FileNode,
[
json!({"name": "twin-replace"}),
json!({"name": "twin-replace"}),
],
[("onExists", "replace")],
)
.await;
let twin_survivor = response.created(0).id().to_string();
let err = response.not_created(1);
assert_eq!(err.typ(), "alreadyExists");
assert!(
err.pointer("/existingId").is_none(),
"Pending Create + Replace returns alreadyExists with no existingId, got {err:?}"
);
account
.jmap_destroy(
MethodObject::FileNode,
[&twin_survivor],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.for_each(drop);
// Pending+Newest: in-batch newest comparison is intentionally not supported; second is rejected
let response = account
.jmap_create(
MethodObject::FileNode,
[
json!({"name": "twin-newest", "modified": "2020-01-01T00:00:00Z"}),
json!({"name": "twin-newest", "modified": "2040-01-01T00:00:00Z"}),
],
[("onExists", "newest")],
)
.await;
let twin_keep = response.created(0).id().to_string();
let err = response.not_created(1);
assert_eq!(err.typ(), "alreadyExists");
account
.jmap_destroy(
MethodObject::FileNode,
[&twin_keep],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.for_each(drop);
// Create+Update collision in one batch
let setup = account
.jmap_create(
MethodObject::FileNode,
[json!({"name": "lhs"})],
Vec::<(&str, &str)>::new(),
)
.await;
let lhs_id = setup.created(0).id().to_string();
let response = account
.jmap_method_calls(json!([[
"FileNode/set",
{
"accountId": account.id_string(),
"update": { &lhs_id: { "name": "merged" } },
"create": { "new1": { "name": "merged" } }
},
"0"
]]))
.await;
let created_new = response
.pointer("/methodResponses/0/1/created/new1")
.expect("new1 should be in created");
let new1_id = created_new.id().to_string();
let upd_err = response
.pointer(&format!("/methodResponses/0/1/notUpdated/{lhs_id}"))
.expect("update should fail");
assert_eq!(upd_err.typ(), "alreadyExists");
assert!(
upd_err.pointer("/existingId").is_none(),
"Pending-from-Create collision has no existingId, got {upd_err:?}"
);
account
.jmap_destroy(
MethodObject::FileNode,
[&lhs_id, &new1_id],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.for_each(drop);
// compareCaseInsensitively + Pending: in-batch "FOO"/"foo" collide when the flag is set
let response = account
.jmap_create(
MethodObject::FileNode,
[json!({"name": "FOO"}), json!({"name": "foo"})],
[("compareCaseInsensitively", true)],
)
.await;
let case_keep = response.created(0).id().to_string();
assert_eq!(response.not_created(1).typ(), "alreadyExists");
account
.jmap_destroy(
MethodObject::FileNode,
[&case_keep],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.for_each(drop);
// onExists=newest: incoming must have a strictly later modified to win
let response = account
.jmap_create(
MethodObject::FileNode,
[json!({"name": "stamped", "modified": "2030-01-01T00:00:00Z"})],
Vec::<(&str, &str)>::new(),
)
.await;
let stamped_id = response.created(0).id().to_string();
let older_attempt = account
.jmap_create(
MethodObject::FileNode,
[json!({"name": "stamped", "modified": "2020-01-01T00:00:00Z"})],
[("onExists", "newest")],
)
.await;
let err = older_attempt.not_created(0);
assert_eq!(err.typ(), "alreadyExists");
assert_eq!(err.text_field("existingId"), stamped_id.as_str());
let newer_attempt = account
.jmap_create(
MethodObject::FileNode,
[json!({"name": "stamped", "modified": "2040-01-01T00:00:00Z"})],
[("onExists", "newest")],
)
.await;
let stamped_winner = newer_attempt.created(0).id().to_string();
assert_ne!(stamped_winner, stamped_id);
let destroyed = newer_attempt.destroyed().collect::<AHashSet<_>>();
assert!(
destroyed.contains(stamped_id.as_str()),
"Expected {stamped_id} to be destroyed by newer onExists=newest, got {destroyed:?}"
);
account
.jmap_destroy(
MethodObject::FileNode,
[&stamped_winner],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.for_each(drop);
// Make sure everything is gone
test.assert_is_empty().await;
}