mirror of https://github.com/synctv-org/synctv
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
417 lines
13 KiB
Rust
417 lines
13 KiB
Rust
//! RTMP authentication integration tests
|
|
//!
|
|
//! Tests the core RTMP authentication components with real `PostgreSQL` and Redis via testcontainers.
|
|
//!
|
|
//! Run with ignored tests: cargo test -p synctv-core --test `rtmp_auth_integration_tests` -- --ignored
|
|
//!
|
|
//! # Test Coverage
|
|
//!
|
|
//! - Banned/deleted user handling
|
|
//! - Banned/pending room handling
|
|
//! - Cross-replica user→stream mapping (Redis)
|
|
//!
|
|
//! # Requirements
|
|
//!
|
|
//! - Docker for testcontainers (`PostgreSQL` + Redis)
|
|
|
|
use std::sync::Arc;
|
|
|
|
use synctv_core::{
|
|
cache::{KeyBuilder, UsernameCache},
|
|
models::{
|
|
MediaId, Room, RoomId, RoomMember, RoomRole, RoomSettings, RoomStatus, SignupMethod, User,
|
|
UserId, UserRole, UserStatus,
|
|
},
|
|
repository::{RoomMemberRepository, RoomRepository, RoomSettingsRepository, UserRepository},
|
|
service::{
|
|
BruteForceProtection, InMemoryTokenBlacklistStore, JwtService, PublishKeyService,
|
|
RoomService, UserService,
|
|
},
|
|
};
|
|
use synctv_core_testing::{create_test_pool, ok, some, start_redis, RedisContainer, TestContainer};
|
|
|
|
async fn create_test_infra() -> (
|
|
TestContainer,
|
|
RedisContainer,
|
|
sqlx::PgPool,
|
|
redis::aio::ConnectionManager,
|
|
) {
|
|
let (postgres, pool) = create_test_pool().await;
|
|
let (redis, redis_conn) = start_redis().await;
|
|
|
|
(postgres, redis, pool, redis_conn)
|
|
}
|
|
|
|
fn create_jwt_service() -> JwtService {
|
|
ok(
|
|
JwtService::new("test-secret-key-for-rtmp-auth-tests-minimum-length-32-chars"),
|
|
"test JWT service should initialize",
|
|
)
|
|
}
|
|
|
|
fn create_user_service(pool: &sqlx::PgPool) -> UserService {
|
|
let jwt_service = create_jwt_service();
|
|
let username_cache = UsernameCache::local_only("test:username:".to_string(), 100, 60);
|
|
let token_blacklist = Arc::new(InMemoryTokenBlacklistStore::new(1000, 3600, 86400));
|
|
let key_builder = KeyBuilder::new("test");
|
|
let brute_force = BruteForceProtection::in_memory("test".to_string());
|
|
|
|
UserService::new_for_tests(
|
|
pool,
|
|
jwt_service,
|
|
username_cache,
|
|
token_blacklist,
|
|
key_builder,
|
|
brute_force,
|
|
)
|
|
}
|
|
|
|
fn create_room_service(pool: sqlx::PgPool) -> RoomService {
|
|
let user_service = create_user_service(&pool);
|
|
|
|
ok(
|
|
RoomService::new_for_tests(pool, user_service),
|
|
"room service should build",
|
|
)
|
|
}
|
|
|
|
fn create_publish_key_service() -> PublishKeyService {
|
|
let jwt_service = create_jwt_service();
|
|
ok(
|
|
PublishKeyService::new(jwt_service, Arc::new(synctv_core::SystemClock), 24),
|
|
"publish key service should build",
|
|
)
|
|
}
|
|
|
|
async fn create_test_user(pool: &sqlx::PgPool, username: &str, role: UserRole) -> User {
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let user = User {
|
|
id: UserId::new(),
|
|
username: username.to_string(),
|
|
signup_method: SignupMethod::Email,
|
|
role,
|
|
avatar_file_reference_id: None,
|
|
status: UserStatus::Active,
|
|
created_at: chrono::Utc::now(),
|
|
updated_at: chrono::Utc::now(),
|
|
version: 0,
|
|
deleted_at: None,
|
|
is_banned: false,
|
|
banned_at: None,
|
|
banned_by: None,
|
|
banned_reason: None,
|
|
};
|
|
ok(
|
|
user_repo.create(&user).await,
|
|
"RTMP auth test user should be created",
|
|
)
|
|
}
|
|
|
|
async fn create_test_room(pool: &sqlx::PgPool, creator_id: UserId, name: &str) -> Room {
|
|
let room_repo = RoomRepository::new(pool.clone());
|
|
let room = Room {
|
|
id: RoomId::new(),
|
|
name: name.to_string(),
|
|
description: "Test room".to_string(),
|
|
cover_file_reference_id: None,
|
|
category: None,
|
|
labels: Vec::new(),
|
|
created_by: creator_id,
|
|
status: RoomStatus::Active,
|
|
is_banned: false,
|
|
is_public: true,
|
|
closed_at: None,
|
|
created_at: chrono::Utc::now(),
|
|
updated_at: chrono::Utc::now(),
|
|
deleted_at: None,
|
|
version: 0,
|
|
last_activity_at: chrono::Utc::now(),
|
|
};
|
|
let room = ok(
|
|
room_repo.create(&room).await,
|
|
"RTMP auth test room should be created",
|
|
);
|
|
|
|
let settings_repo = RoomSettingsRepository::new(pool.clone());
|
|
let settings = RoomSettings::default();
|
|
ok(
|
|
settings_repo
|
|
.set_settings_with_version(&room.id, &settings, 0)
|
|
.await,
|
|
"RTMP auth room settings should be created",
|
|
);
|
|
|
|
// Add creator as room member
|
|
let member_repo = RoomMemberRepository::new(pool.clone());
|
|
let member = RoomMember {
|
|
room_id: room.id,
|
|
user_id: creator_id,
|
|
role: RoomRole::Creator,
|
|
added_permissions: 0,
|
|
removed_permissions: 0,
|
|
admin_added_permissions: 0,
|
|
admin_removed_permissions: 0,
|
|
remark_name: String::new(),
|
|
display_tag: String::new(),
|
|
joined_at: chrono::Utc::now(),
|
|
version: 0,
|
|
};
|
|
ok(
|
|
member_repo.add(&member).await,
|
|
"RTMP auth room creator membership should be created",
|
|
);
|
|
|
|
room
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn rtmp_auth_test_banned_user_validation() {
|
|
let (_postgres, _redis, pool, _redis_conn) = create_test_infra().await;
|
|
|
|
let user = create_test_user(&pool, "banned_user", UserRole::User).await;
|
|
let room = create_test_room(&pool, user.id, "Ban Room").await;
|
|
let media_id = MediaId::new();
|
|
|
|
// Generate publish key before banning
|
|
let publish_key_service = create_publish_key_service();
|
|
let key = ok(
|
|
publish_key_service.generate_publish_key(&room.id, &media_id, &user.id),
|
|
"publish key should be generated",
|
|
);
|
|
|
|
// Ban the user
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
ok(
|
|
user_repo
|
|
.ban(&user.id, None, Some("rtmp auth test".to_string()))
|
|
.await,
|
|
"RTMP auth user should be banned",
|
|
);
|
|
|
|
// Token should still be valid at the JWT level (user status is checked separately)
|
|
let _claims = ok(
|
|
publish_key_service.validate_publish_key(&key.token).await,
|
|
"publish key token should validate before user status check",
|
|
);
|
|
|
|
// But RTMP auth should reject based on user status
|
|
let user_service = Arc::new(create_user_service(&pool));
|
|
let updated_user = ok(
|
|
user_service.get_user(&user.id).await,
|
|
"banned user should be loaded",
|
|
);
|
|
|
|
assert_eq!(updated_user.status, UserStatus::Banned);
|
|
assert!(ok(
|
|
user_repo.is_banned(&user.id).await,
|
|
"banned user status lookup should succeed"
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn rtmp_auth_test_deleted_user_validation() {
|
|
let (_postgres, _redis, pool, _redis_conn) = create_test_infra().await;
|
|
|
|
let user = create_test_user(&pool, "deleted_user", UserRole::User).await;
|
|
let room = create_test_room(&pool, user.id, "Delete Room").await;
|
|
let media_id = MediaId::new();
|
|
|
|
// Generate publish key before deletion
|
|
let publish_key_service = create_publish_key_service();
|
|
let key = ok(
|
|
publish_key_service.generate_publish_key(&room.id, &media_id, &user.id),
|
|
"publish key should be generated",
|
|
);
|
|
|
|
// Soft-delete the user via the repository's delete method
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let deleted = ok(
|
|
user_repo.delete(&user.id).await,
|
|
"RTMP auth user should be soft-deleted",
|
|
);
|
|
assert!(deleted, "delete should have affected one row");
|
|
|
|
// Token should still be valid at the JWT level
|
|
let _claims = ok(
|
|
publish_key_service.validate_publish_key(&key.token).await,
|
|
"publish key token should validate before deleted-user check",
|
|
);
|
|
|
|
// But RTMP auth should reject based on deleted_at:
|
|
// get_user filters out soft-deleted users (WHERE deleted_at IS NULL),
|
|
// so the deleted user should not be found.
|
|
let user_service = Arc::new(create_user_service(&pool));
|
|
let result = user_service.get_user(&user.id).await;
|
|
assert!(
|
|
result.is_err(),
|
|
"Soft-deleted user should not be found by get_user"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn rtmp_auth_test_banned_room_rejects_operations() {
|
|
let (_postgres, _redis, pool, _redis_conn) = create_test_infra().await;
|
|
|
|
let user = create_test_user(&pool, "room_user", UserRole::User).await;
|
|
let room = create_test_room(&pool, user.id, "Test Room").await;
|
|
|
|
// Ban the room
|
|
let room_repo = RoomRepository::new(pool.clone());
|
|
ok(
|
|
room_repo.update_ban_status(&room.id, true).await,
|
|
"RTMP auth room should be banned",
|
|
);
|
|
|
|
// Reload and verify
|
|
let reloaded_room = some(
|
|
ok(
|
|
room_repo.get_by_id(&room.id).await,
|
|
"banned room should be loaded",
|
|
),
|
|
"banned room should exist",
|
|
);
|
|
|
|
assert!(reloaded_room.is_banned, "Room should be banned");
|
|
|
|
// Room service should return banned status
|
|
let room_service = create_room_service(pool.clone());
|
|
let loaded_room = ok(
|
|
room_service.get_room(&room.id).await,
|
|
"banned room should load via service",
|
|
);
|
|
|
|
assert!(
|
|
loaded_room.is_banned,
|
|
"Room loaded via service should be banned"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn rtmp_auth_test_closed_room_lifecycle_is_persisted() {
|
|
let (_postgres, _redis, pool, _redis_conn) = create_test_infra().await;
|
|
|
|
let user = create_test_user(&pool, "pending_user", UserRole::User).await;
|
|
let mut room = create_test_room(&pool, user.id, "Closed Room").await;
|
|
|
|
room.close();
|
|
let room_repo = RoomRepository::new(pool.clone());
|
|
ok(
|
|
room_repo.update(&room, room.version).await,
|
|
"RTMP auth room should be closed",
|
|
);
|
|
|
|
// Reload and verify
|
|
let reloaded_room = some(
|
|
ok(
|
|
room_repo.get_by_id(&room.id).await,
|
|
"closed room should be loaded",
|
|
),
|
|
"closed room should exist",
|
|
);
|
|
|
|
assert_eq!(
|
|
reloaded_room.status,
|
|
RoomStatus::Closed,
|
|
"Room should be closed"
|
|
);
|
|
|
|
// Room service should return closed lifecycle status
|
|
let room_service = create_room_service(pool.clone());
|
|
let loaded_room = ok(
|
|
room_service.get_room(&room.id).await,
|
|
"closed room should load via service",
|
|
);
|
|
|
|
assert_eq!(loaded_room.status, RoomStatus::Closed);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn rtmp_auth_test_cross_replica_user_stream_mapping() {
|
|
let (_postgres, _redis, _pool, redis_conn) = create_test_infra().await;
|
|
|
|
let user_id = UserId::new();
|
|
let room_id = RoomId::new();
|
|
let media_id = MediaId::new();
|
|
|
|
// Simulate writing user stream mapping to Redis using per-user key with | separator
|
|
let stream_value = format!("{room_id}|{media_id}");
|
|
let redis_key = format!("synctv:rtmp:user_stream:{user_id}");
|
|
|
|
let mut conn = redis_conn.clone();
|
|
let _: () = ok(
|
|
redis::cmd("SET")
|
|
.arg(&redis_key)
|
|
.arg(&stream_value)
|
|
.query_async(&mut conn)
|
|
.await,
|
|
"user stream mapping should be written to Redis",
|
|
);
|
|
|
|
// Simulate reading user stream mapping from Redis
|
|
let result: Option<String> = ok(
|
|
redis::cmd("GET")
|
|
.arg(&redis_key)
|
|
.query_async(&mut conn)
|
|
.await,
|
|
"user stream mapping should be read from Redis",
|
|
);
|
|
|
|
assert!(result.is_some(), "Should find user stream mapping");
|
|
|
|
let found_stream_value = some(result, "user stream mapping should exist");
|
|
assert_eq!(found_stream_value, stream_value);
|
|
|
|
// Verify we can parse it back using | separator
|
|
let (parsed_room_id, parsed_media_id) = found_stream_value
|
|
.split_once('|')
|
|
.unwrap_or_else(|| std::panic::panic_any("user stream mapping should contain separator"));
|
|
assert_eq!(parsed_room_id, room_id.to_string());
|
|
assert_eq!(parsed_media_id, media_id.to_string());
|
|
|
|
// Cleanup
|
|
let _: () = ok(
|
|
redis::cmd("DEL")
|
|
.arg(&redis_key)
|
|
.query_async(&mut conn)
|
|
.await,
|
|
"user stream mapping should be deleted from Redis",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn rtmp_auth_test_non_room_member_rejected() {
|
|
let (_postgres, _redis, pool, _redis_conn) = create_test_infra().await;
|
|
|
|
let owner = create_test_user(&pool, "room_owner", UserRole::User).await;
|
|
let room = create_test_room(&pool, owner.id, "Private Room").await;
|
|
|
|
let non_member = create_test_user(&pool, "outsider", UserRole::User).await;
|
|
|
|
let room_service = create_room_service(pool.clone());
|
|
let publish_key_service = create_publish_key_service();
|
|
|
|
// Generate publish key for non-member
|
|
let media_id = MediaId::new();
|
|
let _key = ok(
|
|
publish_key_service.generate_publish_key(&room.id, &media_id, &non_member.id),
|
|
"publish key should be generated",
|
|
);
|
|
|
|
// Verify non-member is not in the room
|
|
let member_result = room_service
|
|
.member_service()
|
|
.get_member(&room.id, &non_member.id)
|
|
.await;
|
|
|
|
assert!(
|
|
member_result.map_or(true, |member| member.is_none()),
|
|
"Non-member should not be found in room membership"
|
|
);
|
|
}
|