#![allow(clippy::unwrap_used)] mod support; use synctv_api::ApiRuntimeSettings as Config; use std::sync::Arc; use chrono::Utc; use synctv_api::{ApiError, ClientApiImpl, GuestRoomAccess, RoomActor}; use synctv_core::{ cache::{KeyBuilder, UsernameCache}, models::{ AuditAction, AuditTargetType, PageParams, RoomAdminPermissionBits, RoomMemberPermissionBits, RoomPermissionSet, RoomRole, SignupMethod, User, UserId, UserRole, UserStatus, }, repository::{ AuditLogQuery, AuditLogRepository, ChatRepository, RoomMemberRepository, RoomRepository, RoomSettingsRepository, UserRepository, }, service::{ AuditService, BruteForceProtection, ChatDependencies, ChatRuntime, ContentFilter, InMemoryTokenBlacklistStore, JwtService, NotificationService, PermissionService, RateLimitConfig, RateLimiter, RequestRateLimiterService, RoomService, RoomSettingsService, UserService, }, }; use synctv_proto::client::{ CreateChatAttachmentUploadSessionRequest, DeleteChatMessageRequest, EditChatMessageRequest, FileUploadManifestPart, GetChatHistoryRequest, GetChatMessageContextRequest, GetChatMessageRequest, GetChatReadStateRequest, MarkChatReadRequest, SendChatMessageRequest, }; use synctv_realtime::sync::{ConnectionLimits, ConnectionManager}; const TEST_JWT_SECRET: &str = "Test_Secret_Key_For_JWT_Tokens_32Bytes!!"; fn make_user(username: &str) -> User { let now = Utc::now(); User { id: UserId::new(), username: username.to_string(), role: UserRole::User, avatar_file_reference_id: None, status: UserStatus::Active, signup_method: SignupMethod::Email, created_at: now, updated_at: now, version: 0, deleted_at: None, is_banned: false, banned_at: None, banned_by: None, banned_reason: None, } } fn make_user_service(pool: &sqlx::PgPool, username_cache: UsernameCache) -> UserService { UserService::new_for_tests( pool, JwtService::new(TEST_JWT_SECRET).unwrap(), username_cache, Arc::new(InMemoryTokenBlacklistStore::new(10_000, 3600, 86400)), KeyBuilder::new("test_chat_permissions"), BruteForceProtection::in_memory("test_chat_permissions:user".to_string()), ) } fn make_chat_service( pool: &sqlx::PgPool, user_service: Arc, ) -> Arc { make_chat_service_with_audit(pool, user_service, None) } fn make_chat_service_with_audit( pool: &sqlx::PgPool, user_service: Arc, audit_service: Option>, ) -> Arc { let member_repo = RoomMemberRepository::new(pool.clone()); let room_repo = RoomRepository::new(pool.clone()); let room_settings_repo = RoomSettingsRepository::new(pool.clone()); let permission_service = PermissionService::new_with_runtime( member_repo, room_repo, synctv_core::service::PermissionServiceRuntime { cache_size: 1000, cache_ttl_secs: 300, room_settings_repo: Some(room_settings_repo.clone()), ..synctv_core::service::PermissionServiceRuntime::local_only() }, ) .expect("permission service should build"); Arc::new(synctv_core::service::ChatService::new( Arc::new(ChatRepository::new(pool.clone())), ChatRuntime { clock: Arc::new(synctv_core::SystemClock), rate_limiter: Arc::new(RateLimiter::local_only( "test_chat_permissions:chat:".to_string(), )) as Arc, rate_limit_config: RateLimitConfig::default(), content_filter: ContentFilter::new(), }, ChatDependencies { permission_service, room_settings_service: RoomSettingsService::new( room_settings_repo, None, Arc::new(NotificationService::default()), None, None, ), user_service, file_storage_service: Arc::new(synctv_core::service::DisabledFileStorageService), audit_service, notification_service: NotificationService::default(), runtime_settings_store: None, }, )) } fn make_client_api( user_service: Arc, room_service: Arc, chat_service: Arc, ) -> ClientApiImpl { let connection_manager = Arc::new(ConnectionManager::new(ConnectionLimits::default())); ClientApiImpl::new_with_runtime( synctv_api::ClientApiOptions { read_pool: None, user_service, room_service, connection_service: connection_manager, runtime_settings: Arc::new(Config::default()), publish_key_service: None, jwt_service: JwtService::new(TEST_JWT_SECRET).unwrap(), live_streaming_infrastructure: None, runtime_settings_store: None, public_id_codec: Arc::new(synctv_api::PublicIdCodec::plain()), chat_service: Some(chat_service), provider_stores: Arc::new(synctv_core::provider::ProviderStoreRegistry::local_only( "test:provider:", )), email_api: None, passkey_service: None, }, support::client_api_runtime(), ) } fn expect_authorization(result: Result) { match result { Err(ApiError::Authorization(_)) => {} Err(other) => panic!("expected authorization error, got {other:?}"), Ok(_) => panic!("expected authorization error"), } } fn guest_actor(room_id: synctv_core::models::RoomId) -> RoomActor { RoomActor::Guest(GuestRoomAccess { room_id, guest_id: "guest-denied".to_string(), display_name: "Guest".to_string(), session_id: "guest-session".to_string(), token_jti: "guest-token".to_string(), permissions: RoomPermissionSet::default_member(), room_guest_version: 1, }) } #[tokio::test] #[ignore = "Requires Docker"] async fn test_chat_write_endpoints_require_signed_in_user_and_chat_permission() { let (_postgres, pool) = synctv_core_testing::create_test_pool().await; let user_repo = UserRepository::new(pool.clone()); let username_cache = UsernameCache::local_only("test:chat-perm:".to_string(), 100, 60); let user_service = Arc::new(make_user_service(&pool, username_cache)); let room_service = Arc::new( RoomService::new_for_tests(pool.clone(), (*user_service).clone()) .expect("room service should build"), ); let chat_service = make_chat_service(&pool, user_service.clone()); let client_api = make_client_api(user_service, room_service.clone(), chat_service); let owner = user_repo .create(&make_user("chat_write_owner")) .await .unwrap(); let member = user_repo .create(&make_user("chat_write_member")) .await .unwrap(); let room = room_service .create_room( "Chat Write Permission Room".to_string(), String::new(), owner.id, None, None, ) .await .unwrap(); room_service .join_room(room.id, member.id, None) .await .unwrap(); room_service .member_service() .revoke_permission( room.id, owner.id, member.id, RoomMemberPermissionBits::SEND_CHAT_MESSAGES, ) .await .unwrap(); let guest = guest_actor(room.id); expect_authorization( client_api .send_chat_message_for_actor(&guest, SendChatMessageRequest::default()) .await, ); expect_authorization( client_api .create_chat_attachment_upload_session_for_actor( &guest, CreateChatAttachmentUploadSessionRequest::default(), ) .await, ); let member_actor = RoomActor::User { room_id: room.id, user_id: member.id, }; expect_authorization( client_api .send_chat_message_for_actor( &member_actor, SendChatMessageRequest { content: "blocked".to_string(), ..Default::default() }, ) .await, ); expect_authorization( client_api .create_chat_attachment_upload_session_for_actor( &member_actor, CreateChatAttachmentUploadSessionRequest { client_attachment_id: "attachment-blocked".to_string(), filename: "blocked.png".to_string(), mime_type: "image/png".to_string(), size_bytes: 10, width: 1, height: 1, duration_seconds: 0, bitrate_bps: 0, parts: vec![FileUploadManifestPart { part_number: 1, offset_bytes: 0, size_bytes: 10, checksum_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" .to_string(), }], metadata: None, }, ) .await, ); let owner_actor = RoomActor::User { room_id: room.id, user_id: owner.id, }; let sent = client_api .send_chat_message_for_actor( &owner_actor, SendChatMessageRequest { content: "editable".to_string(), ..Default::default() }, ) .await .unwrap(); let message_id = sent.event.unwrap().message.unwrap().id; expect_authorization( client_api .edit_chat_message_for_actor( &member_actor, EditChatMessageRequest { message_id, content: "blocked edit".to_string(), metadata: None, ..Default::default() }, ) .await, ); } #[tokio::test] #[ignore = "Requires Docker"] async fn test_chat_read_endpoints_require_view_chat_history_permission() { let (_postgres, pool) = synctv_core_testing::create_test_pool().await; let user_repo = UserRepository::new(pool.clone()); let username_cache = UsernameCache::local_only("test:chat-read-perm:".to_string(), 100, 60); let user_service = Arc::new(make_user_service(&pool, username_cache)); let room_service = Arc::new( RoomService::new_for_tests(pool.clone(), (*user_service).clone()) .expect("room service should build"), ); let chat_service = make_chat_service(&pool, user_service.clone()); let client_api = make_client_api(user_service, room_service.clone(), chat_service); let owner = user_repo .create(&make_user("chat_read_owner")) .await .unwrap(); let member = user_repo .create(&make_user("chat_read_member")) .await .unwrap(); let room = room_service .create_room( "Chat Read Permission Room".to_string(), String::new(), owner.id, None, None, ) .await .unwrap(); room_service .join_room(room.id, member.id, None) .await .unwrap(); let owner_actor = RoomActor::User { room_id: room.id, user_id: owner.id, }; let sent = client_api .send_chat_message_for_actor( &owner_actor, SendChatMessageRequest { content: "read target".to_string(), ..Default::default() }, ) .await .unwrap(); let message_id = sent.event.unwrap().message.unwrap().id; room_service .member_service() .revoke_permission( room.id, owner.id, member.id, RoomMemberPermissionBits::VIEW_CHAT_HISTORY, ) .await .unwrap(); let member_actor = RoomActor::User { room_id: room.id, user_id: member.id, }; expect_authorization( client_api .get_chat_history_for_actor(&member_actor, GetChatHistoryRequest::default()) .await, ); expect_authorization( client_api .get_chat_message_for_actor( &member_actor, GetChatMessageRequest { message_id: message_id.clone(), include_deleted: false, }, ) .await, ); expect_authorization( client_api .get_chat_message_context_for_actor( &member_actor, GetChatMessageContextRequest { message_id: message_id.clone(), before_limit: 1, after_limit: 1, include_deleted: false, }, ) .await, ); expect_authorization( client_api .mark_chat_read_for_actor(&member_actor, MarkChatReadRequest { message_id }) .await, ); expect_authorization( client_api .get_chat_read_state_for_actor(&member_actor, GetChatReadStateRequest {}) .await, ); } #[tokio::test] #[ignore = "Requires Docker"] async fn test_chat_delete_endpoint_allows_sender_and_delete_chat_messages_permission() { let (_postgres, pool) = synctv_core_testing::create_test_pool().await; let user_repo = UserRepository::new(pool.clone()); let username_cache = UsernameCache::local_only("test:chat-delete-perm:".to_string(), 100, 60); let user_service = Arc::new(make_user_service(&pool, username_cache)); let room_service = Arc::new( RoomService::new_for_tests(pool.clone(), (*user_service).clone()) .expect("room service should build"), ); let chat_service = make_chat_service(&pool, user_service.clone()); let client_api = make_client_api(user_service, room_service.clone(), chat_service); let owner = user_repo .create(&make_user("chat_delete_owner")) .await .unwrap(); let member = user_repo .create(&make_user("chat_delete_member")) .await .unwrap(); let admin = user_repo .create(&make_user("chat_delete_admin")) .await .unwrap(); let room = room_service .create_room( "Chat Delete Permission Room".to_string(), String::new(), owner.id, None, None, ) .await .unwrap(); room_service .join_room(room.id, member.id, None) .await .unwrap(); room_service .join_room(room.id, admin.id, None) .await .unwrap(); room_service .member_service() .set_member_role(room.id, owner.id, admin.id, RoomRole::Admin) .await .unwrap(); room_service .member_service() .grant_permission( room.id, owner.id, admin.id, RoomAdminPermissionBits::DELETE_CHAT_MESSAGES, ) .await .unwrap(); let owner_actor = RoomActor::User { room_id: room.id, user_id: owner.id, }; let member_actor = RoomActor::User { room_id: room.id, user_id: member.id, }; let admin_actor = RoomActor::User { room_id: room.id, user_id: admin.id, }; let first = client_api .send_chat_message_for_actor( &owner_actor, SendChatMessageRequest { content: "delete by sender".to_string(), ..Default::default() }, ) .await .unwrap() .event .unwrap() .message .unwrap() .id; expect_authorization( client_api .delete_chat_message_for_actor( &member_actor, DeleteChatMessageRequest { message_id: first.clone(), reason: "blocked".to_string(), ..Default::default() }, ) .await, ); client_api .delete_chat_message_for_actor( &owner_actor, DeleteChatMessageRequest { message_id: first, reason: "sender cleanup".to_string(), ..Default::default() }, ) .await .unwrap(); let second = client_api .send_chat_message_for_actor( &owner_actor, SendChatMessageRequest { content: "delete by admin".to_string(), ..Default::default() }, ) .await .unwrap() .event .unwrap() .message .unwrap() .id; client_api .delete_chat_message_for_actor( &admin_actor, DeleteChatMessageRequest { message_id: second, reason: "moderation".to_string(), ..Default::default() }, ) .await .unwrap(); } #[tokio::test] #[ignore = "Requires Docker"] async fn test_chat_admin_delete_endpoint_writes_audit_log() { let (_postgres, pool) = synctv_core_testing::create_test_pool().await; let user_repo = UserRepository::new(pool.clone()); let username_cache = UsernameCache::local_only("test:chat-audit:".to_string(), 100, 60); let user_service = Arc::new(make_user_service(&pool, username_cache)); let room_service = Arc::new( RoomService::new_for_tests(pool.clone(), (*user_service).clone()) .expect("room service should build"), ); let audit_service = Arc::new(AuditService::new_unbuffered(pool.clone())); let chat_service = make_chat_service_with_audit(&pool, user_service.clone(), Some(audit_service)); let client_api = make_client_api(user_service, room_service.clone(), chat_service); let owner = user_repo .create(&make_user("chat_audit_owner")) .await .unwrap(); let admin = user_repo .create(&make_user("chat_audit_admin")) .await .unwrap(); let room = room_service .create_room( "Chat Audit Room".to_string(), String::new(), owner.id, None, None, ) .await .unwrap(); room_service .join_room(room.id, admin.id, None) .await .unwrap(); room_service .member_service() .set_member_role(room.id, owner.id, admin.id, RoomRole::Admin) .await .unwrap(); room_service .member_service() .grant_permission( room.id, owner.id, admin.id, RoomAdminPermissionBits::DELETE_CHAT_MESSAGES, ) .await .unwrap(); let owner_actor = RoomActor::User { room_id: room.id, user_id: owner.id, }; let admin_actor = RoomActor::User { room_id: room.id, user_id: admin.id, }; let message_id = client_api .send_chat_message_for_actor( &owner_actor, SendChatMessageRequest { content: "audit target".to_string(), ..Default::default() }, ) .await .unwrap() .event .unwrap() .message .unwrap() .id; let deleted_event = client_api .delete_chat_message_for_actor( &admin_actor, DeleteChatMessageRequest { message_id: message_id.clone(), reason: "policy violation".to_string(), client_operation_id: "audit-delete-op".to_string(), ..Default::default() }, ) .await .unwrap() .event .unwrap(); let audit_repo = AuditLogRepository::new(pool); let (rows, total) = audit_repo .list(&AuditLogQuery { actor_id: Some(admin.id), action: Some(AuditAction::ChatMessageDeleted), target_type: Some(AuditTargetType::ChatMessage), target_id: Some(format!("{}:{message_id}", room.id)), page: PageParams::new(Some(1), Some(10)), ..Default::default() }) .await .unwrap(); assert_eq!(total, 1); let row = rows.first().expect("audit row should exist"); assert_eq!(row.actor_id, Some(admin.id)); assert_eq!(row.actor_username.as_deref(), Some(admin.username.as_str())); assert_eq!(row.action, AuditAction::ChatMessageDeleted); assert_eq!(row.target_type, Some(AuditTargetType::ChatMessage)); assert_eq!( row.target_id.as_deref(), Some(format!("{}:{message_id}", room.id).as_str()) ); let details = row.details.as_ref().expect("audit details should exist"); assert_eq!( details.room_id.as_deref(), Some(room.id.to_string().as_str()) ); assert_eq!(details.message_id.as_deref(), Some(message_id.as_str())); assert_eq!( details.original_author_id.as_deref(), Some(owner.id.to_string().as_str()) ); assert_eq!( details.deleted_by.as_deref(), Some(admin.id.to_string().as_str()) ); assert_eq!(details.reason.as_deref(), Some("policy violation")); assert_eq!( details.client_operation_id.as_deref(), Some("audit-delete-op") ); assert_eq!( details.event_id.as_deref(), Some(deleted_event.event_id.as_str()) ); }