test(spaces): add comprehensive unit and integration tests

- 12 event content type serde tests (all pass)
- Service lookup unit tests (power level calc, role qualification)
- Enforcement scenario integration tests (access lifecycle, cross-space isolation)
- Cache consistency tests (populate, invalidate, clear)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ember33 2026-03-18 09:27:22 +01:00
parent f143248636
commit 0a52a928dc
5 changed files with 607 additions and 0 deletions

View file

@ -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"]);
}
}

View file

@ -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<OwnedRoomId, BTreeMap<String, RoleDefinition>>,
user_roles: HashMap<OwnedRoomId, HashMap<OwnedUserId, HashSet<String>>>,
room_requirements: HashMap<OwnedRoomId, HashMap<OwnedRoomId, HashSet<String>>>,
room_to_space: HashMap<OwnedRoomId, OwnedRoomId>,
}
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<String, RoleDefinition>) {
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<String>,
) {
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<i64> {
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));
}

View file

@ -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<String> = 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<String> = 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<String> = 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<String>>> = 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<String, HashSet<String>> = 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<String, HashSet<String>> = 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()));
}

View file

@ -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,

View file

@ -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<i64>)]) -> BTreeMap<String, RoleDefinition> {
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<String> {
roles.iter().map(|s| (*s).to_owned()).collect()
}
pub fn make_requirements(roles: &[&str]) -> HashSet<String> {
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<String> = 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<String> = 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<String> = HashSet::new();
assert!(!required.iter().all(|r| user_assigned.contains(r)));
}
#[test]
fn qualifies_empty_requirements_empty_roles() {
let required: HashSet<String> = HashSet::new();
let user_assigned: HashSet<String> = HashSet::new();
assert!(required.iter().all(|r| user_assigned.contains(r)));
}
#[test]
fn room_to_space_lookup() {
let mut room_to_space: HashMap<OwnedRoomId, OwnedRoomId> = 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));
}