use super::storage::{InvitationStore, MembershipStore, OrganizationStore};
use super::types::DefaultOrgRole;
use crate::error::Result;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Clone, Debug)]
pub struct TestOrganization {
pub id: String,
pub name: String,
pub slug: String,
pub owner_id: String,
pub contact_email: String,
pub created_at: u64,
pub updated_at: u64,
}
#[derive(Clone, Debug)]
pub struct TestMembership {
pub org_id: String,
pub user_id: String,
pub role: DefaultOrgRole,
pub joined_at: u64,
}
#[derive(Clone, Debug)]
pub struct TestInvitation {
pub id: String,
pub org_id: String,
pub email: String,
pub role: DefaultOrgRole,
pub invited_by: String,
pub token: String,
pub expires_at: u64,
pub created_at: u64,
pub accepted: bool,
pub revoked: bool,
}
struct InMemoryOrgStoreInner {
orgs: RwLock<HashMap<String, TestOrganization>>,
orgs_by_slug: RwLock<HashMap<String, String>>, memberships: RwLock<HashMap<(String, String), TestMembership>>, invitations: RwLock<HashMap<String, TestInvitation>>,
invitations_by_token: RwLock<HashMap<String, String>>, }
#[derive(Clone)]
pub struct InMemoryOrgStore {
inner: Arc<InMemoryOrgStoreInner>,
}
impl Default for InMemoryOrgStore {
fn default() -> Self {
Self::new()
}
}
impl InMemoryOrgStore {
#[must_use]
pub fn new() -> Self {
Self {
inner: Arc::new(InMemoryOrgStoreInner {
orgs: RwLock::new(HashMap::new()),
orgs_by_slug: RwLock::new(HashMap::new()),
memberships: RwLock::new(HashMap::new()),
invitations: RwLock::new(HashMap::new()),
invitations_by_token: RwLock::new(HashMap::new()),
}),
}
}
pub fn insert_org(&self, org: TestOrganization) {
let id = org.id.clone();
let slug = org.slug.clone();
self.inner.orgs.write().unwrap().insert(id.clone(), org);
self.inner.orgs_by_slug.write().unwrap().insert(slug, id);
}
pub fn insert_membership(&self, membership: TestMembership) {
let key = (membership.org_id.clone(), membership.user_id.clone());
self.inner
.memberships
.write()
.unwrap()
.insert(key, membership);
}
pub fn insert_invitation(&self, invitation: TestInvitation) {
let id = invitation.id.clone();
let token = invitation.token.clone();
self.inner
.invitations
.write()
.unwrap()
.insert(id.clone(), invitation);
self.inner
.invitations_by_token
.write()
.unwrap()
.insert(token, id);
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
}
#[async_trait]
impl OrganizationStore for InMemoryOrgStore {
type Organization = TestOrganization;
async fn create(&self, org: &Self::Organization) -> Result<()> {
self.insert_org(org.clone());
Ok(())
}
async fn find_by_id(&self, id: &str) -> Result<Option<Self::Organization>> {
Ok(self.inner.orgs.read().unwrap().get(id).cloned())
}
async fn find_by_slug(&self, slug: &str) -> Result<Option<Self::Organization>> {
let id = self.inner.orgs_by_slug.read().unwrap().get(slug).cloned();
match id {
Some(id) => Ok(self.inner.orgs.read().unwrap().get(&id).cloned()),
None => Ok(None),
}
}
async fn update(&self, org: &Self::Organization) -> Result<()> {
let mut orgs = self.inner.orgs.write().unwrap();
if let Some(existing) = orgs.get_mut(&org.id) {
if existing.slug != org.slug {
let mut by_slug = self.inner.orgs_by_slug.write().unwrap();
by_slug.remove(&existing.slug);
by_slug.insert(org.slug.clone(), org.id.clone());
}
*existing = org.clone();
}
Ok(())
}
async fn delete(&self, id: &str) -> Result<()> {
let org = self.inner.orgs.write().unwrap().remove(id);
if let Some(org) = org {
self.inner.orgs_by_slug.write().unwrap().remove(&org.slug);
}
self.inner
.memberships
.write()
.unwrap()
.retain(|k, _| k.0 != id);
self.inner
.invitations
.write()
.unwrap()
.retain(|_, v| v.org_id != id);
Ok(())
}
fn org_id(&self, org: &Self::Organization) -> String {
org.id.clone()
}
fn org_name(&self, org: &Self::Organization) -> String {
org.name.clone()
}
fn org_slug(&self, org: &Self::Organization) -> String {
org.slug.clone()
}
fn owner_id(&self, org: &Self::Organization) -> String {
org.owner_id.clone()
}
fn contact_email(&self, org: &Self::Organization) -> String {
org.contact_email.clone()
}
async fn list_for_user(&self, user_id: &str) -> Result<Vec<Self::Organization>> {
let memberships = self.inner.memberships.read().unwrap();
let orgs = self.inner.orgs.read().unwrap();
let result: Vec<_> = memberships
.iter()
.filter(|(_, m)| m.user_id == user_id)
.filter_map(|((org_id, _), _)| orgs.get(org_id).cloned())
.collect();
Ok(result)
}
async fn count_owned_by_user(&self, user_id: &str) -> Result<u32> {
let orgs = self.inner.orgs.read().unwrap();
let count = orgs.values().filter(|o| o.owner_id == user_id).count();
Ok(count as u32)
}
}
#[async_trait]
impl MembershipStore for InMemoryOrgStore {
type Membership = TestMembership;
type Role = DefaultOrgRole;
async fn add_member(&self, membership: &Self::Membership) -> Result<()> {
self.insert_membership(membership.clone());
Ok(())
}
async fn remove_member(&self, org_id: &str, user_id: &str) -> Result<()> {
self.inner
.memberships
.write()
.unwrap()
.remove(&(org_id.to_string(), user_id.to_string()));
Ok(())
}
async fn get_membership(
&self,
org_id: &str,
user_id: &str,
) -> Result<Option<Self::Membership>> {
Ok(self
.inner
.memberships
.read()
.unwrap()
.get(&(org_id.to_string(), user_id.to_string()))
.cloned())
}
async fn list_members(&self, org_id: &str) -> Result<Vec<Self::Membership>> {
let memberships = self.inner.memberships.read().unwrap();
let result: Vec<_> = memberships
.iter()
.filter(|((oid, _), _)| oid == org_id)
.map(|(_, m)| m.clone())
.collect();
Ok(result)
}
async fn update_membership(&self, membership: &Self::Membership) -> Result<()> {
let key = (membership.org_id.clone(), membership.user_id.clone());
self.inner
.memberships
.write()
.unwrap()
.insert(key, membership.clone());
Ok(())
}
fn membership_user_id(&self, m: &Self::Membership) -> String {
m.user_id.clone()
}
fn membership_org_id(&self, m: &Self::Membership) -> String {
m.org_id.clone()
}
fn membership_role(&self, m: &Self::Membership) -> Self::Role {
m.role
}
fn can_manage_members(&self, role: &Self::Role) -> bool {
matches!(role, DefaultOrgRole::Owner | DefaultOrgRole::Admin)
}
fn can_manage_settings(&self, role: &Self::Role) -> bool {
matches!(role, DefaultOrgRole::Owner | DefaultOrgRole::Admin)
}
fn can_delete_org(&self, role: &Self::Role) -> bool {
matches!(role, DefaultOrgRole::Owner)
}
fn can_transfer_ownership(&self, role: &Self::Role) -> bool {
matches!(role, DefaultOrgRole::Owner)
}
fn is_owner(&self, role: &Self::Role) -> bool {
matches!(role, DefaultOrgRole::Owner)
}
async fn list_user_memberships(&self, user_id: &str) -> Result<Vec<Self::Membership>> {
let memberships = self.inner.memberships.read().unwrap();
let result: Vec<_> = memberships
.iter()
.filter(|((_, uid), _)| uid == user_id)
.map(|(_, m)| m.clone())
.collect();
Ok(result)
}
}
#[async_trait]
impl InvitationStore for InMemoryOrgStore {
type Invitation = TestInvitation;
type Role = DefaultOrgRole;
async fn create(&self, invitation: &Self::Invitation) -> Result<()> {
self.insert_invitation(invitation.clone());
Ok(())
}
async fn find_by_token(&self, token: &str) -> Result<Option<Self::Invitation>> {
let id = self
.inner
.invitations_by_token
.read()
.unwrap()
.get(token)
.cloned();
match id {
Some(id) => Ok(self.inner.invitations.read().unwrap().get(&id).cloned()),
None => Ok(None),
}
}
async fn find_by_id(&self, id: &str) -> Result<Option<Self::Invitation>> {
Ok(self.inner.invitations.read().unwrap().get(id).cloned())
}
async fn list_pending(&self, org_id: &str) -> Result<Vec<Self::Invitation>> {
let invitations = self.inner.invitations.read().unwrap();
let now = Self::now();
let result: Vec<_> = invitations
.values()
.filter(|i| i.org_id == org_id && !i.accepted && !i.revoked && i.expires_at > now)
.cloned()
.collect();
Ok(result)
}
async fn mark_accepted(&self, id: &str) -> Result<()> {
if let Some(inv) = self.inner.invitations.write().unwrap().get_mut(id) {
inv.accepted = true;
}
Ok(())
}
async fn mark_revoked(&self, id: &str) -> Result<()> {
if let Some(inv) = self.inner.invitations.write().unwrap().get_mut(id) {
inv.revoked = true;
}
Ok(())
}
async fn delete_expired(&self) -> Result<usize> {
let now = Self::now();
let mut invitations = self.inner.invitations.write().unwrap();
let mut by_token = self.inner.invitations_by_token.write().unwrap();
let expired: Vec<_> = invitations
.iter()
.filter(|(_, i)| i.expires_at <= now && !i.accepted && !i.revoked)
.map(|(id, i)| (id.clone(), i.token.clone()))
.collect();
let count = expired.len();
for (id, token) in expired {
invitations.remove(&id);
by_token.remove(&token);
}
Ok(count)
}
fn invitation_id(&self, inv: &Self::Invitation) -> String {
inv.id.clone()
}
fn invitation_org_id(&self, inv: &Self::Invitation) -> String {
inv.org_id.clone()
}
fn invitation_email(&self, inv: &Self::Invitation) -> String {
inv.email.clone()
}
fn invitation_role(&self, inv: &Self::Invitation) -> Self::Role {
inv.role
}
fn invitation_token(&self, inv: &Self::Invitation) -> String {
inv.token.clone()
}
fn invitation_expires_at(&self, inv: &Self::Invitation) -> u64 {
inv.expires_at
}
fn is_expired(&self, inv: &Self::Invitation) -> bool {
inv.expires_at <= Self::now()
}
fn is_revoked(&self, inv: &Self::Invitation) -> bool {
inv.revoked
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::organizations::error::OrganizationError;
use crate::organizations::seats::SeatChecker;
use crate::organizations::{
InvitationConfig, InvitationManager, MembershipManager, OrganizationConfig,
OrganizationManager, UnlimitedSeats,
};
fn make_org(p: crate::organizations::manager::OrgCreateParams) -> TestOrganization {
TestOrganization {
id: p.id,
name: p.name,
slug: p.slug,
owner_id: p.owner_id,
contact_email: p.contact_email,
created_at: p.created_at,
updated_at: p.created_at,
}
}
fn make_membership(
p: crate::organizations::manager::MembershipCreateParams,
role: DefaultOrgRole,
) -> TestMembership {
TestMembership {
org_id: p.org_id,
user_id: p.user_id,
role,
joined_at: p.joined_at,
}
}
#[derive(Clone)]
struct LimitedSeats(u32);
#[async_trait]
impl SeatChecker for LimitedSeats {
async fn has_seat_available(
&self,
_org_id: &str,
current_count: u32,
) -> crate::error::Result<bool> {
Ok(current_count < self.0)
}
async fn get_seat_limit(&self, _org_id: &str) -> crate::error::Result<Option<u32>> {
Ok(Some(self.0))
}
}
#[tokio::test]
async fn test_org_creation() {
let store = InMemoryOrgStore::new();
let manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let org = manager
.create(
"user_1",
"Test Org",
Some("test-org"),
"test@example.com",
|p| TestOrganization {
id: p.id,
name: p.name,
slug: p.slug,
owner_id: p.owner_id,
contact_email: p.contact_email,
created_at: p.created_at,
updated_at: p.created_at,
},
|p| TestMembership {
org_id: p.org_id,
user_id: p.user_id,
role: DefaultOrgRole::Owner,
joined_at: p.joined_at,
},
)
.await
.unwrap();
assert_eq!(org.name, "Test Org");
assert_eq!(org.slug, "test-org");
assert!(manager.is_member(&org.id, "user_1").await.unwrap());
}
#[tokio::test]
async fn test_membership_management() {
let store = InMemoryOrgStore::new();
let org_manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let mem_manager = MembershipManager::new(store.clone(), UnlimitedSeats);
let org = org_manager
.create(
"owner",
"Test Org",
None,
"owner@example.com",
|p| TestOrganization {
id: p.id,
name: p.name,
slug: p.slug,
owner_id: p.owner_id,
contact_email: p.contact_email,
created_at: p.created_at,
updated_at: p.created_at,
},
|p| TestMembership {
org_id: p.org_id,
user_id: p.user_id,
role: DefaultOrgRole::Owner,
joined_at: p.joined_at,
},
)
.await
.unwrap();
let membership = mem_manager
.add_member(&org.id, "member_1", "owner", |p| TestMembership {
org_id: p.org_id,
user_id: p.user_id,
role: DefaultOrgRole::Member,
joined_at: p.joined_at,
})
.await
.unwrap();
assert_eq!(membership.role, DefaultOrgRole::Member);
let count = mem_manager.count_members(&org.id).await.unwrap();
assert_eq!(count, 2);
mem_manager
.remove_member(&org.id, "member_1", "owner")
.await
.unwrap();
assert!(!mem_manager.is_member(&org.id, "member_1").await.unwrap());
}
#[tokio::test]
async fn test_cannot_remove_owner() {
let store = InMemoryOrgStore::new();
let org_manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let mem_manager = MembershipManager::new(store.clone(), UnlimitedSeats);
let org = org_manager
.create(
"owner",
"Test Org",
None,
"owner@example.com",
|p| TestOrganization {
id: p.id,
name: p.name,
slug: p.slug,
owner_id: p.owner_id,
contact_email: p.contact_email,
created_at: p.created_at,
updated_at: p.created_at,
},
|p| TestMembership {
org_id: p.org_id,
user_id: p.user_id,
role: DefaultOrgRole::Owner,
joined_at: p.joined_at,
},
)
.await
.unwrap();
let result = mem_manager.remove_member(&org.id, "owner", "owner").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_slug_uniqueness() {
let store = InMemoryOrgStore::new();
let manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let _org1 = manager
.create(
"user_1",
"First Org",
Some("my-slug"),
"first@example.com",
|p| TestOrganization {
id: p.id,
name: p.name,
slug: p.slug,
owner_id: p.owner_id,
contact_email: p.contact_email,
created_at: p.created_at,
updated_at: p.created_at,
},
|p| TestMembership {
org_id: p.org_id,
user_id: p.user_id,
role: DefaultOrgRole::Owner,
joined_at: p.joined_at,
},
)
.await
.unwrap();
let result = manager
.create(
"user_2",
"Second Org",
Some("my-slug"),
"second@example.com",
|p| TestOrganization {
id: p.id,
name: p.name,
slug: p.slug,
owner_id: p.owner_id,
contact_email: p.contact_email,
created_at: p.created_at,
updated_at: p.created_at,
},
|p| TestMembership {
org_id: p.org_id,
user_id: p.user_id,
role: DefaultOrgRole::Owner,
joined_at: p.joined_at,
},
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_create_org_max_orgs_limit() {
let store = InMemoryOrgStore::new();
let config = OrganizationConfig::default().max_orgs_per_user(Some(1));
let manager =
OrganizationManager::new(store.clone(), store.clone(), UnlimitedSeats, config);
let _org1 = manager
.create(
"user_1",
"Org 1",
Some("org-1"),
"test@example.com",
make_org,
|p| make_membership(p, DefaultOrgRole::Owner),
)
.await
.unwrap();
let result = manager
.create(
"user_1",
"Org 2",
Some("org-2"),
"test@example.com",
make_org,
|p| make_membership(p, DefaultOrgRole::Owner),
)
.await;
assert!(matches!(
result.unwrap_err(),
OrganizationError::MaxOrgsReached { .. }
));
}
#[tokio::test]
async fn test_create_org_creation_disabled() {
let store = InMemoryOrgStore::new();
let config = OrganizationConfig::default().allow_user_creation(false);
let manager =
OrganizationManager::new(store.clone(), store.clone(), UnlimitedSeats, config);
let result = manager
.create(
"user_1",
"My Org",
None,
"test@example.com",
make_org,
|p| make_membership(p, DefaultOrgRole::Owner),
)
.await;
assert!(matches!(
result.unwrap_err(),
OrganizationError::InsufficientPermission { .. }
));
}
#[tokio::test]
async fn test_update_org_requires_permission() {
let store = InMemoryOrgStore::new();
let manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let org = manager
.create(
"owner",
"Test Org",
None,
"owner@example.com",
make_org,
|p| make_membership(p, DefaultOrgRole::Owner),
)
.await
.unwrap();
store.insert_membership(TestMembership {
org_id: org.id.clone(),
user_id: "member".to_string(),
role: DefaultOrgRole::Member,
joined_at: 0,
});
let result = manager
.update(&org.id, "member", |mut o| {
o.name = "New Name".to_string();
o
})
.await;
assert!(matches!(
result.unwrap_err(),
OrganizationError::InsufficientPermission { .. }
));
}
#[tokio::test]
async fn test_delete_org_requires_owner() {
let store = InMemoryOrgStore::new();
let manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let org = manager
.create(
"owner",
"Test Org",
None,
"owner@example.com",
make_org,
|p| make_membership(p, DefaultOrgRole::Owner),
)
.await
.unwrap();
store.insert_membership(TestMembership {
org_id: org.id.clone(),
user_id: "admin".to_string(),
role: DefaultOrgRole::Admin,
joined_at: 0,
});
let result = manager.delete(&org.id, "admin").await;
assert!(matches!(
result.unwrap_err(),
OrganizationError::InsufficientPermission { .. }
));
manager.delete(&org.id, "owner").await.unwrap();
}
#[tokio::test]
async fn test_add_member_requires_permission() {
let store = InMemoryOrgStore::new();
let org_manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let mem_manager = MembershipManager::new(store.clone(), UnlimitedSeats);
let org = org_manager
.create(
"owner",
"Test Org",
None,
"owner@example.com",
make_org,
|p| make_membership(p, DefaultOrgRole::Owner),
)
.await
.unwrap();
store.insert_membership(TestMembership {
org_id: org.id.clone(),
user_id: "member".to_string(),
role: DefaultOrgRole::Member,
joined_at: 0,
});
let result = mem_manager
.add_member(&org.id, "new_user", "member", |p| {
make_membership(p, DefaultOrgRole::Member)
})
.await;
assert!(matches!(
result.unwrap_err(),
OrganizationError::InsufficientPermission { .. }
));
}
#[tokio::test]
async fn test_add_member_already_member() {
let store = InMemoryOrgStore::new();
let org_manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let mem_manager = MembershipManager::new(store.clone(), UnlimitedSeats);
let org = org_manager
.create(
"owner",
"Test Org",
None,
"owner@example.com",
make_org,
|p| make_membership(p, DefaultOrgRole::Owner),
)
.await
.unwrap();
let result = mem_manager
.add_member(&org.id, "owner", "owner", |p| {
make_membership(p, DefaultOrgRole::Member)
})
.await;
assert!(matches!(
result.unwrap_err(),
OrganizationError::AlreadyMember
));
}
#[tokio::test]
async fn test_add_member_seat_limit() {
let store = InMemoryOrgStore::new();
let org_manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let mem_manager = MembershipManager::new(store.clone(), LimitedSeats(1));
let org = org_manager
.create(
"owner",
"Test Org",
None,
"owner@example.com",
make_org,
|p| make_membership(p, DefaultOrgRole::Owner),
)
.await
.unwrap();
let result = mem_manager
.add_member(&org.id, "new_user", "owner", |p| {
make_membership(p, DefaultOrgRole::Member)
})
.await;
assert!(matches!(
result.unwrap_err(),
OrganizationError::SeatLimitReached { .. }
));
}
#[tokio::test]
async fn test_remove_member_requires_permission() {
let store = InMemoryOrgStore::new();
let org_manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let mem_manager = MembershipManager::new(store.clone(), UnlimitedSeats);
let org = org_manager
.create(
"owner",
"Test Org",
None,
"owner@example.com",
make_org,
|p| make_membership(p, DefaultOrgRole::Owner),
)
.await
.unwrap();
store.insert_membership(TestMembership {
org_id: org.id.clone(),
user_id: "member1".to_string(),
role: DefaultOrgRole::Member,
joined_at: 0,
});
store.insert_membership(TestMembership {
org_id: org.id.clone(),
user_id: "member2".to_string(),
role: DefaultOrgRole::Member,
joined_at: 0,
});
let result = mem_manager
.remove_member(&org.id, "member2", "member1")
.await;
assert!(matches!(
result.unwrap_err(),
OrganizationError::InsufficientPermission { .. }
));
}
#[tokio::test]
async fn test_update_membership_cannot_promote_to_owner() {
let store = InMemoryOrgStore::new();
let org_manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let mem_manager = MembershipManager::new(store.clone(), UnlimitedSeats);
let org = org_manager
.create(
"owner",
"Test Org",
None,
"owner@example.com",
make_org,
|p| make_membership(p, DefaultOrgRole::Owner),
)
.await
.unwrap();
mem_manager
.add_member(&org.id, "member", "owner", |p| {
make_membership(p, DefaultOrgRole::Member)
})
.await
.unwrap();
let result = mem_manager
.update_membership(&org.id, "member", "owner", |m| TestMembership {
role: DefaultOrgRole::Owner,
..m.clone()
})
.await;
assert!(matches!(
result.unwrap_err(),
OrganizationError::InsufficientPermission { .. }
));
}
#[tokio::test]
async fn test_transfer_ownership_requires_permission() {
let store = InMemoryOrgStore::new();
let org_manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let mem_manager = MembershipManager::new(store.clone(), UnlimitedSeats);
let org = org_manager
.create(
"owner",
"Test Org",
None,
"owner@example.com",
make_org,
|p| make_membership(p, DefaultOrgRole::Owner),
)
.await
.unwrap();
store.insert_membership(TestMembership {
org_id: org.id.clone(),
user_id: "admin".to_string(),
role: DefaultOrgRole::Admin,
joined_at: 0,
});
store.insert_membership(TestMembership {
org_id: org.id.clone(),
user_id: "member".to_string(),
role: DefaultOrgRole::Member,
joined_at: 0,
});
let result = mem_manager
.transfer_ownership(
&org.id,
"member",
"admin",
|m| TestMembership {
role: DefaultOrgRole::Owner,
..m.clone()
},
|m| TestMembership {
role: DefaultOrgRole::Admin,
..m.clone()
},
)
.await;
assert!(matches!(
result.unwrap_err(),
OrganizationError::InsufficientPermission { .. }
));
}
#[tokio::test]
async fn test_transfer_ownership_target_must_be_member() {
let store = InMemoryOrgStore::new();
let org_manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let mem_manager = MembershipManager::new(store.clone(), UnlimitedSeats);
let org = org_manager
.create(
"owner",
"Test Org",
None,
"owner@example.com",
make_org,
|p| make_membership(p, DefaultOrgRole::Owner),
)
.await
.unwrap();
let result = mem_manager
.transfer_ownership(
&org.id,
"non_member",
"owner",
|m| TestMembership {
role: DefaultOrgRole::Owner,
..m.clone()
},
|m| TestMembership {
role: DefaultOrgRole::Admin,
..m.clone()
},
)
.await;
assert!(matches!(result.unwrap_err(), OrganizationError::NotMember));
}
#[tokio::test]
async fn test_leave_owner_cannot_leave() {
let store = InMemoryOrgStore::new();
let org_manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let mem_manager = MembershipManager::new(store.clone(), UnlimitedSeats);
let org = org_manager
.create(
"owner",
"Test Org",
None,
"owner@example.com",
make_org,
|p| make_membership(p, DefaultOrgRole::Owner),
)
.await
.unwrap();
let result = mem_manager.leave(&org.id, "owner").await;
assert!(matches!(
result.unwrap_err(),
OrganizationError::CannotRemoveOwner
));
}
#[tokio::test]
async fn test_member_can_leave() {
let store = InMemoryOrgStore::new();
let org_manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let mem_manager = MembershipManager::new(store.clone(), UnlimitedSeats);
let org = org_manager
.create(
"owner",
"Test Org",
None,
"owner@example.com",
make_org,
|p| make_membership(p, DefaultOrgRole::Owner),
)
.await
.unwrap();
mem_manager
.add_member(&org.id, "member", "owner", |p| {
make_membership(p, DefaultOrgRole::Member)
})
.await
.unwrap();
mem_manager.leave(&org.id, "member").await.unwrap();
assert!(!mem_manager.is_member(&org.id, "member").await.unwrap());
}
#[tokio::test]
async fn test_invite_requires_permission() {
let store = InMemoryOrgStore::new();
let org_manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let inv_manager = InvitationManager::new(
store.clone(),
store.clone(),
store.clone(),
UnlimitedSeats,
InvitationConfig::default(),
);
let org = org_manager
.create(
"owner",
"Test Org",
None,
"owner@example.com",
make_org,
|p| make_membership(p, DefaultOrgRole::Owner),
)
.await
.unwrap();
store.insert_membership(TestMembership {
org_id: org.id.clone(),
user_id: "member".to_string(),
role: DefaultOrgRole::Member,
joined_at: 0,
});
let result = inv_manager
.invite(&org.id, "invitee@example.com", "member", |p| {
TestInvitation {
id: p.id,
org_id: p.org_id,
email: p.email,
role: DefaultOrgRole::Member,
invited_by: p.invited_by,
token: p.token,
expires_at: p.expires_at,
created_at: p.created_at,
accepted: false,
revoked: false,
}
})
.await;
assert!(matches!(
result.unwrap_err(),
OrganizationError::InsufficientPermission { .. }
));
}
#[tokio::test]
async fn test_invite_invalid_email() {
let store = InMemoryOrgStore::new();
let org_manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let inv_manager = InvitationManager::new(
store.clone(),
store.clone(),
store.clone(),
UnlimitedSeats,
InvitationConfig::default(),
);
let org = org_manager
.create(
"owner",
"Test Org",
None,
"owner@example.com",
make_org,
|p| make_membership(p, DefaultOrgRole::Owner),
)
.await
.unwrap();
let result = inv_manager
.invite(&org.id, "not-an-email", "owner", |p| TestInvitation {
id: p.id,
org_id: p.org_id,
email: p.email,
role: DefaultOrgRole::Member,
invited_by: p.invited_by,
token: p.token,
expires_at: p.expires_at,
created_at: p.created_at,
accepted: false,
revoked: false,
})
.await;
assert!(matches!(
result.unwrap_err(),
OrganizationError::InvalidEmail { .. }
));
}
#[tokio::test]
async fn test_invite_max_pending_reached() {
let store = InMemoryOrgStore::new();
let org_manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let inv_manager = InvitationManager::new(
store.clone(),
store.clone(),
store.clone(),
UnlimitedSeats,
InvitationConfig::default().max_pending_per_org(1),
);
let org = org_manager
.create(
"owner",
"Test Org",
None,
"owner@example.com",
make_org,
|p| make_membership(p, DefaultOrgRole::Owner),
)
.await
.unwrap();
inv_manager
.invite(&org.id, "first@example.com", "owner", |p| TestInvitation {
id: p.id,
org_id: p.org_id,
email: p.email,
role: DefaultOrgRole::Member,
invited_by: p.invited_by,
token: p.token,
expires_at: p.expires_at,
created_at: p.created_at,
accepted: false,
revoked: false,
})
.await
.unwrap();
let result = inv_manager
.invite(&org.id, "second@example.com", "owner", |p| TestInvitation {
id: p.id,
org_id: p.org_id,
email: p.email,
role: DefaultOrgRole::Member,
invited_by: p.invited_by,
token: p.token,
expires_at: p.expires_at,
created_at: p.created_at,
accepted: false,
revoked: false,
})
.await;
assert!(matches!(
result.unwrap_err(),
OrganizationError::MaxPendingInvitationsReached { .. }
));
}
#[tokio::test]
async fn test_accept_expired_invitation() {
let store = InMemoryOrgStore::new();
let org_manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let inv_manager = InvitationManager::new(
store.clone(),
store.clone(),
store.clone(),
UnlimitedSeats,
InvitationConfig::default(),
);
let org = org_manager
.create(
"owner",
"Test Org",
None,
"owner@example.com",
make_org,
|p| make_membership(p, DefaultOrgRole::Owner),
)
.await
.unwrap();
store.insert_invitation(TestInvitation {
id: "inv_1".to_string(),
org_id: org.id.clone(),
email: "invitee@example.com".to_string(),
role: DefaultOrgRole::Member,
invited_by: "owner".to_string(),
token: "expired_token".to_string(),
expires_at: 0, created_at: 0,
accepted: false,
revoked: false,
});
let result = inv_manager
.accept("expired_token", "invitee", |inv, p| TestMembership {
org_id: p.org_id,
user_id: p.user_id,
role: inv.role,
joined_at: p.joined_at,
})
.await;
assert!(matches!(
result.unwrap_err(),
OrganizationError::InvitationExpired
));
}
#[tokio::test]
async fn test_accept_revoked_invitation() {
let store = InMemoryOrgStore::new();
let org_manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let inv_manager = InvitationManager::new(
store.clone(),
store.clone(),
store.clone(),
UnlimitedSeats,
InvitationConfig::default(),
);
let org = org_manager
.create(
"owner",
"Test Org",
None,
"owner@example.com",
make_org,
|p| make_membership(p, DefaultOrgRole::Owner),
)
.await
.unwrap();
let future_time = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
+ 86400;
store.insert_invitation(TestInvitation {
id: "inv_1".to_string(),
org_id: org.id.clone(),
email: "invitee@example.com".to_string(),
role: DefaultOrgRole::Member,
invited_by: "owner".to_string(),
token: "revoked_token".to_string(),
expires_at: future_time,
created_at: 0,
accepted: false,
revoked: true, });
let result = inv_manager
.accept("revoked_token", "invitee", |inv, p| TestMembership {
org_id: p.org_id,
user_id: p.user_id,
role: inv.role,
joined_at: p.joined_at,
})
.await;
assert!(matches!(
result.unwrap_err(),
OrganizationError::InvalidToken
));
}
#[tokio::test]
async fn test_revoke_requires_permission() {
let store = InMemoryOrgStore::new();
let org_manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let inv_manager = InvitationManager::new(
store.clone(),
store.clone(),
store.clone(),
UnlimitedSeats,
InvitationConfig::default(),
);
let org = org_manager
.create(
"owner",
"Test Org",
None,
"owner@example.com",
make_org,
|p| make_membership(p, DefaultOrgRole::Owner),
)
.await
.unwrap();
let invitation = inv_manager
.invite(&org.id, "invitee@example.com", "owner", |p| {
TestInvitation {
id: p.id,
org_id: p.org_id,
email: p.email,
role: DefaultOrgRole::Member,
invited_by: p.invited_by,
token: p.token,
expires_at: p.expires_at,
created_at: p.created_at,
accepted: false,
revoked: false,
}
})
.await
.unwrap();
store.insert_membership(TestMembership {
org_id: org.id.clone(),
user_id: "member".to_string(),
role: DefaultOrgRole::Member,
joined_at: 0,
});
let result = inv_manager.revoke(&invitation.id, "member").await;
assert!(matches!(
result.unwrap_err(),
OrganizationError::InsufficientPermission { .. }
));
}
#[tokio::test]
async fn test_cleanup_expired() {
let store = InMemoryOrgStore::new();
let org_manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let inv_manager = InvitationManager::new(
store.clone(),
store.clone(),
store.clone(),
UnlimitedSeats,
InvitationConfig::default(),
);
let org = org_manager
.create(
"owner",
"Test Org",
None,
"owner@example.com",
make_org,
|p| make_membership(p, DefaultOrgRole::Owner),
)
.await
.unwrap();
for i in 0..3 {
store.insert_invitation(TestInvitation {
id: format!("inv_{i}"),
org_id: org.id.clone(),
email: format!("user{i}@example.com"),
role: DefaultOrgRole::Member,
invited_by: "owner".to_string(),
token: format!("token_{i}"),
expires_at: 0, created_at: 0,
accepted: false,
revoked: false,
});
}
let count = inv_manager.cleanup_expired().await.unwrap();
assert_eq!(count, 3);
let pending = inv_manager.list_pending(&org.id).await.unwrap();
assert!(pending.is_empty());
}
#[tokio::test]
async fn test_successful_invitation_flow() {
let store = InMemoryOrgStore::new();
let org_manager = OrganizationManager::new(
store.clone(),
store.clone(),
UnlimitedSeats,
OrganizationConfig::default(),
);
let inv_manager = InvitationManager::new(
store.clone(),
store.clone(),
store.clone(),
UnlimitedSeats,
InvitationConfig::default(),
);
let mem_manager = MembershipManager::new(store.clone(), UnlimitedSeats);
let org = org_manager
.create(
"owner",
"Test Org",
None,
"owner@example.com",
make_org,
|p| make_membership(p, DefaultOrgRole::Owner),
)
.await
.unwrap();
let invitation = inv_manager
.invite(&org.id, "invitee@example.com", "owner", |p| {
TestInvitation {
id: p.id,
org_id: p.org_id,
email: p.email,
role: DefaultOrgRole::Member,
invited_by: p.invited_by,
token: p.token,
expires_at: p.expires_at,
created_at: p.created_at,
accepted: false,
revoked: false,
}
})
.await
.unwrap();
let membership = inv_manager
.accept(&invitation.token, "invitee", |inv, p| TestMembership {
org_id: p.org_id,
user_id: p.user_id,
role: inv.role,
joined_at: p.joined_at,
})
.await
.unwrap();
assert_eq!(membership.role, DefaultOrgRole::Member);
assert!(mem_manager.is_member(&org.id, "invitee").await.unwrap());
}
}