Import upstream v0.16.22, stripped
Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 117 in 50 files Dangling module declarations removed: 5 Cargo edits turning enterprise off: 14 Verification: clean Enterprise feature gates left for rebuilt features: 19 in 18 files Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
@@ -0,0 +1,804 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{registry::UnwrapRegistryId, server::TestServer};
|
||||
use jmap_tools::JsonPointer;
|
||||
use registry::{
|
||||
jmap::{IntoValue, JmapValue, JsonPointerPatch, MaybeUnpatched, RegistryJsonPatch},
|
||||
pickle::{Pickle, PickledStream},
|
||||
schema::{
|
||||
enums::{AccountType, Locale, Permission, StorageQuota},
|
||||
prelude::{Object, ObjectType, Property},
|
||||
structs::{
|
||||
Account, CertificateManagement, Credential, CredentialPermissions,
|
||||
CredentialPermissionsList, CustomRoles, DkimManagement, DnsManagement, Domain,
|
||||
EmailAlias, EncryptionAtRest, EncryptionSettings, GroupAccount, MailingList,
|
||||
PasswordCredential, Permissions, PermissionsList, PublicKey, SecondaryCredential,
|
||||
SieveUserScript, UserAccount, UserRoles,
|
||||
},
|
||||
},
|
||||
types::{
|
||||
EnumImpl, ObjectImpl, datetime::UTCDateTime, id::ObjectId, ipmask::IpAddrOrMask,
|
||||
list::List, map::Map,
|
||||
},
|
||||
};
|
||||
use std::str::FromStr;
|
||||
use store::{
|
||||
registry::{
|
||||
RegistryQuery,
|
||||
write::{RegistryWrite, RegistryWriteResult},
|
||||
},
|
||||
write::now,
|
||||
};
|
||||
use types::id::Id;
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub async fn test(test: &TestServer) {
|
||||
let r = test.server.registry();
|
||||
|
||||
println!("Registry tests...");
|
||||
|
||||
test_patch_regressions();
|
||||
|
||||
// Pickle-unpickle test
|
||||
let mut account = Account::User(UserAccount {
|
||||
aliases: List::from_iter([
|
||||
EmailAlias {
|
||||
description: "Test Alias 1".to_string().into(),
|
||||
domain_id: 1000u64.into(),
|
||||
enabled: true,
|
||||
name: "alias1".into(),
|
||||
},
|
||||
EmailAlias {
|
||||
description: "Test Alias 2".to_string().into(),
|
||||
domain_id: 1001u64.into(),
|
||||
enabled: true,
|
||||
name: "alias2".into(),
|
||||
},
|
||||
]),
|
||||
created_at: UTCDateTime::now(),
|
||||
credentials: List::from_iter([
|
||||
Credential::Password(PasswordCredential {
|
||||
allowed_ips: Map::new(vec![IpAddrOrMask::from_str("192.168.1.1").unwrap()]),
|
||||
credential_id: 3u64.into(),
|
||||
expires_at: None,
|
||||
otp_auth: "otpauth://totp/test?secret=SECRET".to_string().into(),
|
||||
secret: "secret".into(),
|
||||
}),
|
||||
Credential::AppPassword(SecondaryCredential {
|
||||
allowed_ips: Map::new(vec![IpAddrOrMask::from_str("192.168.1.0/24").unwrap()]),
|
||||
created_at: UTCDateTime::now(),
|
||||
credential_id: 4u64.into(),
|
||||
description: "App Password".into(),
|
||||
expires_at: Some(UTCDateTime::from_timestamp((now() + 1000) as i64)),
|
||||
permissions: CredentialPermissions::Disable(CredentialPermissionsList {
|
||||
permissions: Map::new(vec![
|
||||
Permission::Authenticate,
|
||||
Permission::ActionClassifySpam,
|
||||
]),
|
||||
}),
|
||||
secret: "app_password_secret".into(),
|
||||
}),
|
||||
]),
|
||||
description: "This is a test Account".to_string().into(),
|
||||
domain_id: 1004u64.into(),
|
||||
encryption_at_rest: EncryptionAtRest::Aes128(EncryptionSettings {
|
||||
allow_spam_training: true,
|
||||
encrypt_on_append: false,
|
||||
public_key: 0u64.into(),
|
||||
}),
|
||||
external_id: "8f7c1e2a-4b3d-4f1a-9c2e-7d5b6a8f0e11".to_string().into(),
|
||||
locale: Locale::EnUS,
|
||||
member_group_ids: Map::new(vec![2000u64.into(), 2001u64.into()]),
|
||||
member_tenant_id: None,
|
||||
name: "user".into(),
|
||||
permissions: Permissions::Merge(PermissionsList {
|
||||
disabled_permissions: Map::new(vec![Permission::Impersonate]),
|
||||
enabled_permissions: Map::new(vec![Permission::JmapBlobGet]),
|
||||
}),
|
||||
quotas: VecMap::from_iter([
|
||||
(StorageQuota::MaxDiskQuota, 1024u64),
|
||||
(StorageQuota::MaxApiKeys, 3u64),
|
||||
]),
|
||||
roles: UserRoles::Custom(CustomRoles {
|
||||
role_ids: Map::new(vec![5000u64.into()]),
|
||||
}),
|
||||
time_zone: None,
|
||||
});
|
||||
let account_pickle = account.to_pickled_vec();
|
||||
assert_eq!(
|
||||
account,
|
||||
Account::unpickle(&mut PickledStream::new(&account_pickle).unwrap()).unwrap()
|
||||
);
|
||||
|
||||
// Pickle compression test
|
||||
let script = SieveUserScript {
|
||||
contents: "A".repeat(100_000),
|
||||
description: "B".repeat(100_000).into(),
|
||||
is_active: true,
|
||||
name: "C".repeat(100_000),
|
||||
};
|
||||
let script_pickle = script.to_pickled_vec();
|
||||
assert!(
|
||||
script_pickle.len() < 8_192,
|
||||
"Pickle was not compressed: {} bytes",
|
||||
script_pickle.len()
|
||||
);
|
||||
assert_eq!(
|
||||
script,
|
||||
SieveUserScript::unpickle(&mut PickledStream::new(&script_pickle).unwrap()).unwrap()
|
||||
);
|
||||
|
||||
// Create a domain and a group
|
||||
let domain_id = r
|
||||
.write(RegistryWrite::insert(
|
||||
&Domain {
|
||||
name: "test.org".into(),
|
||||
certificate_management: CertificateManagement::Manual,
|
||||
dns_management: DnsManagement::Manual,
|
||||
dkim_management: DkimManagement::Manual,
|
||||
is_enabled: true,
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
let domain_id_2 = r
|
||||
.write(RegistryWrite::insert(
|
||||
&Domain {
|
||||
name: "test.net".into(),
|
||||
certificate_management: CertificateManagement::Manual,
|
||||
dns_management: DnsManagement::Manual,
|
||||
dkim_management: DkimManagement::Manual,
|
||||
is_enabled: true,
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
let group_id = r
|
||||
.write(RegistryWrite::insert(
|
||||
&Account::Group(GroupAccount {
|
||||
name: "group".into(),
|
||||
domain_id,
|
||||
..Default::default()
|
||||
})
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
|
||||
// Inserting an account linking non-existing ids should fail
|
||||
test.assert_registry_insert_error(
|
||||
account.clone(),
|
||||
RegistryWriteResult::InvalidForeignKey {
|
||||
object_id: ObjectId::new(ObjectType::Account, Id::new(2000)),
|
||||
},
|
||||
trc::location!(),
|
||||
)
|
||||
.await;
|
||||
account.assert_patch(
|
||||
&format!("memberGroupIds/{}", Id::new(2000)),
|
||||
false,
|
||||
trc::location!(),
|
||||
);
|
||||
account.assert_patch(
|
||||
&format!("memberGroupIds/{}", Id::new(2001)),
|
||||
false,
|
||||
trc::location!(),
|
||||
);
|
||||
account.assert_patch(
|
||||
&format!("memberGroupIds/{}", group_id),
|
||||
true,
|
||||
trc::location!(),
|
||||
);
|
||||
|
||||
test.assert_registry_insert_error(
|
||||
account.clone(),
|
||||
RegistryWriteResult::InvalidForeignKey {
|
||||
object_id: ObjectId::new(ObjectType::Domain, Id::new(1000)),
|
||||
},
|
||||
trc::location!(),
|
||||
)
|
||||
.await;
|
||||
account.assert_patch("aliases/0/domainId", domain_id, trc::location!());
|
||||
account.assert_patch("aliases/1/domainId", domain_id, trc::location!());
|
||||
|
||||
test.assert_registry_insert_error(
|
||||
account.clone(),
|
||||
RegistryWriteResult::InvalidForeignKey {
|
||||
object_id: ObjectId::new(ObjectType::Domain, Id::new(1004)),
|
||||
},
|
||||
trc::location!(),
|
||||
)
|
||||
.await;
|
||||
account.assert_patch("domainId", domain_id, trc::location!());
|
||||
|
||||
test.assert_registry_insert_error(
|
||||
account.clone(),
|
||||
RegistryWriteResult::InvalidForeignKey {
|
||||
object_id: ObjectId::new(ObjectType::PublicKey, Id::new(0)),
|
||||
},
|
||||
trc::location!(),
|
||||
)
|
||||
.await;
|
||||
account.assert_patch(
|
||||
"encryptionAtRest",
|
||||
EncryptionAtRest::Disabled.into_value(),
|
||||
trc::location!(),
|
||||
);
|
||||
|
||||
test.assert_registry_insert_error(
|
||||
account.clone(),
|
||||
RegistryWriteResult::InvalidForeignKey {
|
||||
object_id: ObjectId::new(ObjectType::Role, Id::new(5000)),
|
||||
},
|
||||
trc::location!(),
|
||||
)
|
||||
.await;
|
||||
account.assert_patch("roles", UserRoles::User.into_value(), trc::location!());
|
||||
|
||||
let account_id = r
|
||||
.write(RegistryWrite::insert(&account.into()))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
|
||||
// Deleting linked objects should fail
|
||||
test.assert_registry_delete_error(
|
||||
ObjectType::Domain,
|
||||
domain_id,
|
||||
RegistryWriteResult::CannotDeleteLinked {
|
||||
object_id: ObjectId::new(ObjectType::Domain, domain_id),
|
||||
linked_objects: vec![
|
||||
ObjectId::new(ObjectType::Account, group_id),
|
||||
ObjectId::new(ObjectType::Account, account_id),
|
||||
],
|
||||
},
|
||||
trc::location!(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Primary key violations should not be allowed
|
||||
test.assert_registry_insert_error(
|
||||
Domain {
|
||||
name: "test.org".into(),
|
||||
is_enabled: true,
|
||||
certificate_management: CertificateManagement::Manual,
|
||||
dns_management: DnsManagement::Manual,
|
||||
dkim_management: DkimManagement::Manual,
|
||||
..Default::default()
|
||||
},
|
||||
RegistryWriteResult::PrimaryKeyConflict {
|
||||
property: Property::Name,
|
||||
existing_id: ObjectId::new(ObjectType::Domain, domain_id),
|
||||
},
|
||||
trc::location!(),
|
||||
)
|
||||
.await;
|
||||
test.assert_registry_insert_error(
|
||||
Account::Group(GroupAccount {
|
||||
name: "group".into(),
|
||||
domain_id,
|
||||
..Default::default()
|
||||
}),
|
||||
RegistryWriteResult::PrimaryKeyConflict {
|
||||
property: Property::Email,
|
||||
existing_id: ObjectId::new(ObjectType::Account, group_id),
|
||||
},
|
||||
trc::location!(),
|
||||
)
|
||||
.await;
|
||||
test.assert_registry_insert_error(
|
||||
MailingList {
|
||||
name: "user".into(),
|
||||
domain_id,
|
||||
recipients: Map::new(vec!["[email protected]".into()]),
|
||||
..Default::default()
|
||||
},
|
||||
RegistryWriteResult::PrimaryKeyConflict {
|
||||
property: Property::Email,
|
||||
existing_id: ObjectId::new(ObjectType::Account, account_id),
|
||||
},
|
||||
trc::location!(),
|
||||
)
|
||||
.await;
|
||||
test.assert_registry_insert_error(
|
||||
MailingList {
|
||||
name: "mailing-list".into(),
|
||||
domain_id,
|
||||
aliases: List::from_iter([EmailAlias {
|
||||
description: "Test Alias 1".to_string().into(),
|
||||
domain_id,
|
||||
enabled: true,
|
||||
name: "alias1".into(),
|
||||
}]),
|
||||
recipients: Map::new(vec!["[email protected]".into()]),
|
||||
..Default::default()
|
||||
},
|
||||
RegistryWriteResult::PrimaryKeyConflict {
|
||||
property: Property::Email,
|
||||
existing_id: ObjectId::new(ObjectType::Account, account_id),
|
||||
},
|
||||
trc::location!(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Create a public key and link it to the account
|
||||
let pk_id = r
|
||||
.write(RegistryWrite::insert(
|
||||
&PublicKey {
|
||||
account_id,
|
||||
key: "secret".into(),
|
||||
description: "Test Key".into(),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
let old_account = r
|
||||
.get(ObjectId::new(ObjectType::Account, account_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut account = old_account.clone();
|
||||
assert_obj_patch(
|
||||
&mut account,
|
||||
"encryptionAtRest",
|
||||
EncryptionAtRest::Aes128(EncryptionSettings {
|
||||
allow_spam_training: true,
|
||||
encrypt_on_append: false,
|
||||
public_key: pk_id,
|
||||
})
|
||||
.into_value(),
|
||||
trc::location!(),
|
||||
);
|
||||
r.write(RegistryWrite::update(account_id, &account, &old_account))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
|
||||
// Search tests
|
||||
assert_eq!(
|
||||
r.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Domain))
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![domain_id, domain_id_2]
|
||||
);
|
||||
assert_eq!(
|
||||
r.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Domain).equal_pk(
|
||||
Property::Name,
|
||||
"test.org".to_string(),
|
||||
true,
|
||||
))
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![domain_id]
|
||||
);
|
||||
assert_eq!(
|
||||
r.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Account))
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![group_id, account_id]
|
||||
);
|
||||
assert_eq!(
|
||||
r.query::<Vec<Id>>(
|
||||
RegistryQuery::new(ObjectType::Account)
|
||||
.equal(Property::Type, AccountType::User.to_id())
|
||||
.text(Property::Text, "this is a test")
|
||||
.equal(Property::Name, "user")
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![account_id]
|
||||
);
|
||||
|
||||
// Sort test
|
||||
assert_eq!(
|
||||
r.sort_by_index(ObjectType::Account, Property::Type, None, true)
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![account_id, group_id]
|
||||
);
|
||||
assert_eq!(
|
||||
r.sort_by_index(
|
||||
ObjectType::Account,
|
||||
Property::Type,
|
||||
Some(vec![group_id, account_id]),
|
||||
true
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![account_id, group_id]
|
||||
);
|
||||
assert_eq!(
|
||||
r.sort_by_index(ObjectType::Account, Property::Name, None, true)
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![group_id, account_id]
|
||||
);
|
||||
assert_eq!(
|
||||
r.sort_by_pk(ObjectType::Domain, Property::Name, None, true)
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![domain_id_2, domain_id]
|
||||
);
|
||||
assert_eq!(
|
||||
r.sort_by_pk(
|
||||
ObjectType::Domain,
|
||||
Property::Name,
|
||||
Some(vec![domain_id, domain_id_2]),
|
||||
true
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![domain_id_2, domain_id]
|
||||
);
|
||||
|
||||
// Delete everything
|
||||
let old_account = r
|
||||
.get(ObjectId::new(ObjectType::Account, account_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut account = old_account.clone();
|
||||
assert_obj_patch(
|
||||
&mut account,
|
||||
"encryptionAtRest",
|
||||
EncryptionAtRest::Disabled.into_value(),
|
||||
trc::location!(),
|
||||
);
|
||||
r.write(RegistryWrite::update(account_id, &account, &old_account))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
r.write(RegistryWrite::delete(ObjectId::new(
|
||||
ObjectType::PublicKey,
|
||||
pk_id,
|
||||
)))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
r.write(RegistryWrite::delete(ObjectId::new(
|
||||
ObjectType::Account,
|
||||
account_id,
|
||||
)))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
r.write(RegistryWrite::delete(ObjectId::new(
|
||||
ObjectType::Account,
|
||||
group_id,
|
||||
)))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
r.write(RegistryWrite::delete(ObjectId::new(
|
||||
ObjectType::Domain,
|
||||
domain_id,
|
||||
)))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
r.write(RegistryWrite::delete(ObjectId::new(
|
||||
ObjectType::Domain,
|
||||
domain_id_2,
|
||||
)))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
|
||||
test.assert_is_empty().await;
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
pub async fn assert_registry_insert_error(
|
||||
&self,
|
||||
obj: impl Into<Object>,
|
||||
result: RegistryWriteResult,
|
||||
location: &str,
|
||||
) {
|
||||
let obj = obj.into();
|
||||
|
||||
assert_eq!(
|
||||
self.server
|
||||
.registry()
|
||||
.write(RegistryWrite::insert(&obj))
|
||||
.await
|
||||
.unwrap(),
|
||||
result,
|
||||
"{}",
|
||||
location
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn assert_registry_delete_error(
|
||||
&self,
|
||||
object_type: ObjectType,
|
||||
id: Id,
|
||||
result: RegistryWriteResult,
|
||||
location: &str,
|
||||
) {
|
||||
assert_eq!(
|
||||
self.server
|
||||
.registry()
|
||||
.write(RegistryWrite::delete(ObjectId::new(object_type, id)))
|
||||
.await
|
||||
.unwrap(),
|
||||
result,
|
||||
"{}",
|
||||
location
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_patch_regressions() {
|
||||
fn fresh_account() -> Account {
|
||||
Account::User(UserAccount {
|
||||
credentials: List::from_iter([
|
||||
Credential::Password(PasswordCredential {
|
||||
allowed_ips: Map::new(vec![
|
||||
IpAddrOrMask::from_str("192.168.1.1").unwrap(),
|
||||
IpAddrOrMask::from_str("192.168.1.2").unwrap(),
|
||||
]),
|
||||
credential_id: 3u64.into(),
|
||||
expires_at: None,
|
||||
otp_auth: None,
|
||||
secret: "secret".into(),
|
||||
}),
|
||||
Credential::Password(PasswordCredential {
|
||||
allowed_ips: Map::new(vec![IpAddrOrMask::from_str("10.0.0.1").unwrap()]),
|
||||
credential_id: 4u64.into(),
|
||||
expires_at: None,
|
||||
otp_auth: None,
|
||||
secret: "another".into(),
|
||||
}),
|
||||
]),
|
||||
domain_id: 1u64.into(),
|
||||
name: "patch-target".into(),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn user(account: &Account) -> &UserAccount {
|
||||
match account {
|
||||
Account::User(u) => u,
|
||||
_ => panic!("expected user account"),
|
||||
}
|
||||
}
|
||||
|
||||
fn user_mut(account: &mut Account) -> &mut UserAccount {
|
||||
match account {
|
||||
Account::User(u) => u,
|
||||
_ => panic!("expected user account"),
|
||||
}
|
||||
}
|
||||
|
||||
fn password_at(account: &Account, idx: u32) -> &PasswordCredential {
|
||||
let cred = user(account)
|
||||
.credentials
|
||||
.0
|
||||
.get(&idx)
|
||||
.expect("credential at index");
|
||||
match cred {
|
||||
Credential::Password(p) => p,
|
||||
_ => panic!("expected password credential at idx {idx}"),
|
||||
}
|
||||
}
|
||||
|
||||
// Leaf-null patch into a List<T> entry removes only the leaf not the whole entry.
|
||||
let mut account = fresh_account();
|
||||
account.assert_patch(
|
||||
"credentials/0/allowedIps/192.168.1.1",
|
||||
JmapValue::Null,
|
||||
trc::location!(),
|
||||
);
|
||||
{
|
||||
let cred = password_at(&account, 0);
|
||||
assert_eq!(cred.allowed_ips.len(), 1, "one ip should remain");
|
||||
assert!(
|
||||
cred.allowed_ips
|
||||
.contains(&IpAddrOrMask::from_str("192.168.1.2").unwrap()),
|
||||
"remaining ip survived"
|
||||
);
|
||||
assert!(
|
||||
!cred
|
||||
.allowed_ips
|
||||
.contains(&IpAddrOrMask::from_str("192.168.1.1").unwrap()),
|
||||
"targeted ip removed"
|
||||
);
|
||||
}
|
||||
// The sibling credential is untouched.
|
||||
{
|
||||
let cred = password_at(&account, 1);
|
||||
assert_eq!(cred.allowed_ips.len(), 1);
|
||||
assert!(
|
||||
cred.allowed_ips
|
||||
.contains(&IpAddrOrMask::from_str("10.0.0.1").unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
// Removing every leaf still leaves the entry in place with an empty map.
|
||||
let mut account = fresh_account();
|
||||
account.assert_patch(
|
||||
"credentials/0/allowedIps/192.168.1.1",
|
||||
JmapValue::Null,
|
||||
trc::location!(),
|
||||
);
|
||||
account.assert_patch(
|
||||
"credentials/0/allowedIps/192.168.1.2",
|
||||
JmapValue::Null,
|
||||
trc::location!(),
|
||||
);
|
||||
{
|
||||
assert_eq!(
|
||||
user(&account).credentials.len(),
|
||||
2,
|
||||
"credential entry retained"
|
||||
);
|
||||
let cred = password_at(&account, 0);
|
||||
assert!(cred.allowed_ips.is_empty(), "leaf map drained");
|
||||
}
|
||||
|
||||
// Direct removal of a list entry with no remaining segments still works.
|
||||
let mut account = fresh_account();
|
||||
account.assert_patch("credentials/0", JmapValue::Null, trc::location!());
|
||||
{
|
||||
assert_eq!(user(&account).credentials.len(), 1, "credential 0 removed");
|
||||
let cred = password_at(&account, 1);
|
||||
assert_eq!(cred.allowed_ips.len(), 1);
|
||||
}
|
||||
|
||||
// Leaf-null patch into a scalar property of a list entry clears only that property.
|
||||
let mut account = fresh_account();
|
||||
{
|
||||
let cred = user_mut(&mut account)
|
||||
.credentials
|
||||
.inner_mut()
|
||||
.get_mut(&0)
|
||||
.expect("credential at 0");
|
||||
if let Credential::Password(p) = cred {
|
||||
p.expires_at = Some(UTCDateTime::from_timestamp(now() as i64));
|
||||
}
|
||||
}
|
||||
account.assert_patch("credentials/0/expiresAt", JmapValue::Null, trc::location!());
|
||||
{
|
||||
let cred = password_at(&account, 0);
|
||||
assert!(cred.expires_at.is_none(), "expiresAt cleared");
|
||||
assert_eq!(cred.allowed_ips.len(), 2, "siblings untouched");
|
||||
}
|
||||
|
||||
// Map<T> set-style patches
|
||||
fn account_with_groups() -> Account {
|
||||
let mut account = match fresh_account() {
|
||||
Account::User(u) => u,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
account.member_group_ids = Map::new(vec![Id::new(2000), Id::new(2001)]);
|
||||
Account::User(account)
|
||||
}
|
||||
|
||||
let mut account = account_with_groups();
|
||||
account.assert_patch(
|
||||
&format!("memberGroupIds/{}", Id::new(2000)),
|
||||
JmapValue::Null,
|
||||
trc::location!(),
|
||||
);
|
||||
assert_eq!(
|
||||
user(&account).member_group_ids.len(),
|
||||
1,
|
||||
"one member removed"
|
||||
);
|
||||
assert!(
|
||||
user(&account).member_group_ids.contains(&Id::new(2001)),
|
||||
"sibling preserved"
|
||||
);
|
||||
|
||||
let mut account = account_with_groups();
|
||||
let extra_path = format!("memberGroupIds/{}/extra", Id::new(2000));
|
||||
let ptr = JsonPointer::parse(&extra_path);
|
||||
let outcome = account.patch(JsonPointerPatch::new(&ptr), JmapValue::Null);
|
||||
assert!(outcome.is_err(), "extra segments must error on remove");
|
||||
assert_eq!(
|
||||
user(&account).member_group_ids.len(),
|
||||
2,
|
||||
"membership unchanged after rejected patch"
|
||||
);
|
||||
|
||||
let mut account = account_with_groups();
|
||||
let ptr = JsonPointer::parse(&format!("memberGroupIds/{}/extra", Id::new(2002)));
|
||||
let outcome = account.patch(JsonPointerPatch::new(&ptr), JmapValue::Bool(true));
|
||||
assert!(outcome.is_err(), "extra segments must error on add");
|
||||
assert_eq!(
|
||||
user(&account).member_group_ids.len(),
|
||||
2,
|
||||
"membership unchanged after rejected add"
|
||||
);
|
||||
|
||||
// Direct adds and removes still work.
|
||||
let mut account = account_with_groups();
|
||||
account.assert_patch(
|
||||
&format!("memberGroupIds/{}", Id::new(2002)),
|
||||
true,
|
||||
trc::location!(),
|
||||
);
|
||||
assert_eq!(user(&account).member_group_ids.len(), 3, "member added");
|
||||
assert!(user(&account).member_group_ids.contains(&Id::new(2002)));
|
||||
}
|
||||
|
||||
trait AssertPatch {
|
||||
fn assert_patch(&mut self, patch: &str, value: impl Into<JmapValue<'static>>, location: &str);
|
||||
}
|
||||
|
||||
impl<T: RegistryJsonPatch> AssertPatch for T {
|
||||
fn assert_patch(&mut self, patch: &str, value: impl Into<JmapValue<'static>>, location: &str) {
|
||||
let ptr = JsonPointer::parse(patch);
|
||||
let patch = JsonPointerPatch::new(&ptr);
|
||||
let value = value.into();
|
||||
match self.patch(patch, value) {
|
||||
Ok(maybe_unpatched) => {
|
||||
match maybe_unpatched {
|
||||
MaybeUnpatched::Patched => {
|
||||
// Patch succeeded
|
||||
}
|
||||
MaybeUnpatched::Unpatched { property, value } => {
|
||||
panic!(
|
||||
"Expected patch to succeed but it was unpatched at {}: property: {}, value: {:?}",
|
||||
location, property, value
|
||||
);
|
||||
}
|
||||
MaybeUnpatched::UnpatchedMany { properties } => {
|
||||
panic!(
|
||||
"Expected patch to succeed but it was unpatched at {}: properties: {:?}",
|
||||
location, properties
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => panic!("Patch failed at {}: {:?}", location, err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_obj_patch(
|
||||
obj: &mut Object,
|
||||
patch: &str,
|
||||
value: impl Into<JmapValue<'static>>,
|
||||
location: &str,
|
||||
) {
|
||||
let ptr = JsonPointer::parse(patch);
|
||||
let patch = JsonPointerPatch::new(&ptr);
|
||||
let value = value.into();
|
||||
match obj.patch(patch, value) {
|
||||
Ok(maybe_unpatched) => {
|
||||
match maybe_unpatched {
|
||||
MaybeUnpatched::Patched => {
|
||||
// Patch succeeded
|
||||
}
|
||||
MaybeUnpatched::Unpatched { property, value } => {
|
||||
panic!(
|
||||
"Expected patch to succeed but it was unpatched at {}: property: {}, value: {:?}",
|
||||
location, property, value
|
||||
);
|
||||
}
|
||||
MaybeUnpatched::UnpatchedMany { properties } => {
|
||||
panic!(
|
||||
"Expected patch to succeed but it was unpatched at {}: properties: {:?}",
|
||||
location, properties
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => panic!("Patch failed at {}: {:?}", location, err),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user