diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index 3eca1f1..0c5c79b 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -15,6 +15,7 @@ pub mod purge; pub mod quota; pub mod security; pub mod task; +pub mod tenant; use crate::utils::server::TestServerBuilder; use registry::schema::structs::{Expression, Imap, MtaStageAuth}; @@ -60,7 +61,6 @@ pub async fn system_tests() { authentication::test(&test).await; oidc::test(&mut test).await; authorization::test(&mut test).await; - #[cfg(feature = "pending-rebuild")] // inbuxa: pending-rebuild, see docs/spec/features/multi-tenancy.md tenant::test(&mut test).await; security::test(&mut test).await; quota::test(&mut test).await; diff --git a/tests/src/system/tenant.rs b/tests/src/system/tenant.rs new file mode 100644 index 0000000..0d006a8 --- /dev/null +++ b/tests/src/system/tenant.rs @@ -0,0 +1,1122 @@ +/* + * 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("admin@example.org"); + + // 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( + "tadmin@t.example.org", + 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("tuser@t.example.org", SECRET_T_USER, "T user", &[], vec![]) + .await; + let u_admin = admin + .create_user_account( + "uadmin@u.example.org", + SECRET_U_ADMIN, + "U admin", + &[], + vec![], + ) + .await; + admin.make_tenant_admin(u_admin.id()).await; + let u_user = admin + .create_user_account("uuser@u.example.org", 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::::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![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::::new()) + .await + .method_response() + .text_field("type"), + "forbidden", + "MT-2 {object_type:?}" + ); + } + let response = t_admin + .registry_get_many(ObjectType::Domain, Vec::::new()) + .await; + assert_eq!( + response + .list() + .iter() + .map(|d| d.object_id()) + .collect::>(), + 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::::new()) + .await; + assert_eq!( + response + .list() + .iter() + .map(|t| t.object_id()) + .collect::>(), + 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("tgroup@t.example.org", "T group", &[]) + .await; + assert_eq!(admin.tenant_of(t_group.id()).await, Some(t_id), "MT-11"); + let u_group = admin + .create_group_account("ugroup@u.example.org", "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( + "tuser@t.example.org", + &["uuser@u.example.org"], + concat!( + "From: tuser@t.example.org\r\n", + "To: uuser@u.example.org\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("muser@m.example.org", 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("padmin@p.example.org", SECRET_P, "P admin", &[], vec![]) + .await; + admin.make_tenant_admin(p_admin.id()).await; + let p_user = admin + .create_user_account( + "puser@p.example.org", + 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( + "suser@example.org", + 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" + ); + + // 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("dadmin@d.example.org", SECRET_D, "D admin", &[], vec![]) + .await; + admin.make_tenant_admin(d_admin.id()).await; + for name in ["d1@d.example.org", "d2@d.example.org", "d3@d.example.org"] { + admin + .create_user_account(name, SECRET_D, "D user", &[], vec![]) + .await; + } + let message = concat!( + "From: someone@remote.example.org\r\n", + "To: d@d.example.org\r\n", + "Subject: filling the tenant\r\n", + "\r\n", + "0123456789012345678901234567890123456789012345678901234567890123456789\r\n" + ); + lmtp.ingest("someone@remote.example.org", &["d1@d.example.org"], message) + .await; + let one = admin.tenant_usage(d_id).await; + assert!(one > 0, "MT-20: usage moves on delivery"); + lmtp.ingest("someone@remote.example.org", &["d2@d.example.org"], 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("someone@remote.example.org", &["d3@d.example.org"], 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": {"": {"name", "quotas", "members": [ids]}}, +/// "tenantAdmins": {"": {"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), + ); + + // 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::(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 { + let mut ids = value + .as_array() + .unwrap() + .iter() + .map(|id| Id::from_str(id.as_str().unwrap()).unwrap()) + .collect::>(); + 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("admin@example.org").await; + test.insert_account(admin); + self::test(&mut test).await; + if test.is_reset() { + test.temp_dir.delete(); + } +} + +fn sorted(mut ids: Vec) -> Vec { + 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 { + 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 { + 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 { + self.tenant_of_object(ObjectType::Account, account_id).await + } + + async fn tenant_of_domain(&self, domain_id: Id) -> Option { + self.tenant_of_object(ObjectType::Domain, domain_id).await + } + + async fn tenant_of_object(&self, object_type: ObjectType, id: Id) -> Option { + 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 { + 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() + } +}