//! Moderation domain logic — report, ban, unban, list. //! //! Pure business logic operating on `Store` trait and domain types. use std::sync::Arc; use crate::storage::Store; use super::types::*; /// Shared state needed by moderation operations. pub struct ModerationService { pub store: Arc, } impl ModerationService { /// Submit an encrypted report for a message. pub fn report_message( &self, req: ReportMessageReq, ) -> Result { if req.encrypted_report.is_empty() { return Err(DomainError::BadParams( "encrypted report must not be empty".into(), )); } self.store .store_report( &req.encrypted_report, &req.conversation_id, &req.reporter_identity, ) .map_err(DomainError::Storage)?; tracing::info!( reporter_prefix = %hex_prefix(&req.reporter_identity), "audit: message reported" ); Ok(ReportMessageResp { accepted: true }) } /// Ban a user by identity key. pub fn ban_user(&self, req: BanUserReq) -> Result { if req.identity_key.len() != 32 { return Err(DomainError::InvalidIdentityKey(req.identity_key.len())); } let expires_at = if req.duration_secs == 0 { 0 // permanent } else { now_secs() + req.duration_secs }; self.store .ban_user(&req.identity_key, &req.reason, expires_at) .map_err(DomainError::Storage)?; tracing::info!( identity_prefix = %hex_prefix(&req.identity_key), reason = %req.reason, expires_at, "audit: user banned" ); Ok(BanUserResp { success: true }) } /// Unban a user by identity key. pub fn unban_user(&self, req: UnbanUserReq) -> Result { if req.identity_key.len() != 32 { return Err(DomainError::InvalidIdentityKey(req.identity_key.len())); } let removed = self .store .unban_user(&req.identity_key) .map_err(DomainError::Storage)?; if removed { tracing::info!( identity_prefix = %hex_prefix(&req.identity_key), "audit: user unbanned" ); } Ok(UnbanUserResp { success: removed }) } /// Check if a user is currently banned. pub fn check_ban(&self, identity_key: &[u8]) -> Result, DomainError> { self.store .is_banned(identity_key) .map_err(DomainError::Storage) } /// List reports with pagination. pub fn list_reports(&self, req: ListReportsReq) -> Result { let raw = self .store .list_reports(req.limit, req.offset) .map_err(DomainError::Storage)?; let reports = raw .into_iter() .map( |(id, encrypted_report, conversation_id, reporter_identity, timestamp)| { ReportEntry { id, encrypted_report, conversation_id, reporter_identity, timestamp, } }, ) .collect(); Ok(ListReportsResp { reports }) } /// List all currently banned users. pub fn list_banned(&self) -> Result { let raw = self.store.list_banned().map_err(DomainError::Storage)?; let users = raw .into_iter() .map( |(identity_key, reason, banned_at, expires_at)| BannedUserEntry { identity_key, reason, banned_at, expires_at, }, ) .collect(); Ok(ListBannedResp { users }) } } fn now_secs() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs() } fn hex_prefix(bytes: &[u8]) -> String { let len = bytes.len().min(4); let hex: String = bytes[..len].iter().map(|b| format!("{b:02x}")).collect(); format!("{hex}...") } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; use crate::storage::FileBackedStore; fn test_service() -> (tempfile::TempDir, ModerationService) { let dir = tempfile::tempdir().unwrap(); let store = Arc::new(FileBackedStore::open(dir.path()).unwrap()); let svc = ModerationService { store }; (dir, svc) } #[test] fn report_store_and_list() { let (_dir, svc) = test_service(); let resp = svc .report_message(ReportMessageReq { encrypted_report: vec![1, 2, 3], conversation_id: vec![10; 16], reporter_identity: vec![20; 32], }) .unwrap(); assert!(resp.accepted); let reports = svc .list_reports(ListReportsReq { limit: 10, offset: 0, }) .unwrap(); assert_eq!(reports.reports.len(), 1); assert_eq!(reports.reports[0].encrypted_report, vec![1, 2, 3]); assert_eq!(reports.reports[0].conversation_id, vec![10; 16]); assert_eq!(reports.reports[0].reporter_identity, vec![20; 32]); } #[test] fn report_empty_rejected() { let (_dir, svc) = test_service(); let result = svc.report_message(ReportMessageReq { encrypted_report: vec![], conversation_id: vec![10; 16], reporter_identity: vec![20; 32], }); assert!(result.is_err()); } #[test] fn ban_unban_lifecycle() { let (_dir, svc) = test_service(); let ik = vec![1u8; 32]; // Not banned initially. assert!(svc.check_ban(&ik).unwrap().is_none()); // Ban permanently. let resp = svc .ban_user(BanUserReq { identity_key: ik.clone(), reason: "spam".into(), duration_secs: 0, }) .unwrap(); assert!(resp.success); // Now banned. let reason = svc.check_ban(&ik).unwrap(); assert_eq!(reason, Some("spam".to_string())); // Listed in banned users. let banned = svc.list_banned().unwrap(); assert_eq!(banned.users.len(), 1); assert_eq!(banned.users[0].identity_key, ik); assert_eq!(banned.users[0].reason, "spam"); assert_eq!(banned.users[0].expires_at, 0); // permanent // Unban. let resp = svc.unban_user(UnbanUserReq { identity_key: ik.clone() }).unwrap(); assert!(resp.success); // No longer banned. assert!(svc.check_ban(&ik).unwrap().is_none()); assert!(svc.list_banned().unwrap().users.is_empty()); } #[test] fn ban_invalid_identity_key() { let (_dir, svc) = test_service(); let result = svc.ban_user(BanUserReq { identity_key: vec![1u8; 16], // wrong length reason: "test".into(), duration_secs: 0, }); assert!(result.is_err()); } #[test] fn list_reports_pagination() { let (_dir, svc) = test_service(); for i in 0..5u8 { svc.report_message(ReportMessageReq { encrypted_report: vec![i], conversation_id: vec![10; 16], reporter_identity: vec![20; 32], }) .unwrap(); } let page1 = svc .list_reports(ListReportsReq { limit: 2, offset: 0, }) .unwrap(); assert_eq!(page1.reports.len(), 2); assert_eq!(page1.reports[0].encrypted_report, vec![0]); let page2 = svc .list_reports(ListReportsReq { limit: 2, offset: 2, }) .unwrap(); assert_eq!(page2.reports.len(), 2); assert_eq!(page2.reports[0].encrypted_report, vec![2]); let page3 = svc .list_reports(ListReportsReq { limit: 2, offset: 4, }) .unwrap(); assert_eq!(page3.reports.len(), 1); } #[test] fn unban_nonexistent_returns_false() { let (_dir, svc) = test_service(); let resp = svc .unban_user(UnbanUserReq { identity_key: vec![99u8; 32], }) .unwrap(); assert!(!resp.success); } }