#![allow(clippy::unwrap_used)] mod support; use synctv_api::ApiRuntimeSettings as Config; use std::sync::Arc; use chrono::Utc; use synctv_api::{ AdminApiImpl, AdminApiOptions, AdminRequestContext as RequestContext, ApiError, ClientApiImpl, }; use synctv_core::{ cache::{KeyBuilder, UsernameCache}, models::{ room_settings::RequireApproval, ReviewRequestId, ReviewStatus, RoomAdminPermissionBits, RoomRole, RoomSettings, RoomStatus, SignupMethod, User, UserId, UserRole, UserStatus, }, repository::{ ProviderInstanceRepository, RoomMemberRepository, RoomRepository, SettingsRepository, UserRepository, }, service::{ AuditService, BruteForceProtection, EmailConfig, EmailConfigProvider, EmailService, InMemoryTokenBlacklistStore, JwtService, PublishKeyService, RemoteProviderManager, RoomService, RoomServiceOptions, RuntimeSettingsStore, SettingsService, UserService, }, }; use synctv_realtime::sync::{ConnectionLimits, ConnectionManager}; struct DisabledEmailConfigProvider; impl EmailConfigProvider for DisabledEmailConfigProvider { fn current_config(&self) -> synctv_core::Result> { Ok(None) } } fn public_id_codec() -> synctv_api::PublicIdCodec { synctv_api::PublicIdCodec::plain() } fn review_request_public_id(id: i64) -> String { public_id_codec() .encode_review_request_id(ReviewRequestId::expect_positive(id)) .unwrap() } fn make_user(username: &str, role: UserRole, status: UserStatus) -> User { let now = Utc::now(); User { id: UserId::new(), username: username.to_string(), role, avatar_file_reference_id: None, status, is_banned: status == UserStatus::Banned, banned_at: None, banned_by: None, banned_reason: None, signup_method: SignupMethod::Email, created_at: now, updated_at: now, version: 0, deleted_at: None, } } fn make_user_service(pool: &sqlx::PgPool) -> UserService { let jwt_service = JwtService::new("Test_Secret_Key_For_JWT_Tokens_32Bytes!!").unwrap(); let username_cache = UsernameCache::local_only("test:username:".to_string(), 100, 60); let token_blacklist = Arc::new(InMemoryTokenBlacklistStore::new(1000, 3600, 86400)); UserService::new_for_tests( pool, jwt_service, username_cache, token_blacklist, KeyBuilder::new("test"), BruteForceProtection::in_memory("test:user".to_string()), ) } fn make_client_api( user_service: Arc, room_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_Secret_Key_For_JWT_Tokens_32Bytes!!").unwrap(), live_streaming_infrastructure: None, runtime_settings_store: None, public_id_codec: Arc::new(synctv_api::PublicIdCodec::plain()), chat_service: None, provider_stores: Arc::new(synctv_core::provider::ProviderStoreRegistry::local_only( "test:provider:", )), email_api: None, passkey_service: None, }, support::client_api_runtime(), ) } async fn make_admin_api(pool: sqlx::PgPool) -> AdminApiImpl { let user_service = Arc::new(make_user_service(&pool)); let settings_service = Arc::new(SettingsService::new( SettingsRepository::new(pool.clone()), pool.clone(), )); settings_service .initialize() .await .expect("settings initialized"); let runtime_settings_store = Arc::new(RuntimeSettingsStore::new(settings_service.clone())); let room_service = RoomService::new_with_options( pool.clone(), (*user_service).clone(), RoomServiceOptions { runtime_settings_store: Some(runtime_settings_store.clone()), ..RoomServiceOptions::test_defaults_with_settings(pool.clone()) }, ) .expect("room service should build"); let email_service = Arc::new(EmailService::new(Arc::new(DisabledEmailConfigProvider)).expect("email service")); let connection_manager = Arc::new(ConnectionManager::new(ConnectionLimits::default())); let provider_instance_manager = Arc::new(RemoteProviderManager::new(Arc::new( ProviderInstanceRepository::new(pool.clone()), ))); let publish_key_service = Arc::new( PublishKeyService::new( JwtService::new("test-secret-key-for-admin-impl-tests-minimum-32-chars").unwrap(), Arc::new(synctv_core::SystemClock), 24, ) .expect("publish key service should build"), ); AdminApiImpl::new_with_runtime( AdminApiOptions { room_service: Arc::new(room_service), chat_service: None, read_services: support::admin_read_services(user_service.as_ref()), user_service, settings_service, runtime_settings_store: Some(runtime_settings_store), email_service, connection_service: connection_manager, provider_instance_manager, live_streaming_infrastructure: None, publish_key_service: Some(publish_key_service), runtime_settings: Arc::new(Config::default()), audit_service: Arc::new(AuditService::new_unbuffered(pool)), public_id_codec: Arc::new(synctv_api::PublicIdCodec::plain()), }, support::admin_api_runtime(), ) } #[tokio::test] #[ignore = "Requires Docker"] async fn test_add_member_rejects_banned_user_status() { let (_postgres, pool) = synctv_core_testing::create_test_pool().await; let user_repo = UserRepository::new(pool.clone()); let user_service = Arc::new(make_user_service(&pool)); let room_service = Arc::new( RoomService::new_for_tests(pool.clone(), (*user_service).clone()) .expect("room service should build"), ); let client_api = make_client_api(user_service, room_service.clone()); let owner = user_repo .create(&make_user( "status_room_owner", UserRole::User, UserStatus::Active, )) .await .unwrap(); let banned_user = user_repo .create(&make_user( "status_banned_target", UserRole::User, UserStatus::Active, )) .await .unwrap(); user_repo .ban( &banned_user.id, Some(&owner.id), Some("membership guard coverage".to_string()), ) .await .unwrap(); let room = room_service .create_room( "User Status Matrix Room".to_string(), String::new(), owner.id, None, None, ) .await .unwrap(); let codec = public_id_codec(); let room_public_id = codec.encode_room_id(room.id).unwrap(); let banned_user_public_id = codec.encode_user_id(banned_user.id).unwrap(); let error = client_api .add_member( &owner.id, &room_public_id, synctv_proto::client::AddMemberRequest { user_id: banned_user_public_id, role: synctv_proto::common::RoomMemberRole::Member as i32, notify: false, remark_name: String::new(), display_tag: String::new(), }, ) .await .expect_err("banned target user must be rejected"); assert!( matches!(error, ApiError::Authorization(ref message) if message.contains("banned")), "banned user should be rejected with status-aware message, got: {error:?}" ); } #[tokio::test] #[ignore = "Requires Docker"] async fn test_member_permission_matrix_controls_moderation_apis() { let (_postgres, pool) = synctv_core_testing::create_test_pool().await; let user_repo = UserRepository::new(pool.clone()); let user_service = Arc::new(make_user_service(&pool)); let room_service = Arc::new( RoomService::new_for_tests(pool.clone(), (*user_service).clone()) .expect("room service should build"), ); let client_api = make_client_api(user_service, room_service.clone()); let owner = user_repo .create(&make_user( "permission_matrix_owner", UserRole::User, UserStatus::Active, )) .await .unwrap(); let moderator = user_repo .create(&make_user( "permission_matrix_member", UserRole::User, UserStatus::Active, )) .await .unwrap(); let pending_target = user_repo .create(&make_user( "permission_matrix_pending", UserRole::User, UserStatus::Active, )) .await .unwrap(); let kick_target = user_repo .create(&make_user( "permission_matrix_guest", UserRole::User, UserStatus::Active, )) .await .unwrap(); let kick_after_reset_target = user_repo .create(&make_user( "permission_matrix_reset_target", UserRole::User, UserStatus::Active, )) .await .unwrap(); let settings = RoomSettings { require_approval: RequireApproval(true), ..Default::default() }; let room = room_service .create_room( "Moderation Permission Matrix Room".to_string(), String::new(), owner.id, None, Some(settings), ) .await .unwrap(); room_service .add_member(room.id, owner.id, moderator.id, RoomRole::Member, false) .await .unwrap(); room_service .join_room(room.id, pending_target.id, None) .await .unwrap(); let pending_request_id: i64 = sqlx::query_scalar!( r#"SELECT id AS "id!" FROM room_join_requests WHERE room_id = $1 AND user_id = $2 AND reviewed_at IS NULL"#, room.id.as_i64(), pending_target.id.as_i64() ) .fetch_one(&pool) .await .unwrap(); room_service .add_member(room.id, owner.id, kick_target.id, RoomRole::Guest, false) .await .unwrap(); room_service .add_member( room.id, owner.id, kick_after_reset_target.id, RoomRole::Guest, false, ) .await .unwrap(); let codec = public_id_codec(); let room_public_id = codec.encode_room_id(room.id).unwrap(); let moderator_public_id = codec.encode_user_id(moderator.id).unwrap(); let pending_target_public_id = codec.encode_user_id(pending_target.id).unwrap(); let kick_target_public_id = codec.encode_user_id(kick_target.id).unwrap(); let kick_after_reset_target_public_id = codec.encode_user_id(kick_after_reset_target.id).unwrap(); let pending_list_error = client_api .list_room_join_reviews( &moderator.id, &room_public_id, synctv_proto::client::ListRoomJoinReviewsRequest { page: 1, page_size: 20, status: synctv_proto::common::ReviewStatus::Pending as i32, user_id: String::new(), }, ) .await .expect_err("non-moderator should not inspect pending queue"); assert!( matches!(pending_list_error, ApiError::Authorization(ref message) if message.contains("Permission denied")), "pending-member listing must require moderation permission, got: {pending_list_error:?}" ); let approve_error = client_api .approve_room_join_review( &moderator.id, &room_public_id, synctv_proto::client::ApproveRoomJoinReviewRequest { request_id: review_request_public_id(pending_request_id), }, ) .await .expect_err("member without approval permission must be rejected"); assert!( matches!(approve_error, ApiError::Authorization(ref message) if message.contains("Permission denied")), "approving members without permission must fail, got: {approve_error:?}" ); client_api .update_member_permissions( &owner.id, &room_public_id, synctv_proto::client::UpdateMemberPermissionsRequest { user_id: moderator_public_id.clone(), role: synctv_proto::common::RoomMemberRole::Admin as i32, added_permissions: 0, removed_permissions: 0, admin_added_permissions: RoomAdminPermissionBits::REVIEW_JOIN_REQUESTS | RoomAdminPermissionBits::REMOVE_MEMBERS, admin_removed_permissions: 0, }, ) .await .unwrap(); let pending_response = client_api .list_room_join_reviews( &moderator.id, &room_public_id, synctv_proto::client::ListRoomJoinReviewsRequest { page: 1, page_size: 20, status: synctv_proto::common::ReviewStatus::Pending as i32, user_id: String::new(), }, ) .await .unwrap(); assert_eq!(pending_response.total, 1); assert_eq!(pending_response.reviews.len(), 1); assert_eq!( pending_response.reviews[0].user_id, pending_target_public_id ); assert_eq!( pending_response.reviews[0].status, synctv_proto::common::ReviewStatus::Pending as i32 ); let approved = client_api .approve_room_join_review( &moderator.id, &room_public_id, synctv_proto::client::ApproveRoomJoinReviewRequest { request_id: review_request_public_id(pending_request_id), }, ) .await .unwrap() .member .expect("approved member"); assert_eq!(approved.user_id, pending_target_public_id); client_api .kick_member( &moderator.id, &room_public_id, synctv_proto::client::KickMemberRequest { user_id: kick_target_public_id.clone(), kick_cooldown_seconds: 300, }, ) .await .unwrap(); client_api .update_member_permissions( &owner.id, &room_public_id, synctv_proto::client::UpdateMemberPermissionsRequest { user_id: moderator_public_id, role: synctv_proto::common::RoomMemberRole::Member as i32, added_permissions: 0, removed_permissions: 0, admin_added_permissions: 0, admin_removed_permissions: 0, }, ) .await .unwrap(); let kick_error = client_api .kick_member( &moderator.id, &room_public_id, synctv_proto::client::KickMemberRequest { user_id: kick_after_reset_target_public_id, kick_cooldown_seconds: 300, }, ) .await .expect_err("resetting permission overrides must remove moderation powers"); assert!( matches!(kick_error, ApiError::Authorization(ref message) if message.contains("Permission denied")), "moderation permission reset must block kick_member, got: {kick_error:?}" ); } #[tokio::test] #[ignore = "Requires Docker"] async fn test_update_member_permissions_requires_admin_override_fields_for_admin_role() { let (_postgres, pool) = synctv_core_testing::create_test_pool().await; let user_repo = UserRepository::new(pool.clone()); let user_service = Arc::new(make_user_service(&pool)); let room_service = Arc::new( RoomService::new_for_tests(pool.clone(), (*user_service).clone()) .expect("room service should build"), ); let client_api = make_client_api(user_service, room_service.clone()); let owner = user_repo .create(&make_user( "admin_override_owner", UserRole::User, UserStatus::Active, )) .await .unwrap(); let target = user_repo .create(&make_user( "admin_override_target", UserRole::User, UserStatus::Active, )) .await .unwrap(); let room = room_service .create_room( "Admin Override Matrix Room".to_string(), String::new(), owner.id, None, None, ) .await .unwrap(); room_service .add_member(room.id, owner.id, target.id, RoomRole::Member, false) .await .unwrap(); let codec = public_id_codec(); let room_public_id = codec.encode_room_id(room.id).unwrap(); let target_public_id = codec.encode_user_id(target.id).unwrap(); let wrong_columns_error = client_api .update_member_permissions( &owner.id, &room_public_id, synctv_proto::client::UpdateMemberPermissionsRequest { user_id: target_public_id.clone(), role: synctv_proto::common::RoomMemberRole::Admin as i32, added_permissions: 0, removed_permissions: RoomAdminPermissionBits::REMOVE_MEMBERS, admin_added_permissions: 0, admin_removed_permissions: 0, }, ) .await .expect_err("promoting to admin must reject member override columns"); assert!( matches!(wrong_columns_error, ApiError::Authorization(ref message) if message.contains("Admin members must use admin_added_permissions")), "admin role updates must enforce admin_* permission columns, got: {wrong_columns_error:?}" ); let updated = client_api .update_member_permissions( &owner.id, &room_public_id, synctv_proto::client::UpdateMemberPermissionsRequest { user_id: target_public_id, role: synctv_proto::common::RoomMemberRole::Admin as i32, added_permissions: 0, removed_permissions: 0, admin_added_permissions: 0, admin_removed_permissions: RoomAdminPermissionBits::REMOVE_MEMBERS, }, ) .await .unwrap(); assert_eq!( updated.role, synctv_proto::common::RoomMemberRole::Admin as i32 ); assert_eq!( updated.admin_removed_permissions, RoomAdminPermissionBits::REMOVE_MEMBERS ); assert_eq!(updated.removed_permissions, 0); assert_eq!( updated.permissions & RoomAdminPermissionBits::REMOVE_MEMBERS, 0 ); } #[tokio::test] #[ignore = "Requires Docker"] async fn test_transfer_room_ownership_requires_creator_and_active_member_target() { let (_postgres, pool) = synctv_core_testing::create_test_pool().await; let user_repo = UserRepository::new(pool.clone()); let user_service = Arc::new(make_user_service(&pool)); let room_service = Arc::new( RoomService::new_for_tests(pool.clone(), (*user_service).clone()) .expect("room service should build"), ); let client_api = make_client_api(user_service, room_service.clone()); let owner = user_repo .create(&make_user( "ownership_matrix_owner", UserRole::User, UserStatus::Active, )) .await .unwrap(); let room_admin = user_repo .create(&make_user( "ownership_matrix_admin", UserRole::User, UserStatus::Active, )) .await .unwrap(); let pending_target = user_repo .create(&make_user( "ownership_matrix_pending", UserRole::User, UserStatus::Active, )) .await .unwrap(); let settings = RoomSettings { require_approval: RequireApproval(true), ..Default::default() }; let room = room_service .create_room( "Ownership Matrix Room".to_string(), String::new(), owner.id, None, Some(settings), ) .await .unwrap(); room_service .add_member(room.id, owner.id, room_admin.id, RoomRole::Admin, false) .await .unwrap(); room_service .join_room(room.id, pending_target.id, None) .await .unwrap(); let codec = public_id_codec(); let room_public_id = codec.encode_room_id(room.id).unwrap(); let room_admin_public_id = codec.encode_user_id(room_admin.id).unwrap(); let pending_target_public_id = codec.encode_user_id(pending_target.id).unwrap(); let non_owner_error = client_api .transfer_room_ownership( &room_admin.id, &room_public_id, synctv_proto::client::TransferRoomOwnershipRequest { new_owner_user_id: room_admin_public_id.clone(), }, ) .await .expect_err("only creator should be able to transfer ownership"); assert!( matches!(non_owner_error, ApiError::Authorization(ref message) if message.contains("Only the current room owner")), "non-owner transfer must fail, got: {non_owner_error:?}" ); let pending_target_error = client_api .transfer_room_ownership( &owner.id, &room_public_id, synctv_proto::client::TransferRoomOwnershipRequest { new_owner_user_id: pending_target_public_id, }, ) .await .expect_err("pending membership cannot receive ownership"); assert!( matches!(pending_target_error, ApiError::InvalidInput(ref message) if message.contains("active member")), "ownership transfer target must be an active member, got: {pending_target_error:?}" ); let response = client_api .transfer_room_ownership( &owner.id, &room_public_id, synctv_proto::client::TransferRoomOwnershipRequest { new_owner_user_id: room_admin_public_id.clone(), }, ) .await .unwrap(); let updated_room = response; assert_eq!(updated_room.created_by, room_admin_public_id); let new_owner_member = room_service .get_member(&room.id, &room_admin.id) .await .unwrap() .expect("new owner member"); assert_eq!(new_owner_member.role, RoomRole::Creator); let old_owner_member = room_service .get_member(&room.id, &owner.id) .await .unwrap() .expect("old owner member"); assert_eq!(old_owner_member.role, RoomRole::Admin); } #[tokio::test] #[ignore = "Requires Docker"] async fn test_room_state_filters_and_member_count_ignore_pending_and_banned_members() { let (_postgres, pool) = synctv_core_testing::create_test_pool().await; let user_repo = UserRepository::new(pool.clone()); let room_repo = RoomRepository::new(pool.clone()); let member_repo = RoomMemberRepository::new(pool.clone()); let user_service = Arc::new(make_user_service(&pool)); let room_service = Arc::new( RoomService::new_for_tests(pool.clone(), (*user_service).clone()) .expect("room service should build"), ); let client_api = make_client_api(user_service, room_service.clone()); let admin_api = make_admin_api(pool.clone()).await; let root_admin = user_repo .create(&make_user( "rooms_matrix_root", UserRole::Root, UserStatus::Active, )) .await .unwrap(); let actor = user_repo .create(&make_user( "rooms_matrix_actor", UserRole::User, UserStatus::Active, )) .await .unwrap(); let external_owner = user_repo .create(&make_user( "rooms_matrix_external_owner", UserRole::User, UserStatus::Active, )) .await .unwrap(); let active_peer = user_repo .create(&make_user( "rooms_matrix_active_peer", UserRole::User, UserStatus::Active, )) .await .unwrap(); let pending_peer = user_repo .create(&make_user( "rooms_matrix_pending_peer", UserRole::User, UserStatus::Active, )) .await .unwrap(); let kicked_peer = user_repo .create(&make_user( "rooms_matrix_kicked_peer", UserRole::User, UserStatus::Active, )) .await .unwrap(); let _public_room = room_service .create_room( "Matrix Public Room".to_string(), "public room".to_string(), actor.id, None, None, ) .await .unwrap(); let pending_room = room_service .create_room( "Matrix Pending Room".to_string(), "pending room".to_string(), actor.id, None, None, ) .await .unwrap(); let rejected_room = room_service .create_room( "Matrix Rejected Room".to_string(), "rejected room".to_string(), actor.id, None, None, ) .await .unwrap(); let closed_room = room_service .create_room( "Matrix Closed Room".to_string(), "closed room".to_string(), actor.id, None, None, ) .await .unwrap(); let banned_room = room_service .create_room( "Matrix Banned Room".to_string(), "banned room".to_string(), actor.id, None, None, ) .await .unwrap(); let approval_settings = RoomSettings { require_approval: RequireApproval(true), ..Default::default() }; let joined_room = room_service .create_room( "Matrix Joined Count Room".to_string(), "joined room".to_string(), external_owner.id, None, Some(approval_settings), ) .await .unwrap(); room_repo .update_status(&pending_room.id, RoomStatus::Closed) .await .unwrap(); room_repo .update_status(&rejected_room.id, RoomStatus::Closed) .await .unwrap(); room_repo .update_status(&closed_room.id, RoomStatus::Closed) .await .unwrap(); room_repo .update_ban_status(&banned_room.id, true) .await .unwrap(); room_service .join_room(joined_room.id, actor.id, None) .await .unwrap(); let actor_join_request_id = sqlx::query_scalar!( r#" SELECT id FROM room_join_requests WHERE room_id = $1 AND user_id = $2 AND reviewed_at IS NULL "#, joined_room.id.as_i64(), actor.id.as_i64() ) .fetch_one(&pool) .await .unwrap(); room_service .approve_join_request_with_outbox( joined_room.id, external_owner.id, ReviewRequestId::expect_positive(actor_join_request_id), None, ) .await .unwrap(); room_service .add_member( joined_room.id, external_owner.id, active_peer.id, RoomRole::Member, false, ) .await .unwrap(); room_service .join_room(joined_room.id, pending_peer.id, None) .await .unwrap(); room_service .add_member( joined_room.id, external_owner.id, kicked_peer.id, RoomRole::Member, false, ) .await .unwrap(); room_service .kick_member(joined_room.id, external_owner.id, kicked_peer.id, 300) .await .unwrap(); let joined_room_public_id = public_id_codec() .encode_room_id(joined_room.id) .expect("joined room public id"); client_api .favorite_room( &actor.id, synctv_proto::client::FavoriteRoomRequest { room_id: joined_room_public_id.clone(), }, ) .await .unwrap(); let authenticated_rooms = client_api .discover_rooms( &actor.id, synctv_proto::client::DiscoverRoomsRequest { page: 1, page_size: 20, search: "Matrix".to_string(), category_id: String::new(), label_ids: Vec::new(), }, ) .await .unwrap(); let joined_discovery = authenticated_rooms .featured_rooms .iter() .chain(authenticated_rooms.rooms.iter()) .find(|item| { item.room .as_ref() .is_some_and(|room| room.id == joined_room_public_id) }) .expect("authenticated discovery should contain joined room"); assert!(joined_discovery.joined); assert!(joined_discovery.favorited); assert!(!joined_discovery.can_join); assert_eq!( joined_discovery.access, synctv_proto::client::RoomDiscoveryAccess::Enter as i32 ); let public_rooms = client_api .discover_public_rooms(synctv_proto::client::DiscoverRoomsRequest { page: 1, page_size: 20, search: "Matrix".to_string(), category_id: String::new(), label_ids: Vec::new(), }) .await .unwrap(); let public_names: Vec<&str> = public_rooms .rooms .iter() .filter_map(|item| item.room.as_ref().map(|room| room.name.as_str())) .collect(); assert!(public_names.contains(&"Matrix Public Room")); assert!(public_names.contains(&"Matrix Joined Count Room")); assert!( !public_names.contains(&"Matrix Pending Room") && !public_names.contains(&"Matrix Rejected Room") && !public_names.contains(&"Matrix Closed Room") && !public_names.contains(&"Matrix Banned Room"), "public discovery must expose only active, non-banned rooms" ); let pending_only = client_api .list_my_rooms( &actor.id, synctv_proto::client::ListMyRoomsRequest { page: 1, page_size: 20, search: "Matrix".to_string(), status: synctv_proto::common::RoomStatus::Closed as i32, is_banned: None, relation: synctv_proto::client::MyRoomRelation::Created as i32, sort_by: synctv_proto::client::MyRoomListSortBy::Name as i32, sort_direction: synctv_proto::client::SortDirection::Asc as i32, }, ) .await .unwrap(); assert_eq!(pending_only.total, 3); let codec = public_id_codec(); let closed_room_ids: Vec<_> = pending_only .rooms .iter() .map(|room| room.room.as_ref().unwrap().id.clone()) .collect(); assert!(closed_room_ids.contains(&codec.encode_room_id(pending_room.id).unwrap())); assert!(closed_room_ids.contains(&codec.encode_room_id(rejected_room.id).unwrap())); assert!(closed_room_ids.contains(&codec.encode_room_id(closed_room.id).unwrap())); let banned_only = client_api .list_my_rooms( &actor.id, synctv_proto::client::ListMyRoomsRequest { page: 1, page_size: 20, search: "Matrix".to_string(), status: synctv_proto::common::RoomStatus::Unspecified as i32, is_banned: Some(true), relation: synctv_proto::client::MyRoomRelation::Created as i32, sort_by: synctv_proto::client::MyRoomListSortBy::Name as i32, sort_direction: synctv_proto::client::SortDirection::Asc as i32, }, ) .await .unwrap(); assert_eq!(banned_only.total, 1); assert_eq!( banned_only.rooms[0].room.as_ref().unwrap().id, codec.encode_room_id(banned_room.id).unwrap() ); assert!( banned_only.rooms[0].room.as_ref().unwrap().is_banned, "list_my_rooms must return the same active ban state it filters by" ); let participating_room = client_api .list_my_rooms( &actor.id, synctv_proto::client::ListMyRoomsRequest { page: 1, page_size: 20, search: "Joined Count".to_string(), status: synctv_proto::common::RoomStatus::Unspecified as i32, is_banned: Some(false), relation: synctv_proto::client::MyRoomRelation::Participating as i32, sort_by: synctv_proto::client::MyRoomListSortBy::Name as i32, sort_direction: synctv_proto::client::SortDirection::Asc as i32, }, ) .await .unwrap(); assert_eq!(participating_room.total, 1); assert_eq!( participating_room.rooms[0] .room .as_ref() .unwrap() .member_count, 3, "member_count must include only active members: owner + actor + active_peer" ); let admin_user_rooms = admin_api .get_user_rooms(synctv_proto::admin::GetUserRoomsRequest { user_id: codec.encode_user_id(actor.id).unwrap(), page: 1, page_size: 20, status: synctv_proto::common::RoomStatus::Unspecified as i32, search: "Joined Count".to_string(), is_banned: Some(false), sort_by: synctv_proto::admin::RoomListSortBy::Name as i32, sort_direction: synctv_proto::admin::SortDirection::Asc as i32, }) .await .unwrap(); assert_eq!(admin_user_rooms.total, 1); assert_eq!( admin_user_rooms.rooms[0].id, codec.encode_room_id(joined_room.id).unwrap() ); assert_eq!( admin_user_rooms.rooms[0].member_count, 3, "admin related-room listing must use the same active-member count semantics" ); let joined_members = member_repo.list_by_room_all(&joined_room.id).await.unwrap(); assert_eq!( joined_members.len(), 3, "fixture should contain only active membership rows" ); let _ = root_admin; } #[tokio::test] #[ignore = "Requires Docker"] async fn test_admin_user_lifecycle_and_role_hierarchy_matrix() { let (_postgres, pool) = synctv_core_testing::create_test_pool().await; let user_repo = UserRepository::new(pool.clone()); let admin_api = make_admin_api(pool.clone()).await; let root = user_repo .create(&make_user( "user_matrix_root", UserRole::Root, UserStatus::Active, )) .await .unwrap(); let platform_admin = user_repo .create(&make_user( "user_matrix_admin", UserRole::Admin, UserStatus::Active, )) .await .unwrap(); let regular_user = user_repo .create(&make_user( "user_matrix_regular", UserRole::User, UserStatus::Active, )) .await .unwrap(); let pending_registration_id = UserId::new(); sqlx::query!( r" INSERT INTO user_registration_requests ( id, username, email, opaque_record, opaque_credential_identifier, opaque_ciphersuite, opaque_server_setup_version, signup_method, status ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ", pending_registration_id.as_i64(), "user_matrix_pending", "user_matrix_pending@test.com", b"opaque-record".as_slice(), b"opaque-id".as_slice(), "opaque-ristretto255-sha512-argon2id", 1_i32, i16::from(SignupMethod::Email), i16::from(ReviewStatus::Pending) ) .execute(&pool) .await .unwrap(); let admin_ban_error = admin_api .ban_user( synctv_proto::admin::BanUserRequest { user_id: public_id_codec().encode_user_id(platform_admin.id).unwrap(), reason: "role hierarchy".to_string(), }, &platform_admin.id, UserRole::Admin, &RequestContext::default(), ) .await .expect_err("admin must not be allowed to ban another admin"); assert!( matches!(admin_ban_error, ApiError::Authorization(ref message) if message.contains("Only root users can ban admin users")), "admin-role hierarchy must be enforced, got: {admin_ban_error:?}" ); let banned_regular = admin_api .ban_user( synctv_proto::admin::BanUserRequest { user_id: public_id_codec().encode_user_id(regular_user.id).unwrap(), reason: "coverage".to_string(), }, &platform_admin.id, UserRole::Admin, &RequestContext::default(), ) .await .unwrap(); assert_eq!( banned_regular.status, synctv_proto::common::UserStatus::Banned as i32 ); assert!(banned_regular.is_banned); let unbanned_regular = admin_api .unban_user( synctv_proto::admin::UnbanUserRequest { user_id: public_id_codec().encode_user_id(regular_user.id).unwrap(), }, &platform_admin.id, &RequestContext::default(), ) .await .unwrap(); assert_eq!( unbanned_regular.status, synctv_proto::common::UserStatus::Active as i32 ); assert!(!unbanned_regular.is_banned); let approved_pending = admin_api .approve_user_registration_review( synctv_proto::admin::ApproveUserRegistrationReviewRequest { request_id: public_id_codec() .encode_user_id(pending_registration_id) .unwrap(), }, &root.id, &RequestContext::default(), ) .await .unwrap() .user .expect("approved user"); assert_eq!( approved_pending.status, synctv_proto::common::UserStatus::Active as i32 ); let approve_active_error = admin_api .approve_user_registration_review( synctv_proto::admin::ApproveUserRegistrationReviewRequest { request_id: public_id_codec().encode_user_id(regular_user.id).unwrap(), }, &root.id, &RequestContext::default(), ) .await .expect_err("active user must not be approved again"); assert!( matches!(approve_active_error, ApiError::NotFound(ref message) if message.contains("Pending registration request")), "approve review should reject non-pending targets, got: {approve_active_error:?}" ); } #[tokio::test] #[ignore = "Requires Docker"] async fn test_join_room_rejects_banned_rooms() { let (_postgres, pool) = synctv_core_testing::create_test_pool().await; let user_repo = UserRepository::new(pool.clone()); let room_repo = RoomRepository::new(pool.clone()); let user_service = Arc::new(make_user_service(&pool)); let room_service = Arc::new( RoomService::new_for_tests(pool.clone(), (*user_service).clone()) .expect("room service should build"), ); let client_api = make_client_api(user_service, room_service.clone()); let owner = user_repo .create(&make_user( "join_banned_owner", UserRole::User, UserStatus::Active, )) .await .unwrap(); let joiner = user_repo .create(&make_user( "join_banned_joiner", UserRole::User, UserStatus::Active, )) .await .unwrap(); let room = room_service .create_room( "Join Banned Room".to_string(), String::new(), owner.id, None, None, ) .await .unwrap(); room_repo.update_ban_status(&room.id, true).await.unwrap(); let error = client_api .join_room_with_control( &joiner.id, &public_id_codec().encode_room_id(room.id).unwrap(), synctv_proto::client::JoinRoomRequest { room_id: public_id_codec().encode_room_id(room.id).unwrap(), password: String::new(), remark_name: String::new(), display_tag: String::new(), }, None, None, ) .await .expect_err("banned room must reject join_room"); assert!( matches!(error, ApiError::Authorization(ref message) if message.contains("Room is banned")), "join_room must reject banned rooms explicitly, got: {error:?}" ); }