Files
inbuxa-server/tests/src/system/tenant.rs
T
jcoffey-dev a993f9ab01
ci / fork-checks (pull_request) Successful in 47s
ci / build (pull_request) Successful in 5m4s
Write the brand in lowercase where people see it
The name is inbuxa, lowercase, like the wordmark; INBUXA reads as an
acronym. The admin and webmail already changed. Here that's everything
the server shows people: the brand macro behind the protocol greetings,
the HTTP and SCIM realms, the startup banner and the calendar and contact
PRODID; the first-party OAuth client descriptions; the legacy-protocol
refusals; the default calendar and address book names and the SMTP
greeting default, in the code and the schema served to the admin
(checksum regenerated); startup and shutdown events; the User-Agent;
the sign-in and RSVP pages; the service units; the OpenAPI realm; the
crate descriptions and the README, where it's set in bold.

Identifiers that are uppercase for their own reasons stay: INBUXA_*
settings, SUBSPACE_INBUXA. So do code comments and the AGPL 5(a) notice
lines.

Tests follow: the IMAP ID name, the default collection names, the PRODID
in the iTIP fixtures and the CalDAV free-busy expectations, and the e2e
legacy-protocol refusals. The webdav, imap and jmap suites pass, so do
the unit tests of every crate touched, and 73 of 75 SMTP tests; of the
other two, antispam fails on main too, and queue_retry is a timing flake
that passes on its own.
2026-09-22 20:08:10 -07:00

1178 lines
38 KiB
Rust

