From 84f6903c9818a9c7a01cd07092d87c6980cdd773 Mon Sep 17 00:00:00 2001 From: ember33 Date: Tue, 17 Mar 2026 16:35:31 +0100 Subject: [PATCH] docs: expand implementation plan with comprehensive testing Adds 5 new testing tasks (14-18): - Task 14: Event content type edge case unit tests - Task 15: Service lookup logic unit tests - Task 16: Admin command parsing tests - Task 17: Enforcement scenario integration tests - Task 18: Cache consistency integration tests Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-17-space-permission-cascading.md | 1033 ++++++++++++++++- 1 file changed, 1015 insertions(+), 18 deletions(-) diff --git a/docs/plans/2026-03-17-space-permission-cascading.md b/docs/plans/2026-03-17-space-permission-cascading.md index 75ae0a9e..3d5c232b 100644 --- a/docs/plans/2026-03-17-space-permission-cascading.md +++ b/docs/plans/2026-03-17-space-permission-cascading.md @@ -1132,40 +1132,1033 @@ git commit -m "feat(spaces): reject power level changes that conflict with space --- -### Task 14: Integration Testing +### Task 14: Unit Tests — Event Content Types **Files:** -- Create: `src/service/rooms/roles/tests.rs` +- Modify: `src/core/matrix/space_roles.rs` (tests already added in Task 2, expand here) -**Step 1: Write unit tests for the roles service** +**Step 1: Add edge case and validation tests** -Test the lookup methods with pre-populated cache data: +Expand the `#[cfg(test)] mod tests` in `src/core/matrix/space_roles.rs`: ```rust -#[cfg(test)] -mod tests { - // Test user_qualifies_for_room with various role combinations - // Test get_user_power_level with multiple roles - // Test cache invalidation paths - // Test default role creation +#[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() { + // BTreeMap maintains sorted order + 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"]); } ``` **Step 2: Run tests** -Run: `cargo test -p conduwuit-service roles 2>&1 | tail -20` +Run: `cargo test -p conduwuit-core space_roles 2>&1 | tail -20` Expected: All tests pass. **Step 3: Commit** ```bash -git add src/service/rooms/roles/tests.rs -git commit -m "test(spaces): add unit tests for space roles service" +git add src/core/matrix/space_roles.rs +git commit -m "test(spaces): expand unit tests for space role event content types" ``` --- -### Task 15: Documentation +### Task 15: Unit Tests — Roles Service Lookups + +**Files:** +- Create: `src/service/rooms/roles/tests.rs` +- Modify: `src/service/rooms/roles/mod.rs` (add `#[cfg(test)] mod tests;`) + +These tests operate directly on the cache structures without needing a running +server. They test the pure logic of lookups and qualification checks. + +**Step 1: Create test file with cache-based tests** + +Create `src/service/rooms/roles/tests.rs`: + +```rust +use std::collections::{BTreeMap, HashMap, HashSet}; + +use conduwuit_core::matrix::space_roles::RoleDefinition; +use ruma::{room_id, user_id, OwnedRoomId, OwnedUserId}; + +/// Helper to build a role definitions map. +fn make_roles(entries: &[(&str, Option)]) -> BTreeMap { + entries + .iter() + .map(|(name, pl)| { + ( + (*name).to_owned(), + RoleDefinition { + description: format!("{name} role"), + power_level: *pl, + }, + ) + }) + .collect() +} + +/// Helper to build a user roles set. +fn make_user_roles(roles: &[&str]) -> HashSet { + roles.iter().map(|s| (*s).to_owned()).collect() +} + +/// Helper to build room requirements set. +fn make_requirements(roles: &[&str]) -> HashSet { + roles.iter().map(|s| (*s).to_owned()).collect() +} + +// --- Power level calculation tests --- + +#[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); +} + +// --- Room qualification tests --- + +#[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(); + + // No requirements means everyone qualifies + assert!(required.iter().all(|r| user_assigned.contains(r))); +} + +// --- Reverse lookup tests --- + +#[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 + ); +} + +// --- Default roles tests --- + +#[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)); +} +``` + +**Step 2: Register the test module** + +In `src/service/rooms/roles/mod.rs`, add near the top: + +```rust +#[cfg(test)] +mod tests; +``` + +**Step 3: Run tests** + +Run: `cargo test -p conduwuit-service roles::tests 2>&1 | tail -20` +Expected: All tests pass. + +**Step 4: Commit** + +```bash +git add src/service/rooms/roles/tests.rs src/service/rooms/roles/mod.rs +git commit -m "test(spaces): add unit tests for space roles service lookups" +``` + +--- + +### Task 16: Unit Tests — Admin Commands + +**Files:** +- Create: `src/admin/space/tests.rs` + +Tests that the clap command parsing works correctly for all space role commands. +Follows the pattern in `src/admin/tests.rs`. + +**Step 1: Create admin command parsing tests** + +Create `src/admin/space/tests.rs`: + +```rust +use clap::Parser; + +use super::{SpaceCommand, SpaceRolesCommand}; +use crate::admin::AdminCommand; + +fn parse(input: &str) -> AdminCommand { + let argv = std::iter::once("admin").chain(input.split_whitespace()); + AdminCommand::parse_from(argv) +} + +#[test] +fn parse_roles_list() { + let cmd = parse("spaces roles list !space:example.com"); + assert!(matches!( + cmd, + AdminCommand::Spaces(SpaceCommand::Roles(SpaceRolesCommand::List { .. })) + )); +} + +#[test] +fn parse_roles_add_basic() { + let cmd = parse("spaces roles add !space:example.com nsfw"); + assert!(matches!( + cmd, + AdminCommand::Spaces(SpaceCommand::Roles(SpaceRolesCommand::Add { .. })) + )); +} + +#[test] +fn parse_roles_add_with_power_level() { + let cmd = parse( + "spaces roles add !space:example.com helper --description \"Helper role\" --power-level 25" + ); + assert!(matches!( + cmd, + AdminCommand::Spaces(SpaceCommand::Roles(SpaceRolesCommand::Add { .. })) + )); +} + +#[test] +fn parse_roles_assign() { + let cmd = parse("spaces roles assign !space:example.com @alice:example.com nsfw"); + assert!(matches!( + cmd, + AdminCommand::Spaces(SpaceCommand::Roles(SpaceRolesCommand::Assign { .. })) + )); +} + +#[test] +fn parse_roles_revoke() { + let cmd = parse("spaces roles revoke !space:example.com @alice:example.com nsfw"); + assert!(matches!( + cmd, + AdminCommand::Spaces(SpaceCommand::Roles(SpaceRolesCommand::Revoke { .. })) + )); +} + +#[test] +fn parse_roles_require() { + let cmd = parse("spaces roles require !space:example.com !room:example.com nsfw"); + assert!(matches!( + cmd, + AdminCommand::Spaces(SpaceCommand::Roles(SpaceRolesCommand::Require { .. })) + )); +} + +#[test] +fn parse_roles_unrequire() { + let cmd = parse("spaces roles unrequire !space:example.com !room:example.com nsfw"); + assert!(matches!( + cmd, + AdminCommand::Spaces(SpaceCommand::Roles(SpaceRolesCommand::Unrequire { .. })) + )); +} + +#[test] +fn parse_roles_user() { + let cmd = parse("spaces roles user !space:example.com @alice:example.com"); + assert!(matches!( + cmd, + AdminCommand::Spaces(SpaceCommand::Roles(SpaceRolesCommand::User { .. })) + )); +} + +#[test] +fn parse_roles_room() { + let cmd = parse("spaces roles room !space:example.com !room:example.com"); + assert!(matches!( + cmd, + AdminCommand::Spaces(SpaceCommand::Roles(SpaceRolesCommand::Room { .. })) + )); +} + +#[test] +fn parse_roles_remove() { + let cmd = parse("spaces roles remove !space:example.com nsfw"); + assert!(matches!( + cmd, + AdminCommand::Spaces(SpaceCommand::Roles(SpaceRolesCommand::Remove { .. })) + )); +} +``` + +**Step 2: Run tests** + +Run: `cargo test -p conduwuit-admin space::tests 2>&1 | tail -20` +Expected: All tests pass. + +**Step 3: Commit** + +```bash +git add src/admin/space/tests.rs +git commit -m "test(spaces): add admin command parsing tests for space roles" +``` + +--- + +### Task 17: Integration Tests — Enforcement Scenarios + +**Files:** +- Create: `src/service/rooms/roles/integration_tests.rs` + +These tests validate the enforcement logic end-to-end by operating on the cache +structures and verifying the expected outcomes. Since the full service stack +requires a running server, these tests mock the data layer and test the +decision-making logic. + +**Step 1: Create enforcement scenario tests** + +Create `src/service/rooms/roles/integration_tests.rs`: + +```rust +//! Integration-style tests for space permission cascading enforcement logic. +//! +//! These tests verify the correctness of enforcement decisions by operating +//! on the in-memory cache structures directly. They test multi-step scenarios +//! that span role assignment, room requirements, and qualification checks. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use conduwuit_core::matrix::space_roles::RoleDefinition; +use ruma::{room_id, user_id}; + +use super::tests::{make_requirements, make_roles, make_user_roles}; + +// --- Scenario: Full lifecycle of role-based access --- + +#[test] +fn scenario_user_gains_and_loses_access() { + let space_id = room_id!("!space:example.com"); + let nsfw_room = room_id!("!nsfw:example.com"); + let alice = user_id!("@alice:example.com"); + + let roles = make_roles(&[("admin", Some(100)), ("nsfw", None)]); + let room_reqs = make_requirements(&["nsfw"]); + + // Step 1: Alice has no roles -> cannot access NSFW room + let alice_roles: HashSet = HashSet::new(); + assert!(!room_reqs.iter().all(|r| alice_roles.contains(r))); + + // Step 2: Alice gets nsfw role -> can access + let alice_roles = make_user_roles(&["nsfw"]); + assert!(room_reqs.iter().all(|r| alice_roles.contains(r))); + + // Step 3: Alice loses nsfw role -> cannot access again + let alice_roles: HashSet = HashSet::new(); + assert!(!room_reqs.iter().all(|r| alice_roles.contains(r))); +} + +#[test] +fn scenario_room_adds_requirement_existing_members_checked() { + let alice = user_id!("@alice:example.com"); + let bob = user_id!("@bob:example.com"); + + let alice_roles = make_user_roles(&["vip"]); + let bob_roles = make_user_roles(&["vip", "nsfw"]); + + // Room initially has no requirements -> both qualify + 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))); + + // Room adds nsfw requirement -> only Bob qualifies + 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_room_reqs = make_requirements(&["nsfw"]); + let vip_room_reqs = make_requirements(&["vip"]); + let both_room_reqs = make_requirements(&["nsfw", "vip"]); + + // Alice qualifies for all three rooms + assert!(nsfw_room_reqs.iter().all(|r| alice_roles.contains(r))); + assert!(vip_room_reqs.iter().all(|r| alice_roles.contains(r))); + assert!(both_room_reqs.iter().all(|r| alice_roles.contains(r))); + + // Bob only qualifies for the nsfw room + assert!(nsfw_room_reqs.iter().all(|r| bob_roles.contains(r))); + assert!(!vip_room_reqs.iter().all(|r| bob_roles.contains(r))); + assert!(!both_room_reqs.iter().all(|r| bob_roles.contains(r))); +} + +// --- Scenario: Power level cascading --- + +#[test] +fn scenario_power_level_cascading_highest_wins() { + let roles = make_roles(&[ + ("admin", Some(100)), + ("mod", Some(50)), + ("helper", Some(25)), + ]); + + // User with admin + mod -> PL 100 + let admin_mod = make_user_roles(&["admin", "mod"]); + let pl = admin_mod + .iter() + .filter_map(|r| roles.get(r)?.power_level) + .max(); + assert_eq!(pl, Some(100)); + + // User with just helper -> PL 25 + let helper = make_user_roles(&["helper"]); + let pl = helper + .iter() + .filter_map(|r| roles.get(r)?.power_level) + .max(); + assert_eq!(pl, Some(25)); + + // User with nsfw (no PL) + mod -> PL 50 + let roles_with_nsfw = make_roles(&[ + ("mod", Some(50)), + ("nsfw", None), + ]); + let mod_nsfw = make_user_roles(&["mod", "nsfw"]); + let pl = mod_nsfw + .iter() + .filter_map(|r| roles_with_nsfw.get(r)?.power_level) + .max(); + assert_eq!(pl, Some(50)); +} + +#[test] +fn scenario_power_level_override_space_always_wins() { + let roles = make_roles(&[("mod", Some(50))]); + let user_assigned = make_user_roles(&["mod"]); + + let space_pl = user_assigned + .iter() + .filter_map(|r| roles.get(r)?.power_level) + .max() + .unwrap(); + + // Simulate: room has user at PL 0, Space says PL 50 + let room_pl: i64 = 0; + // Space always wins + let effective_pl = space_pl.max(room_pl); + assert_eq!(effective_pl, 50); + + // Even if room tries to set PL 100, Space role of 50 overrides + // (Space always wins means Space PL is authoritative, not max) + assert_eq!(space_pl, 50); +} + +// --- Scenario: Role definition changes --- + +#[test] +fn scenario_role_removed_from_definitions() { + let mut roles = make_roles(&[("admin", Some(100)), ("nsfw", None)]); + let user_assigned = make_user_roles(&["nsfw"]); + let room_reqs = make_requirements(&["nsfw"]); + + // User qualifies before role removal + assert!(room_reqs.iter().all(|r| user_assigned.contains(r))); + + // Remove nsfw from definitions + roles.remove("nsfw"); + + // User still has "nsfw" in their assignment but the role doesn't exist + // Qualification should check against defined roles + let qualifies = room_reqs.iter().all(|r| { + user_assigned.contains(r) && roles.contains_key(r) + }); + assert!(!qualifies); +} + +#[test] +fn scenario_role_power_level_changed() { + let mut roles = make_roles(&[("mod", Some(50))]); + let user_assigned = make_user_roles(&["mod"]); + + let pl_before = user_assigned + .iter() + .filter_map(|r| roles.get(r)?.power_level) + .max(); + assert_eq!(pl_before, Some(50)); + + // Change mod PL to 75 + roles.get_mut("mod").unwrap().power_level = Some(75); + + let pl_after = user_assigned + .iter() + .filter_map(|r| roles.get(r)?.power_level) + .max(); + assert_eq!(pl_after, Some(75)); +} + +// --- Scenario: Multiple spaces (no cascade) --- + +#[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"); + + // Space A gives Alice nsfw role + 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"])); + + // Space B room requires nsfw + let mut space_room_reqs: HashMap<_, HashMap<_, HashSet>> = HashMap::new(); + let child_of_b = room_id!("!child_b:example.com"); + space_room_reqs + .entry(space_b.to_owned()) + .or_default() + .insert(child_of_b.to_owned(), make_requirements(&["nsfw"])); + + // Alice has nsfw in Space A but NOT in Space B + let alice_roles_in_b = space_user_roles + .get(space_b) + .and_then(|users| users.get(alice)); + assert!(alice_roles_in_b.is_none()); + + // So Alice does NOT qualify for child_of_b + let reqs = &space_room_reqs[space_b][child_of_b]; + let qualifies = match alice_roles_in_b { + Some(assigned) => reqs.iter().all(|r| assigned.contains(r)), + None => reqs.is_empty(), + }; + assert!(!qualifies); +} + +// --- Scenario: Auto-join candidate identification --- + +#[test] +fn scenario_identify_auto_join_candidates() { + let alice_roles = make_user_roles(&["nsfw", "vip"]); + + let mut room_requirements: HashMap> = HashMap::new(); + room_requirements.insert("general".to_owned(), HashSet::new()); // no reqs + room_requirements.insert("nsfw-chat".to_owned(), make_requirements(&["nsfw"])); + room_requirements.insert("vip-lounge".to_owned(), make_requirements(&["vip"])); + room_requirements.insert("staff-only".to_owned(), make_requirements(&["staff"])); + + let qualifying_rooms: Vec<_> = room_requirements + .iter() + .filter(|(_, reqs)| reqs.iter().all(|r| alice_roles.contains(r))) + .map(|(name, _)| name.clone()) + .collect(); + + // Alice should qualify for: general (no reqs), nsfw-chat, vip-lounge + // But NOT staff-only + assert!(qualifying_rooms.contains(&"general".to_owned())); + assert!(qualifying_rooms.contains(&"nsfw-chat".to_owned())); + assert!(qualifying_rooms.contains(&"vip-lounge".to_owned())); + assert!(!qualifying_rooms.contains(&"staff-only".to_owned())); +} + +// --- Scenario: Kick candidate identification --- + +#[test] +fn scenario_identify_kick_candidates_after_role_revocation() { + // Before: Alice has nsfw + vip + // After: Alice only has vip (nsfw revoked) + + let alice_roles_after = make_user_roles(&["vip"]); + + let mut rooms_alice_is_in: HashMap> = HashMap::new(); + rooms_alice_is_in.insert("general".to_owned(), HashSet::new()); + rooms_alice_is_in.insert("nsfw-chat".to_owned(), make_requirements(&["nsfw"])); + rooms_alice_is_in.insert("vip-lounge".to_owned(), make_requirements(&["vip"])); + rooms_alice_is_in.insert( + "nsfw-vip".to_owned(), + make_requirements(&["nsfw", "vip"]), + ); + + let kick_from: Vec<_> = rooms_alice_is_in + .iter() + .filter(|(_, reqs)| !reqs.iter().all(|r| alice_roles_after.contains(r))) + .map(|(name, _)| name.clone()) + .collect(); + + // Should be kicked from: nsfw-chat and nsfw-vip + 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())); +} +``` + +**Step 2: Register the test module** + +In `src/service/rooms/roles/mod.rs`, add: + +```rust +#[cfg(test)] +mod integration_tests; +``` + +**Step 3: Run tests** + +Run: `cargo test -p conduwuit-service roles::integration_tests 2>&1 | tail -30` +Expected: All tests pass. + +**Step 4: Commit** + +```bash +git add src/service/rooms/roles/integration_tests.rs src/service/rooms/roles/mod.rs +git commit -m "test(spaces): add integration tests for enforcement scenarios" +``` + +--- + +### Task 18: Integration Tests — Cache Consistency + +**Files:** +- Create: `src/service/rooms/roles/cache_tests.rs` + +Tests that verify cache operations behave correctly: population, invalidation, +and consistency between the four index structures. + +**Step 1: Create cache consistency tests** + +Create `src/service/rooms/roles/cache_tests.rs`: + +```rust +//! 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)); +} +``` + +**Step 2: Register the test module** + +In `src/service/rooms/roles/mod.rs`, add: + +```rust +#[cfg(test)] +mod cache_tests; +``` + +**Step 3: Run tests** + +Run: `cargo test -p conduwuit-service roles::cache_tests 2>&1 | tail -30` +Expected: All tests pass. + +**Step 4: Commit** + +```bash +git add src/service/rooms/roles/cache_tests.rs src/service/rooms/roles/mod.rs +git commit -m "test(spaces): add cache consistency tests for space roles" +``` + +--- + +### Task 19: Documentation **Files:** - Modify: `docs/plans/2026-03-17-space-permission-cascading-design.md` (mark as implemented) @@ -1187,7 +2180,8 @@ git commit -m "docs: mark space permission cascading design as implemented" ``` Task 1 (config flag) - └─> Task 2 (event types) + └─> Task 2 (event types + serde tests) + ├─> Task 14 (expanded event type tests) └─> Task 3 (service skeleton) └─> Task 4 (cache + lookups) ├─> Task 5 (default roles) @@ -1197,10 +2191,13 @@ Task 1 (config flag) │ └─> Task 9 (state event hooks) │ └─> Task 10 (startup rebuild) ├─> Task 13 (PL rejection) + ├─> Task 15 (service lookup unit tests) + ├─> Task 17 (enforcement integration tests) + ├─> Task 18 (cache consistency tests) └─> Task 11 (admin cmd structure) └─> Task 12 (admin cmd handlers) -Task 14 (tests) - can run after Task 8 -Task 15 (docs) - final + └─> Task 16 (admin cmd parsing tests) +Task 19 (docs) - final ``` -Tasks 5-8, 11, and 13 can be worked on in parallel after Task 4 is complete. +Tasks 5-8, 11, 13-15, 17-18 can be worked on in parallel after Task 4 is complete.