diff --git a/src/core/matrix/space_roles.rs b/src/core/matrix/space_roles.rs index 560e3f00..aaadcf9f 100644 --- a/src/core/matrix/space_roles.rs +++ b/src/core/matrix/space_roles.rs @@ -97,4 +97,79 @@ mod tests { let content: SpaceRolesEventContent = serde_json::from_str(json).unwrap(); assert!(content.roles.is_empty()); } + + #[test] + fn deserialize_role_with_power_level() { + let json = r#"{"description":"Admin","power_level":100}"#; + let role: RoleDefinition = serde_json::from_str(json).unwrap(); + assert_eq!(role.description, "Admin"); + assert_eq!(role.power_level, Some(100)); + } + + #[test] + fn deserialize_role_without_power_level() { + let json = r#"{"description":"NSFW access"}"#; + let role: RoleDefinition = serde_json::from_str(json).unwrap(); + assert!(role.power_level.is_none()); + } + + #[test] + fn power_level_omitted_in_serialization_when_none() { + let role = RoleDefinition { + description: "Test".to_owned(), + power_level: None, + }; + let json = serde_json::to_string(&role).unwrap(); + assert!(!json.contains("power_level")); + } + + #[test] + fn empty_member_roles() { + let content = SpaceRoleMemberEventContent { roles: vec![] }; + let json = serde_json::to_string(&content).unwrap(); + let deserialized: SpaceRoleMemberEventContent = serde_json::from_str(&json).unwrap(); + assert!(deserialized.roles.is_empty()); + } + + #[test] + fn empty_room_requirements() { + let content = SpaceRoleRoomEventContent { + required_roles: vec![], + }; + let json = serde_json::to_string(&content).unwrap(); + let deserialized: SpaceRoleRoomEventContent = serde_json::from_str(&json).unwrap(); + assert!(deserialized.required_roles.is_empty()); + } + + #[test] + fn roles_ordering_preserved() { + let mut roles = BTreeMap::new(); + roles.insert("zebra".to_owned(), RoleDefinition { + description: "Z".to_owned(), + power_level: None, + }); + roles.insert("alpha".to_owned(), RoleDefinition { + description: "A".to_owned(), + power_level: None, + }); + let content = SpaceRolesEventContent { roles }; + let json = serde_json::to_string(&content).unwrap(); + let deserialized: SpaceRolesEventContent = serde_json::from_str(&json).unwrap(); + let keys: Vec<_> = deserialized.roles.keys().collect(); + assert_eq!(keys, vec!["alpha", "zebra"]); + } + + #[test] + fn negative_power_level() { + let json = r#"{"description":"Restricted","power_level":-10}"#; + let role: RoleDefinition = serde_json::from_str(json).unwrap(); + assert_eq!(role.power_level, Some(-10)); + } + + #[test] + fn unknown_fields_ignored() { + let json = r#"{"roles":["nsfw"],"extra_field":"ignored"}"#; + let content: SpaceRoleMemberEventContent = serde_json::from_str(json).unwrap(); + assert_eq!(content.roles, vec!["nsfw"]); + } } diff --git a/src/service/rooms/roles/cache_tests.rs b/src/service/rooms/roles/cache_tests.rs new file mode 100644 index 00000000..53f152ca --- /dev/null +++ b/src/service/rooms/roles/cache_tests.rs @@ -0,0 +1,230 @@ +//! Tests for cache consistency of the space roles index structures. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use conduwuit_core::matrix::space_roles::RoleDefinition; +use ruma::{room_id, user_id, OwnedRoomId, OwnedUserId}; + +use super::tests::{make_requirements, make_roles, make_user_roles}; + +/// Simulates the full cache state for a space. +struct MockCache { + roles: HashMap>, + user_roles: HashMap>>, + room_requirements: HashMap>>, + room_to_space: HashMap, +} + +impl MockCache { + fn new() -> Self { + Self { + roles: HashMap::new(), + user_roles: HashMap::new(), + room_requirements: HashMap::new(), + room_to_space: HashMap::new(), + } + } + + fn add_space(&mut self, space: OwnedRoomId, roles: BTreeMap) { + self.roles.insert(space, roles); + } + + fn add_child(&mut self, space: &OwnedRoomId, child: OwnedRoomId) { + self.room_to_space.insert(child, space.clone()); + } + + fn assign_role(&mut self, space: &OwnedRoomId, user: OwnedUserId, role: String) { + self.user_roles + .entry(space.clone()) + .or_default() + .entry(user) + .or_default() + .insert(role); + } + + fn revoke_role(&mut self, space: &OwnedRoomId, user: &OwnedUserId, role: &str) { + if let Some(space_users) = self.user_roles.get_mut(space) { + if let Some(user_roles) = space_users.get_mut(user) { + user_roles.remove(role); + } + } + } + + fn set_room_requirements( + &mut self, + space: &OwnedRoomId, + room: OwnedRoomId, + reqs: HashSet, + ) { + self.room_requirements + .entry(space.clone()) + .or_default() + .insert(room, reqs); + } + + fn user_qualifies( + &self, + space: &OwnedRoomId, + room: &OwnedRoomId, + user: &OwnedUserId, + ) -> bool { + let reqs = self + .room_requirements + .get(space) + .and_then(|r| r.get(room)); + + match reqs { + | None => true, + | Some(required) if required.is_empty() => true, + | Some(required) => { + let assigned = self.user_roles.get(space).and_then(|u| u.get(user)); + match assigned { + | None => false, + | Some(roles) => required.iter().all(|r| roles.contains(r)), + } + }, + } + } + + fn get_power_level(&self, space: &OwnedRoomId, user: &OwnedUserId) -> Option { + let role_defs = self.roles.get(space)?; + let assigned = self.user_roles.get(space)?.get(user)?; + assigned + .iter() + .filter_map(|r| role_defs.get(r)?.power_level) + .max() + } + + fn clear(&mut self) { + self.roles.clear(); + self.user_roles.clear(); + self.room_requirements.clear(); + self.room_to_space.clear(); + } +} + +#[test] +fn cache_populate_and_lookup() { + let mut cache = MockCache::new(); + let space = room_id!("!space:example.com").to_owned(); + let child = room_id!("!child:example.com").to_owned(); + let alice = user_id!("@alice:example.com").to_owned(); + + cache.add_space( + space.clone(), + make_roles(&[("admin", Some(100)), ("nsfw", None)]), + ); + cache.add_child(&space, child.clone()); + cache.assign_role(&space, alice.clone(), "nsfw".to_owned()); + cache.set_room_requirements(&space, child.clone(), make_requirements(&["nsfw"])); + + assert!(cache.user_qualifies(&space, &child, &alice)); + assert_eq!(cache.get_power_level(&space, &alice), None); // nsfw has no PL +} + +#[test] +fn cache_invalidation_on_role_revoke() { + let mut cache = MockCache::new(); + let space = room_id!("!space:example.com").to_owned(); + let child = room_id!("!nsfw:example.com").to_owned(); + let alice = user_id!("@alice:example.com").to_owned(); + + cache.add_space(space.clone(), make_roles(&[("nsfw", None)])); + cache.assign_role(&space, alice.clone(), "nsfw".to_owned()); + cache.set_room_requirements(&space, child.clone(), make_requirements(&["nsfw"])); + + assert!(cache.user_qualifies(&space, &child, &alice)); + + // Revoke + cache.revoke_role(&space, &alice, "nsfw"); + assert!(!cache.user_qualifies(&space, &child, &alice)); +} + +#[test] +fn cache_invalidation_on_requirement_change() { + let mut cache = MockCache::new(); + let space = room_id!("!space:example.com").to_owned(); + let child = room_id!("!room:example.com").to_owned(); + let alice = user_id!("@alice:example.com").to_owned(); + + cache.add_space( + space.clone(), + make_roles(&[("nsfw", None), ("vip", None)]), + ); + cache.assign_role(&space, alice.clone(), "vip".to_owned()); + cache.set_room_requirements(&space, child.clone(), make_requirements(&["vip"])); + + assert!(cache.user_qualifies(&space, &child, &alice)); + + // Add nsfw requirement + cache.set_room_requirements( + &space, + child.clone(), + make_requirements(&["vip", "nsfw"]), + ); + assert!(!cache.user_qualifies(&space, &child, &alice)); +} + +#[test] +fn cache_clear_empties_all() { + let mut cache = MockCache::new(); + let space = room_id!("!space:example.com").to_owned(); + cache.add_space(space.clone(), make_roles(&[("admin", Some(100))])); + cache.assign_role( + &space, + user_id!("@alice:example.com").to_owned(), + "admin".to_owned(), + ); + + cache.clear(); + + assert!(cache.roles.is_empty()); + assert!(cache.user_roles.is_empty()); + assert!(cache.room_requirements.is_empty()); + assert!(cache.room_to_space.is_empty()); +} + +#[test] +fn cache_reverse_lookup_consistency() { + let mut cache = MockCache::new(); + let space = room_id!("!space:example.com").to_owned(); + let child1 = room_id!("!child1:example.com").to_owned(); + let child2 = room_id!("!child2:example.com").to_owned(); + + cache.add_child(&space, child1.clone()); + cache.add_child(&space, child2.clone()); + + assert_eq!(cache.room_to_space.get(&child1), Some(&space)); + assert_eq!(cache.room_to_space.get(&child2), Some(&space)); + assert_eq!( + cache.room_to_space.get(room_id!("!unknown:example.com")), + None + ); +} + +#[test] +fn cache_power_level_updates_on_role_change() { + let mut cache = MockCache::new(); + let space = room_id!("!space:example.com").to_owned(); + let alice = user_id!("@alice:example.com").to_owned(); + + cache.add_space( + space.clone(), + make_roles(&[("admin", Some(100)), ("mod", Some(50))]), + ); + + // No roles -> no PL + assert_eq!(cache.get_power_level(&space, &alice), None); + + // Assign mod -> PL 50 + cache.assign_role(&space, alice.clone(), "mod".to_owned()); + assert_eq!(cache.get_power_level(&space, &alice), Some(50)); + + // Also assign admin -> PL 100 (highest wins) + cache.assign_role(&space, alice.clone(), "admin".to_owned()); + assert_eq!(cache.get_power_level(&space, &alice), Some(100)); + + // Revoke admin -> back to PL 50 + cache.revoke_role(&space, &alice, "admin"); + assert_eq!(cache.get_power_level(&space, &alice), Some(50)); +} diff --git a/src/service/rooms/roles/integration_tests.rs b/src/service/rooms/roles/integration_tests.rs new file mode 100644 index 00000000..15179ebc --- /dev/null +++ b/src/service/rooms/roles/integration_tests.rs @@ -0,0 +1,143 @@ +use std::collections::{HashMap, HashSet}; + +use ruma::{room_id, user_id}; + +use super::tests::{make_requirements, make_roles, make_user_roles}; + +#[test] +fn scenario_user_gains_and_loses_access() { + let room_reqs = make_requirements(&["nsfw"]); + + let no_roles: HashSet = HashSet::new(); + assert!(!room_reqs.iter().all(|r| no_roles.contains(r))); + + let with_nsfw = make_user_roles(&["nsfw"]); + assert!(room_reqs.iter().all(|r| with_nsfw.contains(r))); + + let no_roles: HashSet = HashSet::new(); + assert!(!room_reqs.iter().all(|r| no_roles.contains(r))); +} + +#[test] +fn scenario_room_adds_requirement_existing_members_checked() { + let alice_roles = make_user_roles(&["vip"]); + let bob_roles = make_user_roles(&["vip", "nsfw"]); + + let empty_reqs: HashSet = HashSet::new(); + assert!(empty_reqs.iter().all(|r| alice_roles.contains(r))); + assert!(empty_reqs.iter().all(|r| bob_roles.contains(r))); + + let new_reqs = make_requirements(&["nsfw"]); + assert!(!new_reqs.iter().all(|r| alice_roles.contains(r))); + assert!(new_reqs.iter().all(|r| bob_roles.contains(r))); +} + +#[test] +fn scenario_multiple_rooms_different_requirements() { + let alice_roles = make_user_roles(&["nsfw", "vip"]); + let bob_roles = make_user_roles(&["nsfw"]); + + let nsfw_reqs = make_requirements(&["nsfw"]); + let vip_reqs = make_requirements(&["vip"]); + let both_reqs = make_requirements(&["nsfw", "vip"]); + + assert!(nsfw_reqs.iter().all(|r| alice_roles.contains(r))); + assert!(vip_reqs.iter().all(|r| alice_roles.contains(r))); + assert!(both_reqs.iter().all(|r| alice_roles.contains(r))); + + assert!(nsfw_reqs.iter().all(|r| bob_roles.contains(r))); + assert!(!vip_reqs.iter().all(|r| bob_roles.contains(r))); + assert!(!both_reqs.iter().all(|r| bob_roles.contains(r))); +} + +#[test] +fn scenario_power_level_cascading_highest_wins() { + let roles = make_roles(&[ + ("admin", Some(100)), + ("mod", Some(50)), + ("helper", Some(25)), + ]); + + let admin_mod = make_user_roles(&["admin", "mod"]); + assert_eq!( + admin_mod + .iter() + .filter_map(|r| roles.get(r)?.power_level) + .max(), + Some(100) + ); + + let helper = make_user_roles(&["helper"]); + assert_eq!( + helper + .iter() + .filter_map(|r| roles.get(r)?.power_level) + .max(), + Some(25) + ); +} + +#[test] +fn scenario_roles_do_not_cascade_across_spaces() { + let space_a = room_id!("!space_a:example.com"); + let space_b = room_id!("!space_b:example.com"); + let alice = user_id!("@alice:example.com"); + + let mut space_user_roles: HashMap<_, HashMap<_, HashSet>> = HashMap::new(); + space_user_roles + .entry(space_a.to_owned()) + .or_default() + .insert(alice.to_owned(), make_user_roles(&["nsfw"])); + + let alice_roles_in_b = space_user_roles + .get(space_b) + .and_then(|users| users.get(alice)); + assert!(alice_roles_in_b.is_none()); +} + +#[test] +fn scenario_identify_auto_join_candidates() { + let alice_roles = make_user_roles(&["nsfw", "vip"]); + + let mut room_reqs: HashMap> = HashMap::new(); + room_reqs.insert("general".to_owned(), HashSet::new()); + room_reqs.insert("nsfw-chat".to_owned(), make_requirements(&["nsfw"])); + room_reqs.insert("vip-lounge".to_owned(), make_requirements(&["vip"])); + room_reqs.insert("staff-only".to_owned(), make_requirements(&["staff"])); + + let qualifying: Vec<_> = room_reqs + .iter() + .filter(|(_, reqs)| reqs.iter().all(|r| alice_roles.contains(r))) + .map(|(name, _)| name.clone()) + .collect(); + + assert!(qualifying.contains(&"general".to_owned())); + assert!(qualifying.contains(&"nsfw-chat".to_owned())); + assert!(qualifying.contains(&"vip-lounge".to_owned())); + assert!(!qualifying.contains(&"staff-only".to_owned())); +} + +#[test] +fn scenario_identify_kick_candidates_after_role_revocation() { + let alice_roles_after = make_user_roles(&["vip"]); + + let mut rooms: HashMap> = HashMap::new(); + rooms.insert("general".to_owned(), HashSet::new()); + rooms.insert("nsfw-chat".to_owned(), make_requirements(&["nsfw"])); + rooms.insert("vip-lounge".to_owned(), make_requirements(&["vip"])); + rooms.insert( + "nsfw-vip".to_owned(), + make_requirements(&["nsfw", "vip"]), + ); + + let kick_from: Vec<_> = rooms + .iter() + .filter(|(_, reqs)| !reqs.iter().all(|r| alice_roles_after.contains(r))) + .map(|(name, _)| name.clone()) + .collect(); + + assert!(kick_from.contains(&"nsfw-chat".to_owned())); + assert!(kick_from.contains(&"nsfw-vip".to_owned())); + assert!(!kick_from.contains(&"general".to_owned())); + assert!(!kick_from.contains(&"vip-lounge".to_owned())); +} diff --git a/src/service/rooms/roles/mod.rs b/src/service/rooms/roles/mod.rs index 1bc146cb..9321ba67 100644 --- a/src/service/rooms/roles/mod.rs +++ b/src/service/rooms/roles/mod.rs @@ -1,3 +1,10 @@ +#[cfg(test)] +mod cache_tests; +#[cfg(test)] +mod integration_tests; +#[cfg(test)] +mod tests; + use std::{ collections::{BTreeMap, HashMap, HashSet}, fmt::Write, diff --git a/src/service/rooms/roles/tests.rs b/src/service/rooms/roles/tests.rs new file mode 100644 index 00000000..0af8f742 --- /dev/null +++ b/src/service/rooms/roles/tests.rs @@ -0,0 +1,152 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; + +use conduwuit_core::matrix::space_roles::RoleDefinition; +use ruma::{room_id, OwnedRoomId}; + +/// Helper to build a role definitions map. +pub fn make_roles(entries: &[(&str, Option)]) -> BTreeMap { + entries + .iter() + .map(|(name, pl)| { + ( + (*name).to_owned(), + RoleDefinition { + description: format!("{name} role"), + power_level: *pl, + }, + ) + }) + .collect() +} + +pub fn make_user_roles(roles: &[&str]) -> HashSet { + roles.iter().map(|s| (*s).to_owned()).collect() +} + +pub fn make_requirements(roles: &[&str]) -> HashSet { + roles.iter().map(|s| (*s).to_owned()).collect() +} + +#[test] +fn power_level_single_role() { + let roles = make_roles(&[("admin", Some(100)), ("mod", Some(50))]); + let user_assigned = make_user_roles(&["admin"]); + let max_pl = user_assigned + .iter() + .filter_map(|r| roles.get(r)?.power_level) + .max(); + assert_eq!(max_pl, Some(100)); +} + +#[test] +fn power_level_multiple_roles_takes_highest() { + let roles = make_roles(&[ + ("admin", Some(100)), + ("mod", Some(50)), + ("helper", Some(25)), + ]); + let user_assigned = make_user_roles(&["mod", "helper"]); + let max_pl = user_assigned + .iter() + .filter_map(|r| roles.get(r)?.power_level) + .max(); + assert_eq!(max_pl, Some(50)); +} + +#[test] +fn power_level_no_power_roles() { + let roles = make_roles(&[("nsfw", None), ("vip", None)]); + let user_assigned = make_user_roles(&["nsfw", "vip"]); + let max_pl = user_assigned + .iter() + .filter_map(|r| roles.get(r)?.power_level) + .max(); + assert_eq!(max_pl, None); +} + +#[test] +fn power_level_mixed_roles() { + let roles = make_roles(&[("mod", Some(50)), ("nsfw", None)]); + let user_assigned = make_user_roles(&["mod", "nsfw"]); + let max_pl = user_assigned + .iter() + .filter_map(|r| roles.get(r)?.power_level) + .max(); + assert_eq!(max_pl, Some(50)); +} + +#[test] +fn power_level_no_roles_assigned() { + let roles = make_roles(&[("admin", Some(100))]); + let user_assigned: HashSet = HashSet::new(); + let max_pl = user_assigned + .iter() + .filter_map(|r| roles.get(r)?.power_level) + .max(); + assert_eq!(max_pl, None); +} + +#[test] +fn power_level_unknown_role_ignored() { + let roles = make_roles(&[("admin", Some(100))]); + let user_assigned = make_user_roles(&["nonexistent"]); + let max_pl = user_assigned + .iter() + .filter_map(|r| roles.get(r)?.power_level) + .max(); + assert_eq!(max_pl, None); +} + +#[test] +fn qualifies_with_all_required_roles() { + let required = make_requirements(&["nsfw", "vip"]); + let user_assigned = make_user_roles(&["nsfw", "vip", "extra"]); + assert!(required.iter().all(|r| user_assigned.contains(r))); +} + +#[test] +fn does_not_qualify_missing_one_role() { + let required = make_requirements(&["nsfw", "vip"]); + let user_assigned = make_user_roles(&["nsfw"]); + assert!(!required.iter().all(|r| user_assigned.contains(r))); +} + +#[test] +fn qualifies_with_no_requirements() { + let required: HashSet = HashSet::new(); + let user_assigned = make_user_roles(&["nsfw"]); + assert!(required.iter().all(|r| user_assigned.contains(r))); +} + +#[test] +fn does_not_qualify_with_no_roles() { + let required = make_requirements(&["nsfw"]); + let user_assigned: HashSet = HashSet::new(); + assert!(!required.iter().all(|r| user_assigned.contains(r))); +} + +#[test] +fn qualifies_empty_requirements_empty_roles() { + let required: HashSet = HashSet::new(); + let user_assigned: HashSet = HashSet::new(); + assert!(required.iter().all(|r| user_assigned.contains(r))); +} + +#[test] +fn room_to_space_lookup() { + let mut room_to_space: HashMap = HashMap::new(); + let space = room_id!("!space:example.com").to_owned(); + let child = room_id!("!child:example.com").to_owned(); + room_to_space.insert(child.clone(), space.clone()); + assert_eq!(room_to_space.get(&child), Some(&space)); + assert_eq!(room_to_space.get(room_id!("!unknown:example.com")), None); +} + +#[test] +fn default_roles_contain_admin_and_mod() { + let roles = make_roles(&[("admin", Some(100)), ("mod", Some(50))]); + assert!(roles.contains_key("admin")); + assert!(roles.contains_key("mod")); + assert_eq!(roles["admin"].power_level, Some(100)); + assert_eq!(roles["mod"].power_level, Some(50)); +}