/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Multi-tenancy acceptance tests 1 to 14, from
//! `docs/spec/features/multi-tenancy.md`. Each check names the test number
//! or the requirement it covers.
//!
//! Test 15, INBUXA's own data, is `tenant_compat` below. It needs a copy of
//! production data and is ignored until one is provided.
use crate::utils::{
account::Account,
jmap::JmapUtils,
server::{TestServer, TestServerBuilder},
smtp::SmtpConnection,
};
use common::auth::BuildAccessToken;
use email::mailbox::INBOX_ID;
use jmap_proto::error::set::SetErrorType;
use registry::{
schema::{
enums::{
Permission, TaskStoreMaintenanceType, TaskTenantMaintenanceType, TenantStorageQuota,
},
prelude::{ObjectType, Property},
structs::{
self, CertificateManagement, CustomRoles, DkimManagement, DnsManagement, Domain,
Expression, Imap, MtaStageAuth, Permissions, PermissionsList, Roles, Task, TaskStatus,
TaskStoreMaintenance, TaskTenantMaintenance, Tenant, UserRoles,
},
},
types::{EnumImpl, map::Map},
};
use serde_json::{Value, json};
use std::str::FromStr;
use store::write::{BatchBuilder, ValueClass};
use trc::{Collector, MetricType};
use types::id::Id;
use utils::map::vec_map::VecMap;
const SECRET_T_ADMIN: &str = "tenant t administrator passphrase";
const SECRET_T_USER: &str = "tenant t ordinary user passphrase";
const SECRET_U_ADMIN: &str = "tenant u administrator passphrase";
const SECRET_U_USER: &str = "tenant u ordinary user passphrase";
const SECRET_P: &str = "tenant p people share this passphrase";
const SECRET_D: &str = "tenant d people share this passphrase";
pub async fn test(test: &mut TestServer) {
println!("Running multi-tenancy tests...");
let admin = test.account("[email protected]");
// Two tenants, each with a domain, an administrator and a user
let t_id = admin.create_tenant("Tenant T").await;
let u_id = admin.create_tenant("Tenant U").await;
let t_domain = admin
.create_tenant_domain("t.example.org", Some(t_id))
.await;
let u_domain = admin
.create_tenant_domain("u.example.org", Some(u_id))
.await;
// Acceptance test 6: an account created on a tenant's domain without a
// memberTenantId is in that tenant (MT-7)
let t_admin = admin
.create_user_account(
"[email protected]",
SECRET_T_ADMIN,
"T admin",
&[],
vec![],
)
.await;
assert_eq!(admin.tenant_of(t_admin.id()).await, Some(t_id), "test 6");
admin.make_tenant_admin(t_admin.id()).await;
let t_user = admin
.create_user_account("[email protected]", SECRET_T_USER, "T user", &[], vec![])
.await;
let u_admin = admin
.create_user_account(
"[email protected]",
SECRET_U_ADMIN,
"U admin",
&[],
vec![],
)
.await;
admin.make_tenant_admin(u_admin.id()).await;
let u_user = admin
.create_user_account("[email protected]", SECRET_U_USER, "U user", &[], vec![])
.await;
assert_eq!(admin.tenant_of(u_user.id()).await, Some(u_id), "test 6");
// MT-7: a tenant other than the domain's is refused, naming the domain
admin
.registry_create_object_expect_err(structs::Account::User(structs::UserAccount {
name: "misplaced".to_string(),
domain_id: t_domain,
member_tenant_id: Some(u_id),
..Default::default()
}))
.await
.assert_type(SetErrorType::InvalidForeignKey);
// Acceptance test 1: the tenant admin lists only its tenant's accounts
let listed = t_admin.all_ids(ObjectType::Account).await;
assert_eq!(
sorted(listed),
sorted(vec![t_admin.id(), t_user.id()]),
"test 1"
);
let fetched = t_admin
.registry_get_many(ObjectType::Account, Vec::<Id>::new())
.await;
assert_eq!(
sorted(fetched.list().iter().map(|a| a.object_id()).collect()),
sorted(vec![t_admin.id(), t_user.id()]),
"test 1"
);
// MT-11: memberTenantId isn't returned inside a tenant
for account in fetched.list() {
assert!(
account.get(Property::MemberTenantId.as_str()).is_none(),
"MT-11: {account}"
);
}
// Acceptance test 2: another tenant's account is notFound, not forbidden
let response = t_admin
.registry_get_many(ObjectType::Account, [u_user.id()])
.await;
assert!(response.list().is_empty(), "test 2");
assert_eq!(
response.not_found().collect::<Vec<_>>(),
vec![u_user.id().to_string().as_str()],
"test 2"
);
// MT-2: server-level objects are out of reach, and so is the admin's own
// account on the server level
for object_type in [ObjectType::NetworkListener, ObjectType::SystemSettings] {
assert_eq!(
t_admin
.registry_get_many(object_type, Vec::<Id>::new())
.await
.method_response()
.text_field("type"),
"forbidden",
"MT-2 {object_type:?}"
);
}
let response = t_admin
.registry_get_many(ObjectType::Domain, Vec::<Id>::new())
.await;
assert_eq!(
response
.list()
.iter()
.map(|d| d.object_id())
.collect::<Vec<_>>(),
vec![t_domain],
"MT-1: domains"
);
// MT-12: the tenant admin reads its own tenant, and nothing else, and
// can't change it
let response = t_admin
.registry_get_many(ObjectType::Tenant, Vec::<Id>::new())
.await;
assert_eq!(
response
.list()
.iter()
.map(|t| t.object_id())
.collect::<Vec<_>>(),
vec![t_id],
"MT-12"
);
assert!(
response.list()[0]
.get(Property::UsedDiskQuota.as_str())
.is_some(),
"MT-12: usage"
);
assert_eq!(
t_admin.all_ids(ObjectType::Tenant).await,
vec![t_id],
"MT-12 query"
);
assert_eq!(
t_admin
.registry_get_many(ObjectType::Tenant, [u_id])
.await
.not_found()
.count(),
1,
"MT-12"
);
assert_eq!(
t_admin
.registry_update(ObjectType::Tenant, [(t_id, json!({"name": "Renamed"}))])
.await
.method_response()
.text_field("type"),
"forbidden",
"MT-12"
);
// MT-11: what a tenant admin creates lands in its tenant
let t_group = t_admin
.create_group_account("[email protected]", "T group", &[])
.await;
assert_eq!(admin.tenant_of(t_group.id()).await, Some(t_id), "MT-11");
let u_group = admin
.create_group_account("[email protected]", "U group", &[])
.await;
// Acceptance test 3: no group membership across tenants (MT-3)
t_admin
.registry_update_object_expect_err(
ObjectType::Account,
t_user.id(),
json!({ Property::MemberGroupIds: Map::new(vec![u_group.id()]) }),
)
.await
.assert_type(SetErrorType::InvalidForeignKey);
admin
.registry_update_object_expect_err(
ObjectType::Account,
u_user.id(),
json!({ Property::MemberGroupIds: Map::new(vec![t_group.id()]) }),
)
.await
.assert_type(SetErrorType::InvalidForeignKey);
t_admin
.registry_update_object_expect_err(
ObjectType::Account,
u_user.id(),
json!({ Property::MemberGroupIds: Map::new(vec![t_group.id()]) }),
)
.await
.assert_type(SetErrorType::NotFound);
// A server-level account can't join a tenant's group either
admin
.registry_update_object_expect_err(
ObjectType::Account,
admin.id(),
json!({ Property::MemberGroupIds: Map::new(vec![t_group.id()]) }),
)
.await
.assert_type(SetErrorType::InvalidForeignKey);
// Within the tenant it works
t_admin
.registry_update_object(
ObjectType::Account,
t_user.id(),
json!({ Property::MemberGroupIds: Map::new(vec![t_group.id()]) }),
)
.await;
// Acceptance test 5: a tenant admin can't set memberTenantId (MT-6)
t_admin
.registry_update(
ObjectType::Account,
[(t_user.id(), json!({ Property::MemberTenantId: u_id }))],
)
.await
.not_updated(&t_user.id().to_string());
t_admin
.registry_update(
ObjectType::Account,
[(t_user.id(), json!({ Property::MemberTenantId: null }))],
)
.await
.not_updated(&t_user.id().to_string());
t_admin
.registry_update(
ObjectType::Domain,
[(t_domain, json!({ Property::MemberTenantId: null }))],
)
.await
.not_updated(&t_domain.to_string());
assert_eq!(admin.tenant_of(t_user.id()).await, Some(t_id), "test 5");
// Acceptance test 4: mail between tenants is delivered (MT-4)
let mut lmtp = SmtpConnection::connect().await;
lmtp.ingest(
"[email protected]",
&["[email protected]"],
concat!(
"From: [email protected]\r\n",
"To: [email protected]\r\n",
"Subject: across tenants\r\n",
"\r\n",
"Hello from tenant T.\r\n"
),
)
.await;
assert!(
test.server
.get_used_quota_account(u_user.id().document_id())
.await
.unwrap()
> 0,
"test 4"
);
// MT-3: no sharing across tenants, and a missing grantee answers the same.
// U's user has an inbox now that mail reached it.
for grantee in [t_user.id(), Id::new(987654)] {
let response = u_user
.jmap_method_call(
"Mailbox/set",
json!({
"accountId": u_user.id_string(),
"update": {
Id::from(INBOX_ID).to_string(): {
"shareWith": { grantee.to_string(): { "mayReadItems": true } }
}
}
}),
)
.await;
response
.not_updated(&Id::from(INBOX_ID).to_string())
.to_set_error()
.assert_type(SetErrorType::InvalidForeignKey);
}
// MT-22: the logo that applies, domain before tenant
assert_eq!(t_user.session_logo().await, Value::Null, "MT-22");
admin
.registry_update_object(
ObjectType::Tenant,
t_id,
json!({ Property::Logo: "https://logo.example.org/tenant.png" }),
)
.await;
assert_eq!(
t_user.session_logo().await,
json!("https://logo.example.org/tenant.png"),
"MT-22"
);
admin
.registry_update_object(
ObjectType::Domain,
t_domain,
json!({ Property::Logo: "https://logo.example.org/domain.png" }),
)
.await;
assert_eq!(
t_user.session_logo().await,
json!("https://logo.example.org/domain.png"),
"MT-22"
);
assert_eq!(u_user.session_logo().await, Value::Null, "MT-22");
// Acceptance test 7 and MT-8: moving domains
admin
.registry_update_object_expect_err(
ObjectType::Domain,
t_domain,
json!({ Property::MemberTenantId: null }),
)
.await
.assert_type(SetErrorType::InvalidProperties)
.assert_properties(&[Property::MemberTenantId.as_str()]);
admin
.registry_update_object_expect_err(
ObjectType::Domain,
t_domain,
json!({ Property::MemberTenantId: u_id }),
)
.await
.assert_type(SetErrorType::InvalidProperties);
assert_eq!(admin.tenant_of_domain(t_domain).await, Some(t_id), "test 7");
// Moving a domain in carries its principals and DKIM keys (MT-8, MT-9)
let m_domain = admin
.registry_create_object(Domain {
name: "m.example.org".to_string(),
is_enabled: true,
certificate_management: CertificateManagement::Manual,
dns_management: DnsManagement::Manual,
..Default::default()
})
.await;
test.wait_for_tasks_skip_failures().await;
let m_key = *admin
.dkim_keys_of(m_domain)
.await
.first()
.expect("MT-8: no DKIM keys generated");
let m_user = admin
.create_user_account("[email protected]", SECRET_T_USER, "M user", &[], vec![])
.await;
assert_eq!(admin.tenant_of(m_user.id()).await, None);
admin
.registry_update_object(
ObjectType::Domain,
m_domain,
json!({ Property::MemberTenantId: t_id }),
)
.await;
assert_eq!(admin.tenant_of(m_user.id()).await, Some(t_id), "MT-8");
assert_eq!(
admin
.tenant_of_object(ObjectType::DkimSignature, m_key)
.await,
Some(t_id),
"MT-8"
);
// ... and out again only once its people are gone
admin
.registry_update_object_expect_err(
ObjectType::Domain,
m_domain,
json!({ Property::MemberTenantId: null }),
)
.await
.assert_type(SetErrorType::InvalidProperties);
admin.destroy_account(m_user).await;
test.wait_for_tasks().await;
admin
.registry_update_object(
ObjectType::Domain,
m_domain,
json!({ Property::MemberTenantId: null }),
)
.await;
assert_eq!(
admin
.tenant_of_object(ObjectType::DkimSignature, m_key)
.await,
None,
"MT-8"
);
admin
.registry_destroy(
ObjectType::DkimSignature,
admin.dkim_keys_of(m_domain).await,
)
.await;
admin.registry_destroy(ObjectType::Domain, [m_domain]).await;
// MT-9: generated DKIM keys take the domain's tenant
let k_domain = admin
.registry_create_object(Domain {
name: "k.example.org".to_string(),
is_enabled: true,
member_tenant_id: Some(t_id),
certificate_management: CertificateManagement::Manual,
dns_management: DnsManagement::Manual,
..Default::default()
})
.await;
test.wait_for_tasks_skip_failures().await;
let keys = admin.dkim_keys_of(k_domain).await;
assert!(!keys.is_empty(), "MT-9: no keys generated");
for key in &keys {
assert_eq!(
admin
.tenant_of_object(ObjectType::DkimSignature, *key)
.await,
Some(t_id),
"MT-9"
);
}
admin
.registry_destroy(ObjectType::DkimSignature, keys)
.await;
admin.registry_destroy(ObjectType::Domain, [k_domain]).await;
// Acceptance test 8: a tenant that's still referenced can't be deleted
admin
.registry_destroy_object_expect_err(ObjectType::Tenant, t_id)
.await
.assert_type(SetErrorType::ObjectIsLinked);
// Tests 9 to 11 use a tenant of their own
let p_id = admin.create_tenant("Tenant P").await;
admin
.create_tenant_domain("p.example.org", Some(p_id))
.await;
let p_admin = admin
.create_user_account("[email protected]", SECRET_P, "P admin", &[], vec![])
.await;
admin.make_tenant_admin(p_admin.id()).await;
let p_user = admin
.create_user_account(
"[email protected]",
SECRET_P,
"P user",
&[],
vec![Permission::UnlimitedRequests],
)
.await;
// Acceptance test 9: a permission the tenant lacks has no effect (MT-13)
assert!(
!test
.permissions_of(p_user.id())
.await
.has_permission(Permission::UnlimitedRequests),
"test 9"
);
let s_user = admin
.create_user_account(
"[email protected]",
SECRET_P,
"Server-level user",
&[],
vec![Permission::UnlimitedRequests],
)
.await;
assert!(
test.permissions_of(s_user.id())
.await
.has_permission(Permission::UnlimitedRequests),
"test 9, outside a tenant"
);
// MT-15: impersonate has no effect in a tenant, even when the tenant
// allows it and the user holds it
admin
.registry_update_object(
ObjectType::Tenant,
p_id,
json!({
Property::Permissions: Permissions::Merge(PermissionsList {
enabled_permissions: Map::new(vec![Permission::Impersonate]),
disabled_permissions: Map::default(),
})
}),
)
.await;
admin
.registry_update_object(
ObjectType::Account,
p_user.id(),
json!({
Property::Permissions: Permissions::Merge(PermissionsList {
enabled_permissions: Map::new(vec![Permission::Impersonate]),
disabled_permissions: Map::default(),
})
}),
)
.await;
assert!(
!test
.permissions_of(p_user.id())
.await
.has_permission(Permission::Impersonate),
"MT-15: impersonate"
);
assert_eq!(
p_user
.jmap_method_call(
"Mailbox/get",
json!({ "accountId": admin.id_string(), "ids": null }),
)
.await
.method_response()
.text_field("type"),
"forbidden",
"MT-1: no reach into another account's mail"
);
admin
.registry_update_object(
ObjectType::Tenant,
p_id,
json!({ Property::Permissions: Permissions::Inherit }),
)
.await;
// Acceptance test 10: disabled wins over enabled in Replace (MT-14)
admin
.registry_update_object(
ObjectType::Tenant,
p_id,
json!({
Property::Permissions: Permissions::Replace(PermissionsList {
enabled_permissions: Map::new(vec![
Permission::Authenticate,
Permission::JmapEmailGet,
]),
disabled_permissions: Map::new(vec![Permission::JmapEmailGet]),
})
}),
)
.await;
let token = test.permissions_of(p_user.id()).await;
assert!(token.has_permission(Permission::Authenticate), "test 10");
assert!(!token.has_permission(Permission::JmapEmailGet), "test 10");
assert!(!token.has_permission(Permission::JmapMailboxGet), "test 10");
admin
.registry_update_object(
ObjectType::Tenant,
p_id,
json!({ Property::Permissions: Permissions::Inherit }),
)
.await;
assert!(
test.permissions_of(p_user.id())
.await
.has_permission(Permission::JmapEmailGet),
"test 10"
);
// Acceptance test 11: lowering a tenant's roles reaches a signed-in
// administrator on its next request (MT-16)
assert!(p_admin.can_list_accounts().await, "test 11");
let user_role = admin.role_id("user").await;
admin
.registry_update_object(
ObjectType::Tenant,
p_id,
json!({ Property::Roles: Roles::Custom(CustomRoles { role_ids: Map::new(vec![user_role]) }) }),
)
.await;
assert!(!p_admin.can_list_accounts().await, "test 11: roles");
admin
.registry_update_object(
ObjectType::Tenant,
p_id,
json!({ Property::Roles: Roles::Default }),
)
.await;
assert!(p_admin.can_list_accounts().await, "test 11: restored");
// The permissions field on its own too
admin
.registry_update_object(
ObjectType::Tenant,
p_id,
json!({
Property::Permissions: Permissions::Merge(PermissionsList {
enabled_permissions: Map::default(),
disabled_permissions: Map::new(vec![Permission::SysAccountQuery]),
})
}),
)
.await;
assert!(!p_admin.can_list_accounts().await, "test 11: permissions");
admin
.registry_update_object(
ObjectType::Tenant,
p_id,
json!({ Property::Permissions: Permissions::Inherit }),
)
.await;
assert!(p_admin.can_list_accounts().await, "test 11: restored");
// Acceptance test 12: count limits (MT-17, MT-18)
let q_id = admin.create_tenant("Tenant Q").await;
let q_domain = admin
.create_tenant_domain("q.example.org", Some(q_id))
.await;
admin
.set_tenant_quota(q_id, TenantStorageQuota::MaxAccounts, 2)
.await;
let q1 = admin.create_plain_user("q1", q_domain).await;
let q2 = admin.create_plain_user("q2", q_domain).await;
let events = Collector::read_metric(MetricType::LimitTenantQuota);
admin
.registry_create_object_expect_err(structs::Account::User(structs::UserAccount {
name: "q3".to_string(),
domain_id: q_domain,
..Default::default()
}))
.await
.assert_type(SetErrorType::OverQuota)
.assert_description_contains("maxAccounts");
assert!(
Collector::read_metric(MetricType::LimitTenantQuota) > events,
"test 12: limit.tenant-quota"
);
admin
.set_tenant_quota(q_id, TenantStorageQuota::MaxAccounts, 1)
.await;
assert_eq!(
admin
.registry_get_many(ObjectType::Account, [q1, q2])
.await
.list()
.len(),
2,
"test 12: existing accounts stay"
);
admin
.registry_create_object_expect_err(structs::Account::User(structs::UserAccount {
name: "q4".to_string(),
domain_id: q_domain,
..Default::default()
}))
.await
.assert_type(SetErrorType::OverQuota);
// Moving a domain in counts what comes with it
let n_domain = admin.create_tenant_domain("n.example.org", None).await;
let n_user = admin.create_plain_user("n1", n_domain).await;
admin
.registry_update_object_expect_err(
ObjectType::Domain,
n_domain,
json!({ Property::MemberTenantId: q_id }),
)
.await
.assert_type(SetErrorType::OverQuota);
admin
.registry_destroy(ObjectType::Account, [n_user])
.await
.assert_destroyed(&[n_user]);
test.wait_for_tasks().await;
admin.registry_destroy(ObjectType::Domain, [n_domain]).await;
// A domain's generated DKIM keys count before it's created (MT-17, MT-9)
admin
.set_tenant_quota(q_id, TenantStorageQuota::MaxDkimKeys, 1)
.await;
admin
.registry_create_object_expect_err(Domain {
name: "keys.example.org".to_string(),
is_enabled: true,
member_tenant_id: Some(q_id),
certificate_management: CertificateManagement::Manual,
dns_management: DnsManagement::Manual,
..Default::default()
})
.await
.assert_type(SetErrorType::OverQuota)
.assert_description_contains("maxDkimKeys");
// Acceptance test 13: the tenant's disk limit bounds all its members
// together (MT-19, MT-20)
let d_id = admin.create_tenant("Tenant D").await;
admin
.create_tenant_domain("d.example.org", Some(d_id))
.await;
let d_admin = admin
.create_user_account("[email protected]", SECRET_D, "D admin", &[], vec![])
.await;
admin.make_tenant_admin(d_admin.id()).await;
for name in ["[email protected]", "[email protected]", "[email protected]"] {
admin
.create_user_account(name, SECRET_D, "D user", &[], vec![])
.await;
}
let message = concat!(
"From: [email protected]\r\n",
"To: [email protected]\r\n",
"Subject: filling the tenant\r\n",
"\r\n",
"0123456789012345678901234567890123456789012345678901234567890123456789\r\n"
);
lmtp.ingest("[email protected]", &["[email protected]"], message)
.await;
let one = admin.tenant_usage(d_id).await;
assert!(one > 0, "MT-20: usage moves on delivery");
lmtp.ingest("[email protected]", &["[email protected]"], message)
.await;
let two = admin.tenant_usage(d_id).await;
assert!(two > one, "MT-20");
// Room for half a message more: the next one doesn't fit (observed 2)
admin
.set_tenant_quota(
d_id,
TenantStorageQuota::MaxDiskQuota,
(two + one / 2) as u64,
)
.await;
// The message is accepted and queued, and local delivery refuses it
// for now, so it waits in the queue (MT-19a)
lmtp.ingest("[email protected]", &["[email protected]"], message)
.await;
let mut reason = String::new();
for _ in 0..20 {
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
let queued = admin.all_ids(ObjectType::QueuedMessage).await;
reason = admin
.registry_get_many(ObjectType::QueuedMessage, queued)
.await
.list_array()
.to_string();
if reason.contains("Organization over quota") {
break;
}
}
assert!(
reason.contains("Organization over quota"),
"test 13: {reason}"
);
assert_eq!(admin.tenant_usage(d_id).await, two, "test 13");
// MT-5: only the recipient's tenant administrator sees it in the queue
assert_eq!(
d_admin.all_ids(ObjectType::QueuedMessage).await.len(),
1,
"MT-5: the tenant's own"
);
assert!(
t_admin.all_ids(ObjectType::QueuedMessage).await.is_empty(),
"MT-5: nothing else"
);
let queued = admin.all_ids(ObjectType::QueuedMessage).await;
assert!(
t_admin
.registry_get_many(ObjectType::QueuedMessage, queued.clone())
.await
.list()
.is_empty(),
"MT-5"
);
admin
.registry_destroy(ObjectType::QueuedMessage, queued)
.await;
// Acceptance test 14: recalculateQuota restores a corrupted figure (MT-21)
test.corrupt_tenant_usage(d_id, 123_456).await;
assert_ne!(admin.tenant_usage(d_id).await, two);
admin
.registry_create_object(Task::TenantMaintenance(TaskTenantMaintenance {
tenant_id: d_id,
maintenance_type: TaskTenantMaintenanceType::RecalculateQuota,
status: TaskStatus::now(),
}))
.await;
test.wait_for_tasks().await;
assert_eq!(admin.tenant_usage(d_id).await, two, "test 14");
// ... and resetTenantQuotas, every tenant at once
test.corrupt_tenant_usage(d_id, -777).await;
admin
.registry_create_object(Task::StoreMaintenance(TaskStoreMaintenance {
maintenance_type: TaskStoreMaintenanceType::ResetTenantQuotas,
shard_index: None,
status: TaskStatus::now(),
}))
.await;
test.wait_for_tasks().await;
assert_eq!(admin.tenant_usage(d_id).await, two, "test 14");
// Clean up, leaving the server as the other tests expect it
for account in [
t_admin, t_user, t_group, u_admin, u_user, u_group, p_admin, p_user, s_user, d_admin,
] {
admin.destroy_account(account).await;
}
for name in ["d1", "d2", "d3"] {
let id = admin.account_id_by_name(name).await;
admin
.registry_destroy(ObjectType::Account, [id])
.await
.assert_destroyed(&[id]);
}
admin
.registry_destroy(ObjectType::Account, [q1, q2])
.await
.assert_destroyed(&[q1, q2]);
test.wait_for_tasks().await;
for domain in admin
.registry_query_ids(
ObjectType::Domain,
Vec::<(&str, &str)>::new(),
Vec::<&str>::new(),
)
.await
{
if admin.tenant_of_domain(domain).await.is_some() {
admin
.registry_destroy(ObjectType::Domain, [domain])
.await
.assert_destroyed(&[domain]);
}
}
for tenant in [t_id, u_id, p_id, q_id, d_id] {
admin
.registry_destroy(ObjectType::Tenant, [tenant])
.await
.assert_destroyed(&[tenant]);
}
let _ = u_domain;
}
/// Acceptance test 15 (compat): INBUXA's existing tenants, their members and
/// quotas read back unchanged, and each tenant admin sees what it saw before.
///
/// Needs a copy of INBUXA's data, which isn't in the repository:
///
/// - `INBUXA_COMPAT_ADMIN`: `name:password` of a server-level administrator
/// in that data;
/// - `INBUXA_COMPAT_EXPECTED`: a JSON file recorded against the Enterprise
/// server before the move, shaped as
/// `{"tenants": {"<id>": {"name", "quotas", "members": [ids]}},
/// "tenantAdmins": {"<name>": {"password", "accounts": [ids],
/// "domains": [ids]}}}`;
///
/// and the data itself in place of the test store: run with `NO_INSERT=1`
/// and the store's `TMPDIR`/`STORE` pointing at the copy, so it isn't reset.
#[ignore]
#[tokio::test(flavor = "multi_thread")]
pub async fn tenant_compat() {
let admin = std::env::var("INBUXA_COMPAT_ADMIN").expect("INBUXA_COMPAT_ADMIN");
let expected: Value = serde_json::from_slice(
&std::fs::read(std::env::var("INBUXA_COMPAT_EXPECTED").expect("INBUXA_COMPAT_EXPECTED"))
.expect("expected file"),
)
.expect("expected JSON");
assert!(
std::env::var("NO_INSERT").is_ok(),
"NO_INSERT must be set, or the copy of inbuxa's data is wiped"
);
let test = TestServerBuilder::new("tenant_compat")
.await
.with_default_listeners()
.await
.build_with_opts(false)
.await;
let (name, secret) = admin.split_once(':').expect("name:password");
let admin = Account::new(
leak(name),
leak(secret),
&[],
"Compat admin",
Id::from(u32::MAX),
);
admin.assert_authenticates("INBUXA_COMPAT_ADMIN").await;
// Tenants, their quotas and their members read back unchanged
for (id, tenant) in expected["tenants"].as_object().expect("tenants") {
let id = Id::from_str(id).expect("tenant id");
let stored = admin.registry_get::<Tenant>(id).await;
assert_eq!(stored.name, tenant["name"].as_str().unwrap(), "{id}");
assert_eq!(
serde_json::to_value(&stored.quotas).unwrap(),
tenant["quotas"],
"{id}"
);
let mut members = admin
.registry_query_ids(
ObjectType::Account,
[(Property::MemberTenantId, id.to_string())],
Vec::<&str>::new(),
)
.await;
members.sort_unstable();
assert_eq!(members, ids(&tenant["members"]), "tenant {id}'s members");
}
// Each tenant admin sees what it saw before
for (name, seen) in expected["tenantAdmins"].as_object().expect("tenantAdmins") {
let tenant_admin = Account::new(
leak(name),
leak(seen["password"].as_str().unwrap()),
&[],
"Tenant admin",
Id::from(u32::MAX),
);
for (object_type, key) in [
(ObjectType::Account, "accounts"),
(ObjectType::Domain, "domains"),
] {
let mut listed = tenant_admin.all_ids(object_type).await;
listed.sort_unstable();
assert_eq!(listed, ids(&seen[key]), "{name}'s {key}");
}
}
drop(test);
}
fn ids(value: &Value) -> Vec<Id> {
let mut ids = value
.as_array()
.unwrap()
.iter()
.map(|id| Id::from_str(id.as_str().unwrap()).unwrap())
.collect::<Vec<_>>();
ids.sort_unstable();
ids
}
fn leak(value: &str) -> &'static str {
Box::leak(value.to_string().into_boxed_str())
}
/// Runs the tenancy tests alone, for working on them:
/// `cargo test -p tests tenant_tests -- --ignored`.
#[ignore]
#[tokio::test(flavor = "multi_thread")]
pub async fn tenant_tests() {
let mut test = TestServerBuilder::new("tenant_tests")
.await
.with_default_listeners()
.await
.with_object(Imap {
allow_plain_text_auth: true,
..Default::default()
})
.await
.with_object(MtaStageAuth {
require: Expression {
else_: "false".to_string(),
..Default::default()
},
..Default::default()
})
.await
.build()
.await;
let admin = test.create_admin_account("[email protected]").await;
test.insert_account(admin);
self::test(&mut test).await;
if test.is_reset() {
test.temp_dir.delete();
}
}
fn sorted(mut ids: Vec<Id>) -> Vec<Id> {
ids.sort_unstable();
ids
}
impl TestServer {
async fn permissions_of(&self, account_id: Id) -> common::auth::AccessToken {
self.server
.access_token(account_id.document_id())
.await
.unwrap()
.build()
}
async fn corrupt_tenant_usage(&self, tenant_id: Id, by: i64) {
let mut batch = BatchBuilder::new();
batch.add(ValueClass::TenantQuota(tenant_id.document_id()), by);
self.server
.core
.storage
.data
.write(batch.build_all())
.await
.unwrap();
}
}
impl Account {
async fn create_tenant(&self, name: &str) -> Id {
self.registry_create_object(Tenant {
name: name.to_string(),
..Default::default()
})
.await
}
async fn create_tenant_domain(&self, name: &str, tenant_id: Option<Id>) -> Id {
self.registry_create_object(Domain {
name: name.to_string(),
is_enabled: true,
member_tenant_id: tenant_id,
certificate_management: CertificateManagement::Manual,
dns_management: DnsManagement::Manual,
dkim_management: DkimManagement::Manual,
..Default::default()
})
.await
}
async fn dkim_keys_of(&self, domain_id: Id) -> Vec<Id> {
self.registry_query_ids(
ObjectType::DkimSignature,
[(Property::DomainId, domain_id.to_string())],
Vec::<&str>::new(),
)
.await
}
async fn create_plain_user(&self, name: &str, domain_id: Id) -> Id {
self.registry_create_object(structs::Account::User(structs::UserAccount {
name: name.to_string(),
domain_id,
..Default::default()
}))
.await
}
async fn make_tenant_admin(&self, account_id: Id) {
self.registry_update_object(
ObjectType::Account,
account_id,
json!({ Property::Roles: UserRoles::Admin }),
)
.await;
}
async fn set_tenant_quota(&self, tenant_id: Id, quota: TenantStorageQuota, limit: u64) {
self.registry_update_object(
ObjectType::Tenant,
tenant_id,
json!({ Property::Quotas: VecMap::from_iter([(quota, limit)]) }),
)
.await;
}
async fn tenant_of(&self, account_id: Id) -> Option<Id> {
self.tenant_of_object(ObjectType::Account, account_id).await
}
async fn tenant_of_domain(&self, domain_id: Id) -> Option<Id> {
self.tenant_of_object(ObjectType::Domain, domain_id).await
}
async fn tenant_of_object(&self, object_type: ObjectType, id: Id) -> Option<Id> {
let response = self.registry_get_many(object_type, [id]).await;
let object = response
.list()
.first()
.unwrap_or_else(|| panic!("{object_type:?} {id} not found: {response:?}"));
object
.get(Property::MemberTenantId.as_str())
.and_then(|v| v.as_str())
.map(|v| Id::from_str(v).unwrap())
}
async fn tenant_usage(&self, tenant_id: Id) -> i64 {
self.registry_get_many(ObjectType::Tenant, [tenant_id])
.await
.list()[0]
.integer_field(Property::UsedDiskQuota.as_str())
}
async fn role_id(&self, description: &str) -> Id {
*self
.registry_query_ids(
ObjectType::Role,
[(Property::Description, description)],
Vec::<&str>::new(),
)
.await
.first()
.unwrap_or_else(|| panic!("Role {description} not found"))
}
async fn account_id_by_name(&self, name: &str) -> Id {
*self
.registry_query_ids(
ObjectType::Account,
[(Property::Name, name)],
Vec::<&str>::new(),
)
.await
.first()
.unwrap_or_else(|| panic!("Account {name} not found"))
}
async fn all_ids(&self, object_type: ObjectType) -> Vec<Id> {
self.registry_query_ids(object_type, Vec::<(&str, &str)>::new(), Vec::<&str>::new())
.await
}
async fn can_list_accounts(&self) -> bool {
let response = self
.registry_query(
ObjectType::Account,
Vec::<(&str, &str)>::new(),
Vec::<&str>::new(),
)
.await;
response.method_response().get("ids").is_some()
}
async fn session_logo(&self) -> Value {
let session = self.jmap_session_object().await;
session.0["accounts"][self.id_string()]["accountCapabilities"]["urn:inbuxa:jmap"]["logo"]
.clone()
}
}