continuwuity/src/core/matrix/space_roles.rs
ember33 cee5aa476c chore(spaces): fix doc comments, design doc accuracy, consistent error style
- Fix doc comment referencing room_to_space instead of space_to_rooms
- Add space_to_rooms forward index to design doc index table
- Use Err! consistently for validation errors in admin commands
- Rename test to follow deserialize_ prefix convention

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:35:35 +01:00

202 lines
6.2 KiB
Rust

//! Custom state event content types for space permission cascading.
//!
//! These events live in Space rooms and define roles, user-role assignments,
//! and room-role requirements.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
/// Custom event type for space role definitions.
pub const SPACE_ROLES_EVENT_TYPE: &str = "com.continuwuity.space.roles";
/// Custom event type for per-user role assignments within a space.
pub const SPACE_ROLE_MEMBER_EVENT_TYPE: &str = "com.continuwuity.space.role.member";
/// Custom event type for per-room role requirements within a space.
pub const SPACE_ROLE_ROOM_EVENT_TYPE: &str = "com.continuwuity.space.role.room";
/// Content for `com.continuwuity.space.roles` (state key: "")
///
/// Defines available roles for a Space.
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct SpaceRolesEventContent {
pub roles: BTreeMap<String, RoleDefinition>,
}
/// A single role definition within a Space.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct RoleDefinition {
pub description: String,
/// If present, users with this role receive this power level in child
/// rooms.
#[serde(skip_serializing_if = "Option::is_none")]
pub power_level: Option<i64>,
}
/// Content for `com.continuwuity.space.role.member` (state key: user ID)
///
/// Assigns roles to a user within a Space.
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct SpaceRoleMemberEventContent {
pub roles: Vec<String>,
}
/// Content for `com.continuwuity.space.role.room` (state key: room ID)
///
/// Declares which roles a child room requires for access.
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct SpaceRoleRoomEventContent {
pub required_roles: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serialize_space_roles() {
let mut roles = BTreeMap::new();
roles.insert(
"admin".to_owned(),
RoleDefinition {
description: "Space administrator".to_owned(),
power_level: Some(100),
},
);
roles.insert(
"nsfw".to_owned(),
RoleDefinition {
description: "NSFW access".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();
assert_eq!(deserialized.roles.len(), 2);
assert_eq!(deserialized.roles["admin"].power_level, Some(100));
assert!(deserialized.roles["nsfw"].power_level.is_none());
}
#[test]
fn serialize_role_member() {
let content = SpaceRoleMemberEventContent {
roles: vec!["nsfw".to_owned(), "vip".to_owned()],
};
let json = serde_json::to_string(&content).unwrap();
let deserialized: SpaceRoleMemberEventContent = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.roles, vec!["nsfw", "vip"]);
}
#[test]
fn serialize_role_room() {
let content = SpaceRoleRoomEventContent {
required_roles: vec!["nsfw".to_owned()],
};
let json = serde_json::to_string(&content).unwrap();
let deserialized: SpaceRoleRoomEventContent = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.required_roles, vec!["nsfw"]);
}
#[test]
fn empty_roles_deserialize() {
let json = r#"{"roles":{}}"#;
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 deserialize_ignores_unknown_fields() {
let json = r#"{"roles":["nsfw"],"extra_field":"ignored"}"#;
let content: SpaceRoleMemberEventContent = serde_json::from_str(json).unwrap();
assert_eq!(content.roles, vec!["nsfw"]);
}
#[test]
fn missing_description_fails() {
let json = r#"{"power_level":100}"#;
assert!(serde_json::from_str::<RoleDefinition>(json).is_err());
}
#[test]
fn wrong_type_for_roles_fails() {
let json = r#"{"roles":"not_an_array"}"#;
assert!(serde_json::from_str::<SpaceRoleMemberEventContent>(json).is_err());
}
#[test]
fn wrong_type_for_required_roles_fails() {
let json = r#"{"required_roles":42}"#;
assert!(serde_json::from_str::<SpaceRoleRoomEventContent>(json).is_err());
}
}