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.
4886 lines
182 KiB
Rust
4886 lines
182 KiB
Rust
//! WebSocket integration tests for synctv-api
|
|
//!
|
|
//! Tests WebSocket-related types, authentication methods, query parameter
|
|
//! parsing, proto codec encoding/decoding, and message type handling.
|
|
//!
|
|
//! Includes both:
|
|
//! - Unit tests: validate individual components in isolation (no server needed)
|
|
//! - E2E tests: full WebSocket lifecycle with real Postgres + Redis (`TestInfra`)
|
|
|
|
#![allow(clippy::unwrap_used)]
|
|
use synctv_proto::client::WebSocketConnectRequest;
|
|
|
|
async fn wait_for_condition<F>(timeout: std::time::Duration, mut check: F)
|
|
where
|
|
F: FnMut() -> bool,
|
|
{
|
|
let deadline = std::time::Instant::now() + timeout;
|
|
while std::time::Instant::now() < deadline {
|
|
if check() {
|
|
return;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
assert!(check(), "condition was not satisfied within {timeout:?}");
|
|
}
|
|
|
|
async fn await_server_shutdown(
|
|
shutdown_tx: tokio::sync::oneshot::Sender<()>,
|
|
server_handle: tokio::task::JoinHandle<()>,
|
|
) {
|
|
let _ = shutdown_tx.send(());
|
|
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), server_handle).await;
|
|
}
|
|
|
|
mod ws_query {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_deserialize_rejects_unknown_params() {
|
|
#[derive(Debug, serde::Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct Query {
|
|
#[serde(default, rename = "ticket")]
|
|
_ticket: String,
|
|
}
|
|
|
|
let params = "ticket=tix&unknown=value";
|
|
let error = serde_urlencoded::from_str::<Query>(params).unwrap_err();
|
|
assert!(error.to_string().contains("unknown"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_deserialize_invalid_ticket_still_needs_proto_validation() {
|
|
let params = "ticket=bad%20ticket";
|
|
let query: WebSocketConnectRequest = serde_urlencoded::from_str(params).unwrap();
|
|
assert_eq!(query.ticket, "bad ticket");
|
|
let error = synctv_proto::validate(&query).expect_err("ticket format must be invalid");
|
|
assert!(error.to_string().contains("ticket"));
|
|
}
|
|
}
|
|
|
|
mod proto_codec {
|
|
use prost::Message;
|
|
use synctv_api::ProtoCodec;
|
|
use synctv_proto::client::{server_message, HeartbeatAck, ServerMessage};
|
|
|
|
#[test]
|
|
fn test_encode_server_message() {
|
|
let msg = ServerMessage {
|
|
message: Some(server_message::Message::HeartbeatAck(HeartbeatAck {
|
|
timestamp: 123_456,
|
|
})),
|
|
};
|
|
let bytes = ProtoCodec::encode_server_message(&msg).unwrap();
|
|
let decoded = ServerMessage::decode(bytes.as_slice()).expect("encoded server message");
|
|
assert_eq!(decoded, msg);
|
|
}
|
|
|
|
#[test]
|
|
fn test_decode_empty_bytes_client_message() {
|
|
// Empty bytes should produce a default ClientMessage (protobuf convention)
|
|
let result = ProtoCodec::decode_client_message(&[]);
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_decode_garbage_bytes_returns_error() {
|
|
// Random garbage should fail to decode
|
|
let result = ProtoCodec::decode_client_message(&[0xFF, 0xFE, 0xFD, 0xFC, 0xFB]);
|
|
assert!(result.is_err());
|
|
}
|
|
}
|
|
|
|
mod ticket_types {
|
|
use synctv_proto::client::CreateWebSocketTicketResponse;
|
|
|
|
#[test]
|
|
fn test_ticket_response_serializes() {
|
|
let resp = CreateWebSocketTicketResponse {
|
|
ticket: "ticket_abc123".to_string(),
|
|
room_id: "room_abc".to_string(),
|
|
expires_in_secs: 30,
|
|
usage: "Use in WebSocket URL".to_string(),
|
|
};
|
|
let json = serde_json::to_value(&resp).unwrap();
|
|
assert_eq!(json["ticket"], "ticket_abc123");
|
|
assert_eq!(json["roomId"], "room_abc");
|
|
assert_eq!(json["expiresInSecs"], "30");
|
|
assert!(json["usage"].as_str().unwrap().contains("WebSocket"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ticket_response_usage_points_to_registered_websocket_route() {
|
|
let room_id = "room_abc";
|
|
let resp = CreateWebSocketTicketResponse {
|
|
ticket: "ticket_abc123".to_string(),
|
|
room_id: room_id.to_string(),
|
|
expires_in_secs: 30,
|
|
usage: format!("Use in WebSocket URL: ws://host/ws/rooms/{room_id}?ticket=xxx"),
|
|
};
|
|
|
|
assert!(
|
|
resp.usage.contains("/ws/rooms/"),
|
|
"ticket usage must point at the registered websocket route"
|
|
);
|
|
assert!(
|
|
!resp.usage.contains("/ws/room/"),
|
|
"removed singular websocket route must not appear in ticket usage"
|
|
);
|
|
}
|
|
}
|
|
|
|
mod jwt_auth {
|
|
use std::sync::Arc;
|
|
use synctv_core::models::id::UserId;
|
|
use synctv_core::service::JwtService;
|
|
use synctv_core::service::JwtValidator;
|
|
use synctv_core::service::TokenCredentialBinding;
|
|
use synctv_core::service::TokenType;
|
|
|
|
// Use a 32+ character secret for testing
|
|
const TEST_SECRET: &str = "this-is-a-test-secret-with-enough-entropy-for-jwt-signing-32chars";
|
|
|
|
fn test_jwt_service() -> JwtService {
|
|
JwtService::new(TEST_SECRET).expect("JwtService creation should succeed")
|
|
}
|
|
|
|
fn sign_test_refresh_token(svc: &JwtService, user_id: &UserId) -> String {
|
|
svc.sign_refresh_token_with_session(
|
|
user_id,
|
|
0,
|
|
None,
|
|
"websocket-refresh-session",
|
|
&TokenCredentialBinding::Password { version: 0 },
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn test_validator() -> JwtValidator {
|
|
JwtValidator::new(Arc::new(test_jwt_service()))
|
|
}
|
|
|
|
#[test]
|
|
fn test_sign_access_token() {
|
|
let svc = test_jwt_service();
|
|
let user_id = UserId::expect_positive(10_000_013);
|
|
let token = svc.sign_access_token(&user_id, 0).unwrap();
|
|
assert!(!token.is_empty());
|
|
// JWT has 3 parts separated by dots
|
|
assert_eq!(token.split('.').count(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sign_refresh_token() {
|
|
let svc = test_jwt_service();
|
|
let user_id = UserId::expect_positive(10_000_014);
|
|
let token = sign_test_refresh_token(&svc, &user_id);
|
|
assert!(!token.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_access_token() {
|
|
let svc = test_jwt_service();
|
|
let user_id = UserId::expect_positive(10_000_015);
|
|
let token = svc.sign_access_token(&user_id, 0).unwrap();
|
|
|
|
let claims = svc.verify_access_token(&token).unwrap();
|
|
assert_eq!(claims.user_id(), user_id);
|
|
assert_eq!(claims.token_type(), TokenType::Access);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_refresh_token() {
|
|
let svc = test_jwt_service();
|
|
let user_id = UserId::expect_positive(10_000_016);
|
|
let token = sign_test_refresh_token(&svc, &user_id);
|
|
|
|
let claims = svc.verify_refresh_token(&token).unwrap();
|
|
assert_eq!(claims.user_id(), user_id);
|
|
assert_eq!(claims.token_type(), TokenType::Refresh);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_with_wrong_secret_fails() {
|
|
let svc1 = test_jwt_service();
|
|
let svc2 = JwtService::new("another-secret-that-is-different-and-long-enough-for-the-test")
|
|
.unwrap();
|
|
|
|
let user_id = UserId::expect_positive(10_000_017);
|
|
let token = svc1.sign_access_token(&user_id, 0).unwrap();
|
|
|
|
// Verification with different secret should fail
|
|
assert!(svc2.verify_access_token(&token).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_invalid_token_fails() {
|
|
let svc = test_jwt_service();
|
|
assert!(svc.verify_token("not.a.valid.jwt").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_empty_token_fails() {
|
|
let svc = test_jwt_service();
|
|
assert!(svc.verify_token("").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validator_extract_user_id_from_bearer() {
|
|
let svc = test_jwt_service();
|
|
let validator = test_validator();
|
|
let user_id = UserId::expect_positive(10_000_018);
|
|
let token = svc.sign_access_token(&user_id, 0).unwrap();
|
|
let _bearer = format!("Bearer {token}");
|
|
|
|
let extracted = validator.validate_and_extract_user_id(&token).unwrap();
|
|
assert_eq!(extracted, user_id);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validator_http_bearer_header() {
|
|
let svc = test_jwt_service();
|
|
let validator = test_validator();
|
|
let user_id = UserId::expect_positive(10_000_019);
|
|
let token = svc.sign_access_token(&user_id, 0).unwrap();
|
|
let header = format!("Bearer {token}");
|
|
|
|
let claims = validator.validate_authorization_header(&header).unwrap();
|
|
assert_eq!(claims.user_id(), user_id);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validator_rejects_missing_bearer_prefix() {
|
|
let svc = test_jwt_service();
|
|
let validator = test_validator();
|
|
let user_id = UserId::expect_positive(10_000_020);
|
|
let token = svc.sign_access_token(&user_id, 0).unwrap();
|
|
|
|
// Without "Bearer " prefix
|
|
assert!(validator.validate_authorization_header(&token).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_access_token_has_jti() {
|
|
let svc = test_jwt_service();
|
|
let user_id = UserId::expect_positive(10_000_021);
|
|
let token = svc.sign_access_token(&user_id, 0).unwrap();
|
|
let claims = svc.verify_access_token(&token).unwrap();
|
|
assert!(!claims.token_id().is_empty(), "JWT ID (jti) should be set");
|
|
}
|
|
|
|
#[test]
|
|
fn test_unique_jti_per_token() {
|
|
let svc = test_jwt_service();
|
|
let user_id = UserId::expect_positive(10_000_022);
|
|
let token1 = svc.sign_access_token(&user_id, 0).unwrap();
|
|
let token2 = svc.sign_access_token(&user_id, 0).unwrap();
|
|
let claims1 = svc.verify_access_token(&token1).unwrap();
|
|
let claims2 = svc.verify_access_token(&token2).unwrap();
|
|
assert_ne!(
|
|
claims1.token_id(),
|
|
claims2.token_id(),
|
|
"Each token should have a unique jti"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_claims_iat_is_recent() {
|
|
let svc = test_jwt_service();
|
|
let user_id = UserId::expect_positive(10_000_023);
|
|
let token = svc.sign_access_token(&user_id, 0).unwrap();
|
|
let claims = svc.verify_access_token(&token).unwrap();
|
|
|
|
let now = chrono::Utc::now().timestamp();
|
|
assert!(
|
|
(now - claims.iat).abs() < 10,
|
|
"iat should be within 10 seconds of now"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_access_token_exp_is_in_future() {
|
|
let svc = test_jwt_service();
|
|
let user_id = UserId::expect_positive(10_000_024);
|
|
let token = svc.sign_access_token(&user_id, 0).unwrap();
|
|
let claims = svc.verify_access_token(&token).unwrap();
|
|
|
|
let now = chrono::Utc::now().timestamp();
|
|
assert!(claims.exp > now, "exp should be in the future");
|
|
}
|
|
}
|
|
|
|
// SecurityPipeline enforces password version and user status checks.
|
|
// Token revocation is stateless: clients discard tokens on logout.
|
|
// Full integration tests require a running database instance.
|
|
|
|
mod rate_limiter {
|
|
use synctv_core::service::RateLimiter;
|
|
|
|
#[tokio::test]
|
|
async fn test_in_memory_allows_within_limit() {
|
|
let limiter = RateLimiter::local_only("test:".to_string());
|
|
let result = limiter.check_rate_limit("test_key", 5, 60).await;
|
|
assert!(result.is_ok(), "First request should be allowed");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_in_memory_blocks_after_exceeding_limit() {
|
|
let limiter = RateLimiter::local_only("test:".to_string());
|
|
let key = "burst_test_key";
|
|
|
|
// Exhaust the limit
|
|
for _ in 0..5 {
|
|
let _ = limiter.check_rate_limit(key, 5, 60).await;
|
|
}
|
|
|
|
// Next request should be rate limited
|
|
let result = limiter.check_rate_limit(key, 5, 60).await;
|
|
assert!(
|
|
result.is_err(),
|
|
"Should be rate limited after exceeding limit"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_different_keys_independent() {
|
|
let limiter = RateLimiter::local_only("test:".to_string());
|
|
|
|
// Exhaust limit for key_a
|
|
for _ in 0..5 {
|
|
let _ = limiter.check_rate_limit("key_a", 5, 60).await;
|
|
}
|
|
|
|
// key_b should still be allowed
|
|
let result = limiter.check_rate_limit("key_b", 5, 60).await;
|
|
assert!(result.is_ok(), "Different key should not be rate limited");
|
|
}
|
|
|
|
#[test]
|
|
fn test_sync_rate_limit_allows_within_limit() {
|
|
let limiter = RateLimiter::local_only("sync_test:".to_string());
|
|
let result = limiter.check_rate_limit_sync("grpc_key", 10, 60);
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_sync_rate_limit_blocks_after_exceeding() {
|
|
let limiter = RateLimiter::local_only("sync_test:".to_string());
|
|
for _ in 0..10 {
|
|
let _ = limiter.check_rate_limit_sync("grpc_burst", 10, 60);
|
|
}
|
|
let result = limiter.check_rate_limit_sync("grpc_burst", 10, 60);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_health_check_without_redis() {
|
|
let limiter = RateLimiter::local_only("test:".to_string());
|
|
let result = limiter.health_check().await;
|
|
assert!(result.is_err(), "Should error when Redis not configured");
|
|
assert!(result.unwrap_err().contains("not configured"));
|
|
}
|
|
}
|
|
|
|
mod health_types {
|
|
use synctv_proto::client::{HealthDetails, HealthResponse};
|
|
|
|
#[test]
|
|
fn test_health_response_ok_serializes() {
|
|
let resp = HealthResponse {
|
|
status: "ok".to_string(),
|
|
details: None,
|
|
};
|
|
let json = serde_json::to_value(&resp).unwrap();
|
|
assert_eq!(json["status"], "ok");
|
|
assert!(json.get("details").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_health_response_with_details() {
|
|
let resp = HealthResponse {
|
|
status: "healthy".to_string(),
|
|
details: Some(HealthDetails {
|
|
database: "healthy".to_string(),
|
|
redis: "healthy".to_string(),
|
|
cluster: None,
|
|
ws_ticket: None,
|
|
email: None,
|
|
livestream: None,
|
|
memory: None,
|
|
message: None,
|
|
webrtc: None,
|
|
}),
|
|
};
|
|
let json = serde_json::to_value(&resp).unwrap();
|
|
assert_eq!(json["status"], "healthy");
|
|
assert_eq!(json["details"]["database"], "healthy");
|
|
assert_eq!(json["details"]["redis"], "healthy");
|
|
assert!(json["details"].get("message").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_health_response_unhealthy_with_message() {
|
|
let resp = HealthResponse {
|
|
status: "unhealthy".to_string(),
|
|
details: Some(HealthDetails {
|
|
database: "unhealthy".to_string(),
|
|
redis: "healthy".to_string(),
|
|
cluster: None,
|
|
ws_ticket: None,
|
|
email: None,
|
|
livestream: None,
|
|
memory: None,
|
|
message: Some("Database: connection refused".to_string()),
|
|
webrtc: None,
|
|
}),
|
|
};
|
|
let json = serde_json::to_value(&resp).unwrap();
|
|
assert_eq!(json["status"], "unhealthy");
|
|
assert_eq!(json["details"]["database"], "unhealthy");
|
|
assert!(json["details"]["message"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("Database"));
|
|
}
|
|
}
|
|
|
|
mod ws_auth_scenarios {
|
|
use axum::http::StatusCode;
|
|
use synctv_api::AppError;
|
|
|
|
#[test]
|
|
fn test_missing_all_auth_methods() {
|
|
let err = AppError::unauthorized(
|
|
"Missing authentication: provide token via Authorization header or ?ticket=",
|
|
);
|
|
assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
|
|
assert!(err.message().contains("Missing authentication"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_token_error() {
|
|
let err = AppError::unauthorized("Invalid or expired token");
|
|
assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
|
|
assert_eq!(err.message(), "Invalid or expired token");
|
|
}
|
|
|
|
#[test]
|
|
fn test_token_revoked_error() {
|
|
let err = AppError::unauthorized("Token has been revoked");
|
|
assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[test]
|
|
fn test_not_member_error() {
|
|
let err = AppError::forbidden("Not a member of this room");
|
|
assert_eq!(err.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[test]
|
|
fn test_expired_ticket_error() {
|
|
let err = AppError::unauthorized("Invalid or expired ticket");
|
|
assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
|
|
assert_eq!(err.message(), "Invalid or expired ticket");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod websocket_e2e {
|
|
use futures::{SinkExt, StreamExt};
|
|
use prost::Message;
|
|
use std::sync::Arc;
|
|
use tokio::net::TcpListener;
|
|
use tokio::task::JoinHandle;
|
|
use tokio_tungstenite::tungstenite;
|
|
|
|
use synctv_api::websocket_handler;
|
|
use synctv_api::ProtoCodec;
|
|
use synctv_core::cache::UsernameCache;
|
|
use synctv_core::models::id::{RoomId, UserId};
|
|
use synctv_core::service::JwtService;
|
|
use synctv_core::service::RateLimiter;
|
|
use synctv_core::service::UserServiceRuntimeOptions;
|
|
// Security checks (password version, user status) handled by SecurityPipeline
|
|
use synctv_core::service::{RoomService, UserService};
|
|
use synctv_core::SharedStateProfile;
|
|
use synctv_core_testing::{
|
|
create_test_pool_with_options_and_label, opaque_register_user, redis_connection_manager,
|
|
start_redis_url_with_label, test_redis_key_prefix, RedisContainer, TestContainer,
|
|
};
|
|
use synctv_proto::client::{
|
|
client_message, server_message, ClientMessage, HeartbeatMessage, ServerMessage,
|
|
WebRtcCommand, WebRtcVoiceJoinCommand,
|
|
};
|
|
use synctv_realtime::sync::{
|
|
build_connection_manager, ConnectionLimits, ConnectionManager, RealtimeConfig,
|
|
RealtimeManager,
|
|
};
|
|
|
|
use sqlx::PgPool;
|
|
|
|
const TEST_JWT_SECRET: &str =
|
|
"this-is-a-test-secret-with-enough-entropy-for-jwt-signing-32chars";
|
|
const TEST_DB_MAX_CONNECTIONS: u32 = 5;
|
|
|
|
/// Lightweight test infrastructure for E2E tests.
|
|
/// Starts Postgres and Redis containers, runs migrations, and provides connections.
|
|
pub(super) struct TestInfra {
|
|
pool: PgPool,
|
|
redis_url: String,
|
|
redis_key_prefix: String,
|
|
postgres: Option<TestContainer>,
|
|
redis: Option<RedisContainer>,
|
|
}
|
|
|
|
impl TestInfra {
|
|
pub(super) async fn new() -> Self {
|
|
let (postgres, pool) = create_test_pool_with_options_and_label(
|
|
"synctv_test",
|
|
"api-ws-rate-limiter",
|
|
TEST_DB_MAX_CONNECTIONS,
|
|
std::time::Duration::from_secs(30),
|
|
)
|
|
.await;
|
|
let (redis, redis_url) = start_redis_url_with_label("api-ws-rate-limiter").await;
|
|
|
|
Self {
|
|
pool,
|
|
redis_url,
|
|
redis_key_prefix: test_redis_key_prefix("api-ws-rate-limiter"),
|
|
postgres: Some(postgres),
|
|
redis: Some(redis),
|
|
}
|
|
}
|
|
|
|
pub(super) async fn cleanup(mut self) {
|
|
self.pool.close().await;
|
|
if let Some(redis) = self.redis.take() {
|
|
redis.cleanup();
|
|
}
|
|
if let Some(postgres) = self.postgres.take() {
|
|
postgres.cleanup().await;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returned from `setup_e2e_server` so tests can access all shared components.
|
|
pub(super) struct E2EServer {
|
|
pub(super) addr: String,
|
|
pub(super) jwt_service: JwtService,
|
|
pub(super) room_service: Arc<RoomService>,
|
|
pub(super) user_service: Arc<UserService>,
|
|
pub(super) connection_manager: Arc<ConnectionManager>,
|
|
client_api: Arc<synctv_api::ClientApiImpl>,
|
|
realtime_manager: Arc<RealtimeManager>,
|
|
ws_ticket_service: Arc<dyn synctv_core::service::WebSocketTicketService>,
|
|
server_shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
|
|
server_handle: Option<JoinHandle<()>>,
|
|
}
|
|
|
|
impl E2EServer {
|
|
pub(super) async fn shutdown(&mut self) {
|
|
if let Some(shutdown_tx) = self.server_shutdown_tx.take() {
|
|
if let Some(server_handle) = self.server_handle.take() {
|
|
super::await_server_shutdown(shutdown_tx, server_handle).await;
|
|
}
|
|
}
|
|
|
|
self.connection_manager.shutdown().await;
|
|
self.realtime_manager.shutdown().await;
|
|
}
|
|
}
|
|
|
|
impl Drop for E2EServer {
|
|
fn drop(&mut self) {
|
|
if let Some(shutdown_tx) = self.server_shutdown_tx.take() {
|
|
let _ = shutdown_tx.send(());
|
|
}
|
|
if let Some(server_handle) = self.server_handle.take() {
|
|
server_handle.abort();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create a minimal `ChatService` for tests.
|
|
///
|
|
/// Accepts a shared `UsernameCache` so the `ChatService` can resolve
|
|
/// usernames that were populated by the `UserService`.
|
|
fn build_test_chat_service(
|
|
pool: &sqlx::PgPool,
|
|
username_cache: UsernameCache,
|
|
rate_limit_config: synctv_core::service::RateLimitConfig,
|
|
) -> Arc<synctv_core::service::ChatService> {
|
|
let chat_repo = Arc::new(synctv_core::repository::ChatRepository::new(pool.clone()));
|
|
let chat_rate_limiter: Arc<dyn synctv_core::service::RequestRateLimiterService> =
|
|
Arc::new(RateLimiter::local_only("test_chat:".to_string()));
|
|
let content_filter = synctv_core::service::ContentFilter::new();
|
|
let member_repo = synctv_core::repository::RoomMemberRepository::new(pool.clone());
|
|
let room_repo = synctv_core::repository::RoomRepository::new(pool.clone());
|
|
let permission_service =
|
|
synctv_core::service::PermissionService::new(member_repo, room_repo, None, 1000, 300)
|
|
.expect("permission service should build");
|
|
let room_settings_repo = synctv_core::repository::RoomSettingsRepository::new(pool.clone());
|
|
let notification_service = Arc::new(synctv_core::service::NotificationService::default());
|
|
let room_settings_service = synctv_core::service::RoomSettingsService::new(
|
|
room_settings_repo,
|
|
None,
|
|
notification_service,
|
|
None,
|
|
None,
|
|
);
|
|
Arc::new(synctv_core::service::ChatService::new(
|
|
chat_repo,
|
|
synctv_core::service::ChatRuntime {
|
|
clock: Arc::new(synctv_core::SystemClock),
|
|
rate_limiter: chat_rate_limiter,
|
|
rate_limit_config,
|
|
content_filter,
|
|
},
|
|
synctv_core::service::ChatDependencies {
|
|
permission_service,
|
|
room_settings_service,
|
|
user_service: Arc::new(UserService::new_for_tests(
|
|
pool,
|
|
JwtService::new(TEST_JWT_SECRET).expect("JwtService"),
|
|
username_cache,
|
|
Arc::new(synctv_core::service::InMemoryTokenBlacklistStore::new(
|
|
10_000, 3600, 86400,
|
|
)),
|
|
synctv_core::cache::KeyBuilder::new("test_chat"),
|
|
synctv_core::service::BruteForceProtection::in_memory("test_chat:".to_string()),
|
|
)),
|
|
file_storage_service: Arc::new(synctv_core::service::DisabledFileStorageService),
|
|
audit_service: None,
|
|
notification_service: synctv_core::service::NotificationService::default(),
|
|
runtime_settings_store: None,
|
|
},
|
|
))
|
|
}
|
|
|
|
/// Build a minimal `AppState` with real database and Redis for E2E testing.
|
|
async fn setup_e2e_server(infra: &TestInfra) -> E2EServer {
|
|
setup_e2e_server_with_origins_and_chat_rate_limit(
|
|
infra,
|
|
Vec::new(),
|
|
synctv_core::service::RateLimitConfig::default(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn setup_e2e_server_with_origins(
|
|
infra: &TestInfra,
|
|
cors_allowed_origins: Vec<String>,
|
|
) -> E2EServer {
|
|
setup_e2e_server_with_origins_and_chat_rate_limit(
|
|
infra,
|
|
cors_allowed_origins,
|
|
synctv_core::service::RateLimitConfig::default(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn setup_e2e_server_with_chat_rate_limit(
|
|
infra: &TestInfra,
|
|
chat_rate_limit_config: synctv_core::service::RateLimitConfig,
|
|
) -> E2EServer {
|
|
setup_e2e_server_with_origins_and_chat_rate_limit(infra, Vec::new(), chat_rate_limit_config)
|
|
.await
|
|
}
|
|
|
|
async fn setup_e2e_server_with_origins_and_chat_rate_limit(
|
|
infra: &TestInfra,
|
|
cors_allowed_origins: Vec<String>,
|
|
chat_rate_limit_config: synctv_core::service::RateLimitConfig,
|
|
) -> E2EServer {
|
|
setup_e2e_server_with_node_origins_and_chat_rate_limit(
|
|
infra,
|
|
"test_node_1",
|
|
cors_allowed_origins,
|
|
chat_rate_limit_config,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Build a minimal `AppState` with a custom `node_id`.
|
|
///
|
|
/// Useful for cross-replica tests: call twice with different node IDs
|
|
/// but the same `TestInfra` to simulate two server replicas.
|
|
async fn setup_e2e_server_with_node(infra: &TestInfra, node_id: &str) -> E2EServer {
|
|
setup_e2e_server_with_node_origins_and_chat_rate_limit(
|
|
infra,
|
|
node_id,
|
|
Vec::new(),
|
|
synctv_core::service::RateLimitConfig::default(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn setup_e2e_server_with_node_origins_and_chat_rate_limit(
|
|
infra: &TestInfra,
|
|
node_id: &str,
|
|
cors_allowed_origins: Vec<String>,
|
|
chat_rate_limit_config: synctv_core::service::RateLimitConfig,
|
|
) -> E2EServer {
|
|
setup_e2e_server_with_node_origins_chat_rate_limit_and_connection_limits(
|
|
infra,
|
|
node_id,
|
|
cors_allowed_origins,
|
|
chat_rate_limit_config,
|
|
ConnectionLimits::default(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub(super) async fn setup_e2e_server_with_connection_limits(
|
|
infra: &TestInfra,
|
|
connection_limits: ConnectionLimits,
|
|
) -> E2EServer {
|
|
setup_e2e_server_with_node_origins_chat_rate_limit_and_connection_limits(
|
|
infra,
|
|
"test_node_1",
|
|
Vec::new(),
|
|
synctv_core::service::RateLimitConfig::default(),
|
|
connection_limits,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn setup_e2e_server_with_node_origins_chat_rate_limit_and_connection_limits(
|
|
infra: &TestInfra,
|
|
node_id: &str,
|
|
cors_allowed_origins: Vec<String>,
|
|
chat_rate_limit_config: synctv_core::service::RateLimitConfig,
|
|
connection_limits: ConnectionLimits,
|
|
) -> E2EServer {
|
|
let pool = infra.pool.clone();
|
|
let redis_url = infra.redis_url.clone();
|
|
let redis_key_prefix = infra.redis_key_prefix.clone();
|
|
|
|
let jwt_service = JwtService::new(TEST_JWT_SECRET).expect("JwtService");
|
|
let redis_client = redis::Client::open(infra.redis_url.as_str()).expect("Redis client");
|
|
let redis_conn = Arc::new(tokio::sync::RwLock::new(
|
|
redis_connection_manager(&redis_client).await,
|
|
));
|
|
|
|
// UsernameCache with Redis L2 backend
|
|
let l2_backend = Arc::new(synctv_core::cache::l2_backend::RedisCacheL2::from_runtime(
|
|
synctv_core::shared_runtime(redis_conn.clone()),
|
|
));
|
|
let username_cache =
|
|
UsernameCache::new(l2_backend, format!("{redis_key_prefix}un:"), 100, 300);
|
|
let username_cache_for_chat = username_cache.clone();
|
|
|
|
let key_builder = synctv_core::cache::KeyBuilder::new(redis_key_prefix.clone());
|
|
|
|
// BruteForceProtection with Redis backend
|
|
let brute_force = synctv_core::service::BruteForceProtection::new_with_config(
|
|
redis_key_prefix.clone(),
|
|
Arc::new(synctv_core::service::RedisAttemptTracker::new(
|
|
redis_conn.clone(),
|
|
50_000,
|
|
synctv_core::service::BruteForceConfig::default().attempts_ttl_secs,
|
|
)),
|
|
Arc::new(synctv_core::service::RedisAttemptTracker::new(
|
|
redis_conn.clone(),
|
|
100_000,
|
|
synctv_core::service::BruteForceConfig::default().ip_attempts_ttl_secs,
|
|
)),
|
|
synctv_core::service::BruteForceConfig::default(),
|
|
);
|
|
|
|
// Token blacklist with in-memory backend (sufficient for tests)
|
|
let token_blacklist: Arc<dyn synctv_core::service::TokenBlacklistStore> = Arc::new(
|
|
synctv_core::service::InMemoryTokenBlacklistStore::new(10_000, 3600, 86400),
|
|
);
|
|
|
|
let user_service = UserService::new_with_runtime(
|
|
&pool,
|
|
jwt_service.clone(),
|
|
username_cache,
|
|
token_blacklist,
|
|
key_builder,
|
|
brute_force,
|
|
UserServiceRuntimeOptions {
|
|
password_registration_policy_override: Some(
|
|
synctv_core::service::RegistrationPolicy::Immediate,
|
|
),
|
|
..synctv_core::service::UserServiceRuntimeOptions::test_defaults()
|
|
},
|
|
);
|
|
let user_service = Arc::new(user_service);
|
|
let room_service = Arc::new(
|
|
RoomService::new_for_tests(pool.clone(), (*user_service).clone())
|
|
.expect("room service should build"),
|
|
);
|
|
|
|
// These helpers are used by cross-replica websocket tests, so cluster
|
|
// mode must be explicitly enabled to start distributed fan-out.
|
|
let redis_client_for_cluster =
|
|
redis::Client::open(redis_url.clone()).expect("Failed to open Redis client");
|
|
let redis_conn_for_cluster = redis_connection_manager(&redis_client_for_cluster).await;
|
|
let shared_runtime: Arc<dyn synctv_core::RedisConnectionRuntime> = Arc::new(
|
|
synctv_core::DirectRedisConnectionRuntime::new(redis_conn_for_cluster.clone()),
|
|
);
|
|
let realtime_config = RealtimeConfig {
|
|
distributed_transport_factory: Some(
|
|
synctv_realtime::sync::build_realtime_message_transport_factory(
|
|
synctv_core::coordination_runtime_from_client(redis_client_for_cluster),
|
|
),
|
|
),
|
|
message_runtime: synctv_realtime::sync::build_room_message_runtime(
|
|
&SharedStateProfile::for_cluster_runtime(
|
|
Some(shared_runtime),
|
|
&redis_key_prefix,
|
|
true,
|
|
),
|
|
)
|
|
.expect("shared message runtime should initialize"),
|
|
distributed_enabled: true,
|
|
node_id: node_id.to_string(),
|
|
key_prefix: redis_key_prefix.clone(),
|
|
..Default::default()
|
|
};
|
|
let realtime_manager = Arc::new(
|
|
RealtimeManager::new(realtime_config)
|
|
.await
|
|
.expect("RealtimeManager"),
|
|
);
|
|
let realtime_fanout_service =
|
|
synctv_api::local_realtime_fanout_service(realtime_manager.clone());
|
|
let redis_client_for_connections =
|
|
redis::Client::open(redis_url.clone()).expect("Redis client for connection manager");
|
|
let redis_conn_for_connections =
|
|
redis_connection_manager(&redis_client_for_connections).await;
|
|
let connection_profile = SharedStateProfile::for_cluster_runtime(
|
|
Some(synctv_core::direct_runtime(redis_conn_for_connections)),
|
|
&redis_key_prefix,
|
|
true,
|
|
);
|
|
let presence_service = Arc::new(
|
|
synctv_core::service::OnlinePresenceService::from_shared_state_profile(
|
|
&connection_profile,
|
|
)
|
|
.expect("shared presence service should initialize"),
|
|
);
|
|
let connection_manager = Arc::new(
|
|
build_connection_manager(
|
|
connection_limits,
|
|
&connection_profile,
|
|
presence_service.clone(),
|
|
node_id,
|
|
)
|
|
.expect("shared realtime connection runtime should initialize"),
|
|
);
|
|
let connection_manager_ret = connection_manager.clone();
|
|
|
|
// Rate limiter (in-memory only for tests)
|
|
let rate_limiter: Arc<dyn synctv_core::service::RequestRateLimiterService> = Arc::new(
|
|
RateLimiter::local_only(format!("{redis_key_prefix}ws-rate-limit:")),
|
|
);
|
|
|
|
let provider_instance_manager =
|
|
synctv_core_testing::create_empty_provider_instance_manager();
|
|
let providers_manager = Arc::new(
|
|
synctv_core::service::ProvidersManager::new(provider_instance_manager.clone())
|
|
.expect("providers manager should build"),
|
|
);
|
|
let user_provider_credential_repo =
|
|
Arc::new(synctv_core::repository::UserProviderCredentialRepository::new(pool.clone()));
|
|
let providers = synctv_core::provider::ProviderSet::new_with_ssrf_guard(
|
|
provider_instance_manager.clone(),
|
|
synctv_common::ssrf::SsrfGuard::strict_policy(),
|
|
)
|
|
.expect("provider set should build");
|
|
let shared_provider_stores: Arc<dyn synctv_core::provider::ProviderStoreResolver> =
|
|
Arc::new(synctv_core::provider::ProviderStoreRegistry::local_only(
|
|
"test:provider:",
|
|
));
|
|
let provider_access_service: Arc<dyn synctv_core::provider::ProviderAccessService> =
|
|
Arc::new(
|
|
synctv_core::provider::CachedProviderAccessService::new(
|
|
user_provider_credential_repo.clone(),
|
|
providers.alist.clone(),
|
|
)
|
|
.with_store(shared_provider_stores.load("credentials")),
|
|
);
|
|
let playback_transport_services =
|
|
Arc::new(synctv_core::provider::PlaybackTransportServices {
|
|
room_service: room_service.clone(),
|
|
permission_service: room_service.permission_service().clone(),
|
|
credential_encryption: None,
|
|
playback_session_repo:
|
|
synctv_core::repository::ProviderPlaybackSessionRepository::new(
|
|
user_provider_credential_repo.pool().clone(),
|
|
),
|
|
credential_repo: user_provider_credential_repo.clone(),
|
|
provider_access_service: provider_access_service.clone(),
|
|
});
|
|
let playback_provider_deps = synctv_core::service::PlaybackProviderServiceDeps {
|
|
providers: providers.clone(),
|
|
provider_stores: shared_provider_stores.clone(),
|
|
playback_transport_services: playback_transport_services.clone(),
|
|
provider_access_service: provider_access_service.clone(),
|
|
};
|
|
let alist_playback_provider_service = Arc::new(
|
|
synctv_core::service::AlistPlaybackProviderService::new(playback_provider_deps.clone()),
|
|
);
|
|
let bilibili_playback_provider_service =
|
|
Arc::new(synctv_core::service::BilibiliPlaybackProviderService::new(
|
|
playback_provider_deps.clone(),
|
|
));
|
|
let direct_url_playback_provider_service =
|
|
Arc::new(synctv_core::service::DirectUrlPlaybackProviderService::new(
|
|
playback_provider_deps.clone(),
|
|
));
|
|
let cloudreve_playback_provider_service =
|
|
Arc::new(synctv_core::service::CloudrevePlaybackProviderService::new(
|
|
playback_provider_deps.clone(),
|
|
));
|
|
let emby_playback_provider_service = Arc::new(
|
|
synctv_core::service::EmbyPlaybackProviderService::new(playback_provider_deps.clone()),
|
|
);
|
|
let rtmp_playback_provider_service = Arc::new(
|
|
synctv_core::service::RtmpPlaybackProviderService::new(playback_provider_deps.clone()),
|
|
);
|
|
let live_proxy_playback_provider_service =
|
|
Arc::new(synctv_core::service::LiveProxyPlaybackProviderService::new(
|
|
playback_provider_deps.clone(),
|
|
));
|
|
let twitch_playback_provider_service =
|
|
Arc::new(synctv_core::service::TwitchPlaybackProviderService::new(
|
|
playback_provider_deps.clone(),
|
|
));
|
|
let youtube_playback_provider_service =
|
|
Arc::new(synctv_core::service::YoutubePlaybackProviderService::new(
|
|
playback_provider_deps.clone(),
|
|
));
|
|
let douyin_playback_provider_service =
|
|
Arc::new(synctv_core::service::DouyinPlaybackProviderService::new(
|
|
playback_provider_deps.clone(),
|
|
));
|
|
let tiktok_playback_provider_service =
|
|
Arc::new(synctv_core::service::TikTokPlaybackProviderService::new(
|
|
playback_provider_deps.clone(),
|
|
));
|
|
let huya_playback_provider_service = Arc::new(
|
|
synctv_core::service::HuyaPlaybackProviderService::new(playback_provider_deps.clone()),
|
|
);
|
|
let douyu_playback_provider_service = Arc::new(
|
|
synctv_core::service::DouyuPlaybackProviderService::new(playback_provider_deps.clone()),
|
|
);
|
|
let acfun_playback_provider_service = Arc::new(
|
|
synctv_core::service::AcFunPlaybackProviderService::new(playback_provider_deps.clone()),
|
|
);
|
|
let cctv_playback_provider_service = Arc::new(
|
|
synctv_core::service::CctvPlaybackProviderService::new(playback_provider_deps.clone()),
|
|
);
|
|
let fnos_playback_provider_service = Arc::new(
|
|
synctv_core::service::FnosPlaybackProviderService::new(playback_provider_deps.clone()),
|
|
);
|
|
let qnap_playback_provider_service = Arc::new(
|
|
synctv_core::service::QnapPlaybackProviderService::new(playback_provider_deps.clone()),
|
|
);
|
|
let synology_playback_provider_service =
|
|
Arc::new(synctv_core::service::SynologyPlaybackProviderService::new(
|
|
playback_provider_deps.clone(),
|
|
));
|
|
let nextcloud_playback_provider_service =
|
|
Arc::new(synctv_core::service::NextcloudPlaybackProviderService::new(
|
|
playback_provider_deps.clone(),
|
|
));
|
|
let seafile_playback_provider_service =
|
|
Arc::new(synctv_core::service::SeafilePlaybackProviderService::new(
|
|
playback_provider_deps.clone(),
|
|
));
|
|
let truenas_playback_provider_service = Arc::new(
|
|
synctv_core::service::TrueNasPlaybackProviderService::new(playback_provider_deps),
|
|
);
|
|
|
|
let mut config_inner = synctv_api::ApiRuntimeSettings::default();
|
|
config_inner.server.cors_allowed_origins = cors_allowed_origins;
|
|
let config = Arc::new(config_inner);
|
|
let user_cache = Arc::new(synctv_core::cache::UserCache::local_only(
|
|
128,
|
|
60,
|
|
300,
|
|
"test:user:".to_string(),
|
|
));
|
|
let security_pipeline = Arc::new(synctv_core::service::SecurityPipeline::new_with_runtime(
|
|
user_service.clone(),
|
|
synctv_core::service::SecurityPipelineRuntime {
|
|
user_cache: Some(user_cache.clone()),
|
|
token_blacklist: user_service.token_blacklist_store(),
|
|
key_builder: user_service.key_builder().clone(),
|
|
},
|
|
));
|
|
let jwt_validator = Arc::new(synctv_core::service::JwtValidator::new(Arc::new(
|
|
jwt_service.clone(),
|
|
)));
|
|
let public_id_codec = Arc::new(
|
|
synctv_api::PublicIdCodec::from_config(&synctv_api::PublicIdConfig::default())
|
|
.expect("test public ID codec should build"),
|
|
);
|
|
let request_executor = Arc::new(synctv_api::RequestExecutor::new(
|
|
config.clone(),
|
|
jwt_validator.clone(),
|
|
security_pipeline.clone(),
|
|
rate_limiter.clone(),
|
|
));
|
|
let audit_service = {
|
|
let (audit_svc, _audit_handle) = synctv_core::service::AuditService::new(pool.clone());
|
|
Arc::new(audit_svc)
|
|
};
|
|
let provider_common_api = Arc::new(
|
|
synctv_api::providers::ProviderCommonApiImpl::new_with_runtime(
|
|
provider_instance_manager.clone(),
|
|
user_service.clone(),
|
|
audit_service.clone(),
|
|
synctv_api::providers::ProviderCommonApiRuntime {
|
|
providers_manager: providers_manager.clone(),
|
|
request_executor: request_executor.clone(),
|
|
},
|
|
),
|
|
);
|
|
let provider_api_runtime = synctv_api::providers::ProviderApiRuntime {
|
|
access_service: provider_access_service.clone(),
|
|
event_service: realtime_manager.clone(),
|
|
};
|
|
let credential_backed_providers =
|
|
providers.with_credential_repo(user_provider_credential_repo.clone());
|
|
let bilibili_api = Arc::new(
|
|
synctv_api::providers::BilibiliApiImpl::new_with_runtime(
|
|
credential_backed_providers.bilibili.clone(),
|
|
b"test-secret-key-for-websocket-tests-minimum-32-chars",
|
|
provider_api_runtime.clone(),
|
|
)
|
|
.expect("test Bilibili API should build"),
|
|
);
|
|
let alist_api = Arc::new(synctv_api::providers::AlistApiImpl::new_with_runtime(
|
|
credential_backed_providers.alist.clone(),
|
|
provider_api_runtime.clone(),
|
|
));
|
|
let emby_api = Arc::new(synctv_api::providers::EmbyApiImpl::new_with_runtime(
|
|
credential_backed_providers.emby.clone(),
|
|
provider_api_runtime.clone(),
|
|
));
|
|
let cloudreve_api = Arc::new(synctv_api::providers::CloudreveApiImpl::new(
|
|
credential_backed_providers.cloudreve.clone(),
|
|
realtime_manager.clone(),
|
|
));
|
|
let twitch_api = Arc::new(synctv_api::providers::TwitchApiImpl::new(
|
|
credential_backed_providers.twitch.clone(),
|
|
realtime_manager.clone(),
|
|
));
|
|
let youtube_api = Arc::new(synctv_api::providers::YoutubeApiImpl::new(
|
|
credential_backed_providers.youtube.clone(),
|
|
realtime_manager.clone(),
|
|
));
|
|
let douyin_api = Arc::new(synctv_api::providers::DouyinApiImpl::new(
|
|
credential_backed_providers.douyin.clone(),
|
|
realtime_manager.clone(),
|
|
));
|
|
let tiktok_api = Arc::new(synctv_api::providers::TikTokApiImpl::new(
|
|
credential_backed_providers.tiktok.clone(),
|
|
realtime_manager.clone(),
|
|
));
|
|
let fnos_api = Arc::new(synctv_api::providers::FnosApiImpl::new(
|
|
credential_backed_providers.fnos.clone(),
|
|
realtime_manager.clone(),
|
|
));
|
|
let qnap_api = Arc::new(synctv_api::providers::QnapApiImpl::new(
|
|
credential_backed_providers.qnap.clone(),
|
|
realtime_manager.clone(),
|
|
));
|
|
let synology_api = Arc::new(synctv_api::providers::SynologyApiImpl::new(
|
|
credential_backed_providers.synology.clone(),
|
|
realtime_manager.clone(),
|
|
));
|
|
let nextcloud_api = Arc::new(synctv_api::providers::NextcloudApiImpl::new(
|
|
credential_backed_providers.nextcloud.clone(),
|
|
realtime_manager.clone(),
|
|
));
|
|
let seafile_api = Arc::new(synctv_api::providers::SeafileApiImpl::new(
|
|
credential_backed_providers.seafile.clone(),
|
|
realtime_manager.clone(),
|
|
));
|
|
let truenas_api = Arc::new(synctv_api::providers::TrueNasApiImpl::new(
|
|
credential_backed_providers.truenas.clone(),
|
|
realtime_manager.clone(),
|
|
));
|
|
|
|
let ws_ticket_service = Arc::new(synctv_core::service::WsTicketService::local_only(None));
|
|
|
|
let chat_service =
|
|
build_test_chat_service(&pool, username_cache_for_chat, chat_rate_limit_config);
|
|
let shared_proxy_signing_key = Arc::new(
|
|
synctv_api::ProxySigningKey::try_derive_from(
|
|
b"test-proxy-signing-key-minimum-32-bytes!!",
|
|
)
|
|
.expect("test proxy signing key should derive"),
|
|
);
|
|
let media_swarm_signing_key = Arc::new(
|
|
synctv_api::MediaSwarmSigningKey::try_derive_from(
|
|
b"test-media-swarm-signing-key-minimum-32-bytes",
|
|
)
|
|
.expect("test media swarm signing key should derive"),
|
|
);
|
|
let client_api = Arc::new(synctv_api::ClientApiImpl::new_with_runtime(
|
|
synctv_api::ClientApiOptions {
|
|
user_service: user_service.clone(),
|
|
read_pool: None,
|
|
room_service: room_service.clone(),
|
|
connection_service: connection_manager.clone(),
|
|
runtime_settings: config.clone(),
|
|
publish_key_service: None,
|
|
jwt_service: jwt_service.clone(),
|
|
live_streaming_infrastructure: None,
|
|
runtime_settings_store: None,
|
|
public_id_codec: public_id_codec.clone(),
|
|
chat_service: Some(chat_service.clone()),
|
|
provider_stores: shared_provider_stores.clone(),
|
|
email_api: None,
|
|
passkey_service: None,
|
|
},
|
|
synctv_api::ClientApiRuntime::new_with_services(synctv_api::ClientApiRuntimeServices {
|
|
clock: Arc::new(synctv_core::SyncedClock::system()),
|
|
realtime_fanout: realtime_fanout_service.clone(),
|
|
realtime_event_service: realtime_manager.clone(),
|
|
redis_runtime: None,
|
|
builtin_stun_url: None,
|
|
webrtc_status:
|
|
synctv_core::service::WebRtcRuntimeStatus::peer_to_peer_stun_disabled(),
|
|
provider_access_service: provider_access_service.clone(),
|
|
signing_key: shared_proxy_signing_key.clone(),
|
|
media_swarm_signing_key: media_swarm_signing_key.clone(),
|
|
presence_service: presence_service.clone(),
|
|
jwt_validator: jwt_validator.clone(),
|
|
request_executor: request_executor.clone(),
|
|
ws_ticket_service: ws_ticket_service.clone(),
|
|
playback_duration_probe: None,
|
|
}),
|
|
));
|
|
|
|
let router_options = synctv_api::RouterOptions {
|
|
runtime_settings: config,
|
|
user_service: user_service.clone(),
|
|
read_pool: None,
|
|
user_cache,
|
|
room_service: room_service.clone(),
|
|
content_filter: synctv_core::service::ContentFilter::new(),
|
|
provider_access_service,
|
|
event_service: realtime_manager.clone(),
|
|
connection_manager,
|
|
presence_service,
|
|
jwt_service: jwt_service.clone(),
|
|
jwt_validator,
|
|
security_pipeline,
|
|
public_id_codec,
|
|
request_executor,
|
|
metrics_access_controller: Arc::new(synctv_api::MetricsAccessController::new()),
|
|
client_api: client_api.clone(),
|
|
admin_api: None,
|
|
email_api: None,
|
|
notification_api: None,
|
|
oauth2_api: None,
|
|
realtime_fanout_service,
|
|
oauth2_service: None,
|
|
passkey_service: None,
|
|
settings_service: None,
|
|
runtime_settings_store: None,
|
|
email_service: None,
|
|
email_token_service: None,
|
|
publish_key_service: None,
|
|
notification_service: None,
|
|
chat_service: Some(chat_service.clone()),
|
|
audit_service,
|
|
live_streaming_infrastructure: None,
|
|
cluster_client: None,
|
|
rate_limiter,
|
|
ws_ticket_service: ws_ticket_service.clone(),
|
|
redis_runtime: None,
|
|
shared_provider_stores,
|
|
playback_transport_services,
|
|
alist_playback_provider_service,
|
|
bilibili_playback_provider_service,
|
|
direct_url_playback_provider_service,
|
|
cloudreve_playback_provider_service,
|
|
emby_playback_provider_service,
|
|
rtmp_playback_provider_service,
|
|
live_proxy_playback_provider_service,
|
|
twitch_playback_provider_service,
|
|
youtube_playback_provider_service,
|
|
douyin_playback_provider_service,
|
|
tiktok_playback_provider_service,
|
|
huya_playback_provider_service,
|
|
douyu_playback_provider_service,
|
|
acfun_playback_provider_service,
|
|
cctv_playback_provider_service,
|
|
fnos_playback_provider_service,
|
|
qnap_playback_provider_service,
|
|
synology_playback_provider_service,
|
|
nextcloud_playback_provider_service,
|
|
seafile_playback_provider_service,
|
|
truenas_playback_provider_service,
|
|
provider_common_api,
|
|
bilibili_api,
|
|
alist_api,
|
|
emby_api,
|
|
cloudreve_api,
|
|
twitch_api,
|
|
youtube_api,
|
|
douyin_api,
|
|
tiktok_api,
|
|
fnos_api,
|
|
qnap_api,
|
|
synology_api,
|
|
nextcloud_api,
|
|
seafile_api,
|
|
truenas_api,
|
|
shared_proxy_signing_key,
|
|
media_swarm_signing_key,
|
|
builtin_stun_url: None,
|
|
webrtc_status: synctv_core::service::WebRtcRuntimeStatus::peer_to_peer_stun_disabled(),
|
|
credential_encryption: None,
|
|
proxy_slice_cache: std::sync::Arc::new(
|
|
synctv_proxy::slice_cache::SliceCache::new(
|
|
synctv_proxy::slice_cache::SliceCacheConfig::default(),
|
|
)
|
|
.expect("test slice cache should build"),
|
|
),
|
|
ssrf_guard: synctv_common::ssrf::SsrfGuard::disabled(),
|
|
proxy_http_client: synctv_proxy::build_proxy_http_client(
|
|
synctv_common::ssrf::SsrfGuard::disabled(),
|
|
)
|
|
.expect("proxy HTTP client should build for tests"),
|
|
messaging_rate_limit_config: synctv_core::service::RateLimitConfig::default(),
|
|
heartbeat_schedule: synctv_api::HeartbeatSchedule::fixed(
|
|
std::time::Duration::from_millis(400),
|
|
std::time::Duration::from_millis(100),
|
|
),
|
|
providers_manager,
|
|
playback_duration_probe: None,
|
|
};
|
|
|
|
let state = synctv_api::create_app_state_from_options(router_options)
|
|
.expect("test HTTP app state should build");
|
|
|
|
let app = axum::Router::new()
|
|
.route("/ws/rooms/{roomId}", axum::routing::get(websocket_handler))
|
|
.with_state(state);
|
|
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
|
let addr = listener.local_addr().expect("local_addr");
|
|
let addr_str = format!("127.0.0.1:{}", addr.port());
|
|
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
|
|
|
let server_handle = tokio::spawn(async move {
|
|
axum::serve(
|
|
listener,
|
|
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
|
)
|
|
.with_graceful_shutdown(async {
|
|
let _ = shutdown_rx.await;
|
|
})
|
|
.await
|
|
.expect("server error");
|
|
});
|
|
|
|
E2EServer {
|
|
addr: addr_str,
|
|
jwt_service,
|
|
room_service,
|
|
user_service,
|
|
connection_manager: connection_manager_ret,
|
|
client_api,
|
|
realtime_manager,
|
|
ws_ticket_service,
|
|
server_shutdown_tx: Some(shutdown_tx),
|
|
server_handle: Some(server_handle),
|
|
}
|
|
}
|
|
|
|
/// Register a test user and return their `UserId` + access token.
|
|
pub(super) async fn register_test_user(
|
|
user_service: &UserService,
|
|
jwt_service: &JwtService,
|
|
username: &str,
|
|
) -> (UserId, String) {
|
|
let (user, _access, _refresh) = opaque_register_user(
|
|
user_service,
|
|
username,
|
|
Some(format!("{username}@test.com")),
|
|
"TestPassword123!",
|
|
)
|
|
.await
|
|
.expect("register user");
|
|
let token = jwt_service
|
|
.sign_access_token(&user.id, 0)
|
|
.expect("sign token");
|
|
(user.id, token)
|
|
}
|
|
|
|
/// Create a test room and add the user as creator/member.
|
|
pub(super) async fn create_test_room(
|
|
room_service: &RoomService,
|
|
user_id: &UserId,
|
|
room_name: &str,
|
|
) -> String {
|
|
let (room, _member) = room_service
|
|
.create_room(room_name.to_string(), String::new(), *user_id, None, None)
|
|
.await
|
|
.expect("create room");
|
|
synctv_api::PublicIdCodec::plain()
|
|
.encode_room_id(room.id)
|
|
.expect("room id should encode")
|
|
}
|
|
|
|
pub(super) fn decode_test_room_id(room_id: &str) -> synctv_core::models::RoomId {
|
|
synctv_api::PublicIdCodec::plain()
|
|
.decode_room_id(room_id)
|
|
.expect("test room id should decode")
|
|
}
|
|
|
|
fn encode_test_user_id(user_id: &UserId) -> String {
|
|
synctv_api::PublicIdCodec::plain()
|
|
.encode_user_id(*user_id)
|
|
.expect("test user id should encode")
|
|
}
|
|
|
|
fn encode_test_media_id(media_id: &synctv_core::models::MediaId) -> String {
|
|
synctv_api::PublicIdCodec::plain()
|
|
.encode_media_id(*media_id)
|
|
.expect("test media id should encode")
|
|
}
|
|
|
|
/// Connect to the WebSocket endpoint using Authorization header.
|
|
async fn ws_connect(
|
|
addr: &str,
|
|
room_id: &str,
|
|
token: &str,
|
|
) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>
|
|
{
|
|
let url = format!("ws://{addr}/ws/rooms/{room_id}?format=protobuf");
|
|
let request = tungstenite::http::Request::builder()
|
|
.uri(&url)
|
|
.header("Authorization", format!("Bearer {token}"))
|
|
.header("Connection", "Upgrade")
|
|
.header("Upgrade", "websocket")
|
|
.header("Sec-WebSocket-Version", "13")
|
|
.header(
|
|
"Sec-WebSocket-Key",
|
|
tungstenite::handshake::client::generate_key(),
|
|
)
|
|
.header("Host", addr)
|
|
.body(())
|
|
.expect("build WS request");
|
|
let (ws_stream, response) = tokio_tungstenite::connect_async(request)
|
|
.await
|
|
.expect("WebSocket connect failed");
|
|
assert_eq!(
|
|
response.status(),
|
|
tungstenite::http::StatusCode::SWITCHING_PROTOCOLS,
|
|
"Expected 101 Switching Protocols"
|
|
);
|
|
ws_stream
|
|
}
|
|
|
|
async fn ws_connect_with_origin(
|
|
addr: &str,
|
|
room_id: &str,
|
|
token: &str,
|
|
origin: &str,
|
|
) -> Result<
|
|
(
|
|
tokio_tungstenite::WebSocketStream<
|
|
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
|
>,
|
|
tungstenite::handshake::client::Response,
|
|
),
|
|
tokio_tungstenite::tungstenite::Error,
|
|
> {
|
|
let url = format!("ws://{addr}/ws/rooms/{room_id}?format=protobuf");
|
|
let request = tungstenite::http::Request::builder()
|
|
.uri(&url)
|
|
.header("Authorization", format!("Bearer {token}"))
|
|
.header("Origin", origin)
|
|
.header("Connection", "Upgrade")
|
|
.header("Upgrade", "websocket")
|
|
.header("Sec-WebSocket-Version", "13")
|
|
.header(
|
|
"Sec-WebSocket-Key",
|
|
tungstenite::handshake::client::generate_key(),
|
|
)
|
|
.header("Host", addr)
|
|
.body(())
|
|
.expect("build WS request");
|
|
tokio_tungstenite::connect_async(request).await
|
|
}
|
|
|
|
/// Read the next binary message from the WebSocket and decode it as a `ServerMessage`.
|
|
async fn recv_server_message(
|
|
ws: &mut (impl StreamExt<Item = Result<tungstenite::Message, tungstenite::Error>> + Unpin),
|
|
) -> Option<ServerMessage> {
|
|
while let Some(msg) = ws.next().await {
|
|
match msg {
|
|
Ok(tungstenite::Message::Binary(bytes)) => {
|
|
return Some(
|
|
ProtoCodec::decode_server_message(&bytes).expect("decode server message"),
|
|
);
|
|
}
|
|
Ok(tungstenite::Message::Close(_)) => return None,
|
|
Err(e) => panic!("WebSocket error: {e}"),
|
|
_ => {}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Drain all pending server messages until a quiet period (no message within `quiet_ms`).
|
|
/// Returns the collected messages for optional inspection.
|
|
async fn drain_until_quiet(
|
|
ws: &mut (impl StreamExt<Item = Result<tungstenite::Message, tungstenite::Error>> + Unpin),
|
|
quiet_ms: u64,
|
|
) -> Vec<ServerMessage> {
|
|
let mut collected = Vec::new();
|
|
while let Ok(Some(msg)) = tokio::time::timeout(
|
|
std::time::Duration::from_millis(quiet_ms),
|
|
recv_server_message(ws),
|
|
)
|
|
.await
|
|
{
|
|
collected.push(msg);
|
|
}
|
|
collected
|
|
}
|
|
|
|
/// Read the next server message, skipping room member events.
|
|
/// Useful after draining initial messages when you want to read a specific
|
|
/// event type (Chat, `HeartbeatAck`, etc.) without being tripped up by
|
|
/// membership notifications that arrive at unpredictable times.
|
|
async fn recv_server_message_skip_membership(
|
|
ws: &mut (impl StreamExt<Item = Result<tungstenite::Message, tungstenite::Error>> + Unpin),
|
|
) -> Option<ServerMessage> {
|
|
loop {
|
|
let msg = recv_server_message(ws).await?;
|
|
if resource_room_member_event(&msg).is_none() {
|
|
return Some(msg);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn recv_matching_server_message(
|
|
ws: &mut (impl StreamExt<Item = Result<tungstenite::Message, tungstenite::Error>> + Unpin),
|
|
timeout: std::time::Duration,
|
|
mut predicate: impl FnMut(&ServerMessage) -> bool,
|
|
label: &str,
|
|
) -> ServerMessage {
|
|
tokio::time::timeout(timeout, async {
|
|
loop {
|
|
let msg = recv_server_message(ws).await.expect("stream ended");
|
|
if predicate(&msg) {
|
|
return msg;
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.unwrap_or_else(|_| panic!("timeout waiting for {label}"))
|
|
}
|
|
|
|
/// Encode a `ClientMessage` and send it as binary over the WebSocket.
|
|
async fn send_client_message(
|
|
ws: &mut (impl SinkExt<tungstenite::Message, Error = tungstenite::Error> + Unpin),
|
|
msg: &ClientMessage,
|
|
) {
|
|
let bytes = msg.encode_to_vec();
|
|
ws.send(tungstenite::Message::Binary(bytes.into()))
|
|
.await
|
|
.expect("send client message");
|
|
}
|
|
|
|
async fn publish_realtime_event_confirmed(
|
|
server: &E2EServer,
|
|
event: synctv_realtime::sync::RealtimeEvent,
|
|
) {
|
|
let event_type = event.event_type();
|
|
server
|
|
.realtime_manager
|
|
.publish_only_confirmed(event, std::time::Duration::from_secs(5))
|
|
.await
|
|
.unwrap_or_else(|error| panic!("cross-replica {event_type} publish failed: {error}"));
|
|
}
|
|
|
|
async fn wait_for_distributed_room_subscribers(
|
|
server: &E2EServer,
|
|
room_id: RoomId,
|
|
expected: usize,
|
|
) {
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
|
loop {
|
|
let subscribers = server
|
|
.realtime_manager
|
|
.message_hub()
|
|
.get_room_subscribers_replicas_wide(&room_id)
|
|
.await
|
|
.expect("distributed room subscriber lookup should succeed");
|
|
if subscribers.len() >= expected {
|
|
return;
|
|
}
|
|
assert!(
|
|
std::time::Instant::now() < deadline,
|
|
"distributed room subscribers did not reach {expected}; got {}",
|
|
subscribers.len()
|
|
);
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
}
|
|
}
|
|
|
|
fn observe_playback_state_message(observe_id: &str) -> ClientMessage {
|
|
ClientMessage {
|
|
message: Some(client_message::Message::ObserveResource(
|
|
synctv_proto::client::ObserveResource {
|
|
observe_id: observe_id.to_string(),
|
|
delivery_mode: synctv_proto::client::ResourceDeliveryMode::PushSnapshot as i32,
|
|
resource: Some(
|
|
synctv_proto::client::observe_resource::Resource::PlaybackState(
|
|
synctv_proto::client::ObservePlaybackState::default(),
|
|
),
|
|
),
|
|
},
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn observe_chat_events_message(observe_id: &str) -> ClientMessage {
|
|
ClientMessage {
|
|
message: Some(client_message::Message::ObserveResource(
|
|
synctv_proto::client::ObserveResource {
|
|
observe_id: observe_id.to_string(),
|
|
delivery_mode: synctv_proto::client::ResourceDeliveryMode::NotifyOnly as i32,
|
|
resource: Some(
|
|
synctv_proto::client::observe_resource::Resource::ChatEvents(
|
|
synctv_proto::client::ObserveChatEvents {
|
|
after_event_sequence: None,
|
|
include_message_types: Vec::new(),
|
|
},
|
|
),
|
|
),
|
|
},
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn observe_room_member_events_message(observe_id: &str) -> ClientMessage {
|
|
ClientMessage {
|
|
message: Some(client_message::Message::ObserveResource(
|
|
synctv_proto::client::ObserveResource {
|
|
observe_id: observe_id.to_string(),
|
|
delivery_mode: synctv_proto::client::ResourceDeliveryMode::NotifyOnly as i32,
|
|
resource: Some(
|
|
synctv_proto::client::observe_resource::Resource::RoomMemberEvents(
|
|
synctv_proto::client::ObserveRoomMemberEvents {
|
|
after_event_sequence: None,
|
|
},
|
|
),
|
|
),
|
|
},
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn webrtc_command_message(
|
|
command: synctv_proto::client::web_rtc_command::Command,
|
|
) -> ClientMessage {
|
|
ClientMessage {
|
|
message: Some(client_message::Message::Webrtc(WebRtcCommand {
|
|
command: Some(command),
|
|
})),
|
|
}
|
|
}
|
|
|
|
fn resource_chat_event(
|
|
message: &ServerMessage,
|
|
) -> Option<&synctv_proto::client::ChatMessageEvent> {
|
|
match &message.message {
|
|
Some(server_message::Message::ResourceEvent(changed)) => {
|
|
match changed.payload.as_ref() {
|
|
Some(synctv_proto::client::resource_event::Payload::ChatEvent(event)) => {
|
|
Some(event)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn resource_room_member_event(
|
|
message: &ServerMessage,
|
|
) -> Option<&synctv_proto::client::RoomMemberEvent> {
|
|
match &message.message {
|
|
Some(server_message::Message::ResourceEvent(changed)) => {
|
|
match changed.payload.as_ref() {
|
|
Some(synctv_proto::client::resource_event::Payload::RoomMemberEvent(event)) => {
|
|
Some(event)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn resource_webrtc_event(
|
|
message: &ServerMessage,
|
|
) -> Option<&synctv_proto::client::WebRtcEvent> {
|
|
match &message.message {
|
|
Some(server_message::Message::ResourceEvent(changed)) => {
|
|
match changed.payload.as_ref() {
|
|
Some(synctv_proto::client::resource_event::Payload::WebrtcEvent(event)) => {
|
|
Some(event)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn resource_room_member_joined(
|
|
message: &ServerMessage,
|
|
) -> Option<&synctv_proto::client::RoomMemberEvent> {
|
|
resource_room_member_event(message)
|
|
.filter(|event| event.kind == synctv_proto::client::RoomMemberEventKind::Joined as i32)
|
|
}
|
|
|
|
fn resource_room_member_left(
|
|
message: &ServerMessage,
|
|
) -> Option<&synctv_proto::client::RoomMemberEvent> {
|
|
resource_room_member_event(message)
|
|
.filter(|event| event.kind == synctv_proto::client::RoomMemberEventKind::Left as i32)
|
|
}
|
|
|
|
fn resource_playback_state_matches(
|
|
message: &ServerMessage,
|
|
observe_id: &str,
|
|
predicate: impl FnOnce(&synctv_proto::client::PlaybackState) -> bool,
|
|
) -> bool {
|
|
match &message.message {
|
|
Some(server_message::Message::ResourceEvent(changed))
|
|
if changed.observe_id == observe_id =>
|
|
{
|
|
match changed.payload.as_ref() {
|
|
Some(synctv_proto::client::resource_event::Payload::PlaybackState(state)) => {
|
|
predicate(state)
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_handshake_and_initial_user_joined() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (user_id, token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "alice_ws").await;
|
|
let room_id = create_test_room(&server.room_service, &user_id, "Test Room WS").await;
|
|
|
|
let mut ws = ws_connect(&server.addr, &room_id, &token).await;
|
|
|
|
let heartbeat = ClientMessage {
|
|
message: Some(client_message::Message::Heartbeat(HeartbeatMessage {
|
|
timestamp: chrono::Utc::now().timestamp_millis(),
|
|
})),
|
|
};
|
|
send_client_message(&mut ws, &heartbeat).await;
|
|
let ack = recv_matching_server_message(
|
|
&mut ws,
|
|
std::time::Duration::from_secs(5),
|
|
|message| {
|
|
matches!(
|
|
message.message,
|
|
Some(server_message::Message::HeartbeatAck(_))
|
|
)
|
|
},
|
|
"heartbeat ack after websocket auth",
|
|
)
|
|
.await;
|
|
assert!(matches!(
|
|
ack.message,
|
|
Some(server_message::Message::HeartbeatAck(_))
|
|
));
|
|
|
|
ws.close(None).await.expect("close");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_heartbeat_ping_pong() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (user_id, token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "bob_hb").await;
|
|
let room_id = create_test_room(&server.room_service, &user_id, "Heartbeat Room").await;
|
|
|
|
let mut ws = ws_connect(&server.addr, &room_id, &token).await;
|
|
|
|
drain_until_quiet(&mut ws, 2000).await;
|
|
|
|
let heartbeat = ClientMessage {
|
|
message: Some(client_message::Message::Heartbeat(HeartbeatMessage {
|
|
timestamp: chrono::Utc::now().timestamp_millis(),
|
|
})),
|
|
};
|
|
send_client_message(&mut ws, &heartbeat).await;
|
|
|
|
let ack = tokio::time::timeout(
|
|
std::time::Duration::from_secs(5),
|
|
recv_server_message_skip_membership(&mut ws),
|
|
)
|
|
.await
|
|
.expect("timeout waiting for heartbeat ack")
|
|
.expect("stream ended");
|
|
|
|
match ack.message {
|
|
Some(server_message::Message::HeartbeatAck(ack)) => {
|
|
assert!(
|
|
ack.timestamp > 0,
|
|
"HeartbeatAck should have a valid timestamp"
|
|
);
|
|
}
|
|
other => panic!("Expected HeartbeatAck, got: {other:?}"),
|
|
}
|
|
|
|
ws.close(None).await.expect("close");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_graceful_disconnect() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (user_id, token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "carol_dc").await;
|
|
let room_id = create_test_room(&server.room_service, &user_id, "Disconnect Room").await;
|
|
|
|
let mut ws = ws_connect(&server.addr, &room_id, &token).await;
|
|
|
|
drain_until_quiet(&mut ws, 2000).await;
|
|
|
|
ws.close(Some(tungstenite::protocol::CloseFrame {
|
|
code: tungstenite::protocol::frame::coding::CloseCode::Normal,
|
|
reason: "bye".into(),
|
|
}))
|
|
.await
|
|
.expect("close");
|
|
|
|
let result = tokio::time::timeout(std::time::Duration::from_secs(5), ws.next()).await;
|
|
|
|
match result {
|
|
Ok(Some(Ok(tungstenite::Message::Close(_)) | Err(_)) | None) | Err(_) => {
|
|
// All acceptable: either received Close frame, stream ended, or timeout
|
|
}
|
|
Ok(Some(Ok(msg))) => {
|
|
// After close, we may still receive buffered messages; that's fine
|
|
assert!(
|
|
!matches!(msg, tungstenite::Message::Binary(_)),
|
|
"Should not receive new binary messages after close"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_unauthenticated_rejected() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let url = format!("ws://{}/ws/rooms/invalid_room", server.addr);
|
|
let result = tokio_tungstenite::connect_async(&url).await;
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"Connection without auth should be rejected"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_invalid_token_rejected() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let url = format!("ws://{}/ws/rooms/invalid_room", server.addr);
|
|
let request = tungstenite::http::Request::builder()
|
|
.uri(&url)
|
|
.header("Authorization", "Bearer invalid.jwt.token")
|
|
.header("Connection", "Upgrade")
|
|
.header("Upgrade", "websocket")
|
|
.header("Sec-WebSocket-Version", "13")
|
|
.header(
|
|
"Sec-WebSocket-Key",
|
|
tungstenite::handshake::client::generate_key(),
|
|
)
|
|
.header("Host", &*server.addr)
|
|
.body(())
|
|
.unwrap();
|
|
let result = tokio_tungstenite::connect_async(request).await;
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"Connection with invalid token should be rejected"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_non_member_rejected() {
|
|
let infra = TestInfra::new().await;
|
|
let mut server = setup_e2e_server(&infra).await;
|
|
|
|
let (owner_id, _owner_token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "owner_nm").await;
|
|
let room_id = create_test_room(&server.room_service, &owner_id, "Private Room").await;
|
|
|
|
let (_outsider_id, outsider_token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "outsider_nm").await;
|
|
|
|
let url = format!("ws://{}/ws/rooms/{}", server.addr, room_id);
|
|
let request = tungstenite::http::Request::builder()
|
|
.uri(&url)
|
|
.header("Authorization", format!("Bearer {outsider_token}"))
|
|
.header("Connection", "Upgrade")
|
|
.header("Upgrade", "websocket")
|
|
.header("Sec-WebSocket-Version", "13")
|
|
.header(
|
|
"Sec-WebSocket-Key",
|
|
tungstenite::handshake::client::generate_key(),
|
|
)
|
|
.header("Host", &*server.addr)
|
|
.body(())
|
|
.unwrap();
|
|
let result = tokio::time::timeout(
|
|
std::time::Duration::from_secs(10),
|
|
tokio_tungstenite::connect_async(request),
|
|
)
|
|
.await
|
|
.expect("non-member websocket handshake should fail promptly");
|
|
|
|
match result {
|
|
Err(tokio_tungstenite::tungstenite::Error::Http(response)) => {
|
|
assert_eq!(
|
|
response.status(),
|
|
tungstenite::http::StatusCode::FORBIDDEN,
|
|
"non-member websocket should be rejected with HTTP 403"
|
|
);
|
|
}
|
|
Err(other) => {
|
|
panic!("expected HTTP 403 rejection for non-member websocket, got: {other:?}");
|
|
}
|
|
Ok((_ws, response)) => {
|
|
panic!(
|
|
"non-member websocket must not upgrade successfully, got status {}",
|
|
response.status()
|
|
);
|
|
}
|
|
}
|
|
|
|
server.shutdown().await;
|
|
infra.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_cross_origin_rejected_when_not_in_allowlist() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (owner_id, owner_token) = register_test_user(
|
|
&server.user_service,
|
|
&server.jwt_service,
|
|
"owner_origin_deny",
|
|
)
|
|
.await;
|
|
let room_id = create_test_room(&server.room_service, &owner_id, "Origin Deny Room").await;
|
|
|
|
let result = ws_connect_with_origin(
|
|
&server.addr,
|
|
&room_id,
|
|
&owner_token,
|
|
"https://evil.example.com",
|
|
)
|
|
.await;
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"cross-origin browser websocket must be rejected when the origin is not allowlisted"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_cross_origin_allowed_when_in_allowlist() {
|
|
let infra = TestInfra::new().await;
|
|
let server =
|
|
setup_e2e_server_with_origins(&infra, vec!["https://app.example.com".to_string()])
|
|
.await;
|
|
|
|
let (owner_id, owner_token) = register_test_user(
|
|
&server.user_service,
|
|
&server.jwt_service,
|
|
"owner_origin_allow",
|
|
)
|
|
.await;
|
|
let room_id = create_test_room(&server.room_service, &owner_id, "Origin Allow Room").await;
|
|
|
|
let (_ws, response) = ws_connect_with_origin(
|
|
&server.addr,
|
|
&room_id,
|
|
&owner_token,
|
|
"https://app.example.com",
|
|
)
|
|
.await
|
|
.expect("allowlisted cross-origin websocket should succeed");
|
|
|
|
assert_eq!(
|
|
response.status(),
|
|
tungstenite::http::StatusCode::SWITCHING_PROTOCOLS
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_multi_client_room_sync() {
|
|
let infra = TestInfra::new().await;
|
|
let mut server = setup_e2e_server(&infra).await;
|
|
|
|
let (user1_id, user1_token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "user1_mc").await;
|
|
let room_id = create_test_room(&server.room_service, &user1_id, "Sync Room").await;
|
|
|
|
let (user2_id, user2_token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "user2_mc").await;
|
|
let rid = decode_test_room_id(&room_id);
|
|
server
|
|
.room_service
|
|
.join_room(rid, user2_id, None)
|
|
.await
|
|
.expect("user2 join room");
|
|
|
|
let mut ws1 = ws_connect(&server.addr, &room_id, &user1_token).await;
|
|
send_client_message(&mut ws1, &observe_room_member_events_message("ws1-members")).await;
|
|
drain_until_quiet(&mut ws1, 2000).await;
|
|
|
|
let mut ws2 = ws_connect(&server.addr, &room_id, &user2_token).await;
|
|
drain_until_quiet(&mut ws2, 2000).await;
|
|
|
|
let user2_join_event = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
|
loop {
|
|
let msg = recv_server_message(&mut ws1).await.expect("stream ended");
|
|
if let Some(joined) = resource_room_member_joined(&msg) {
|
|
if joined.member.as_ref().map(|m| m.user_id.as_str())
|
|
== Some(encode_test_user_id(&user2_id).as_str())
|
|
{
|
|
return msg;
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("timeout waiting for user2 join event on ws1");
|
|
|
|
let joined = resource_room_member_joined(&user2_join_event).unwrap_or_else(|| {
|
|
panic!(
|
|
"Expected joined room member event for user2, got: {:?}",
|
|
user2_join_event.message
|
|
)
|
|
});
|
|
assert_eq!(joined.room_id, room_id);
|
|
let member = joined.member.as_ref().expect("member");
|
|
assert_eq!(member.user_id, encode_test_user_id(&user2_id));
|
|
|
|
ws1.close(None).await.expect("close ws1");
|
|
ws2.close(None).await.expect("close ws2");
|
|
server.shutdown().await;
|
|
infra.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_chat_presentation_fields_broadcast() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (user1_id, user1_token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "sender_cb").await;
|
|
let room_id = create_test_room(&server.room_service, &user1_id, "Chat Room").await;
|
|
|
|
let (user2_id, user2_token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "receiver_cb").await;
|
|
let rid = decode_test_room_id(&room_id);
|
|
server
|
|
.room_service
|
|
.join_room(rid, user2_id, None)
|
|
.await
|
|
.expect("user2 join");
|
|
|
|
let mut ws1 = ws_connect(&server.addr, &room_id, &user1_token).await;
|
|
send_client_message(&mut ws1, &observe_room_member_events_message("ws1-members")).await;
|
|
let mut ws2 = ws_connect(&server.addr, &room_id, &user2_token).await;
|
|
|
|
tokio::join!(
|
|
drain_until_quiet(&mut ws1, 1500),
|
|
drain_until_quiet(&mut ws2, 1500),
|
|
);
|
|
send_client_message(&mut ws2, &observe_chat_events_message("chat-events")).await;
|
|
recv_matching_server_message(
|
|
&mut ws2,
|
|
std::time::Duration::from_secs(5),
|
|
|message| {
|
|
matches!(
|
|
message.message,
|
|
Some(server_message::Message::ResourceObserved(_))
|
|
)
|
|
},
|
|
"chat_events observation acknowledgement",
|
|
)
|
|
.await;
|
|
|
|
// Let subscriptions settle
|
|
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
|
|
|
let chat_msg = ClientMessage {
|
|
message: Some(client_message::Message::Chat(
|
|
synctv_proto::client::ChatMessageSend {
|
|
content: "Hello from user1!".to_string(),
|
|
display_position: String::new(),
|
|
display_color: String::new(),
|
|
client_message_id: String::new(),
|
|
attachments: Vec::new(),
|
|
reply_to_message_id: String::new(),
|
|
metadata: None,
|
|
mentions: Vec::new(),
|
|
},
|
|
)),
|
|
};
|
|
send_client_message(&mut ws1, &chat_msg).await;
|
|
|
|
// user2 should receive the chat event through explicit chat_events observation.
|
|
let received = recv_matching_server_message(
|
|
&mut ws2,
|
|
std::time::Duration::from_secs(10),
|
|
|message| {
|
|
resource_chat_event(message).is_some_and(|event| {
|
|
event.message.as_ref().is_some_and(|chat| {
|
|
chat.content == "Hello from user1!"
|
|
&& chat.room_id == room_id
|
|
&& chat.user_id == encode_test_user_id(&user1_id)
|
|
})
|
|
})
|
|
},
|
|
"chat_events resource update on ws2",
|
|
)
|
|
.await;
|
|
|
|
assert!(resource_chat_event(&received).is_some());
|
|
|
|
ws1.close(None).await.expect("close ws1");
|
|
ws2.close(None).await.expect("close ws2");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_multiple_heartbeats() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (user_id, token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "hb_multi").await;
|
|
let room_id = create_test_room(&server.room_service, &user_id, "Multi HB Room").await;
|
|
|
|
let mut ws = ws_connect(&server.addr, &room_id, &token).await;
|
|
|
|
drain_until_quiet(&mut ws, 2000).await;
|
|
|
|
for i in 0..3 {
|
|
let heartbeat = ClientMessage {
|
|
message: Some(client_message::Message::Heartbeat(HeartbeatMessage {
|
|
timestamp: chrono::Utc::now().timestamp() + i,
|
|
})),
|
|
};
|
|
send_client_message(&mut ws, &heartbeat).await;
|
|
|
|
let ack = tokio::time::timeout(
|
|
std::time::Duration::from_secs(5),
|
|
recv_server_message_skip_membership(&mut ws),
|
|
)
|
|
.await
|
|
.expect("timeout waiting for heartbeat ack")
|
|
.expect("stream ended");
|
|
|
|
assert!(
|
|
matches!(ack.message, Some(server_message::Message::HeartbeatAck(_))),
|
|
"Expected HeartbeatAck for heartbeat {i}"
|
|
);
|
|
}
|
|
|
|
ws.close(None).await.expect("close");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_user_left_on_disconnect() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (user1_id, user1_token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "stayer_ul").await;
|
|
let room_id = create_test_room(&server.room_service, &user1_id, "Leave Room").await;
|
|
|
|
let (user2_id, user2_token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "leaver_ul").await;
|
|
let rid = decode_test_room_id(&room_id);
|
|
server
|
|
.room_service
|
|
.join_room(rid, user2_id, None)
|
|
.await
|
|
.expect("join");
|
|
|
|
let mut ws1 = ws_connect(&server.addr, &room_id, &user1_token).await;
|
|
send_client_message(&mut ws1, &observe_room_member_events_message("ws1-members")).await;
|
|
let mut ws2 = ws_connect(&server.addr, &room_id, &user2_token).await;
|
|
|
|
tokio::join!(
|
|
drain_until_quiet(&mut ws1, 1500),
|
|
drain_until_quiet(&mut ws2, 1500),
|
|
);
|
|
|
|
ws2.close(None).await.expect("close ws2");
|
|
|
|
// Read messages, skipping any stale UserJoined that may still be in flight
|
|
let left_event = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
|
loop {
|
|
let msg = recv_server_message(&mut ws1).await.expect("stream ended");
|
|
if resource_room_member_left(&msg).is_some() {
|
|
return msg;
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("timeout waiting for UserLeft event");
|
|
|
|
let left = resource_room_member_left(&left_event).unwrap_or_else(|| {
|
|
panic!(
|
|
"Expected left room member event, got: {:?}",
|
|
left_event.message
|
|
)
|
|
});
|
|
assert_eq!(left.room_id, room_id);
|
|
assert_eq!(left.user_id, encode_test_user_id(&user2_id));
|
|
|
|
ws1.close(None).await.expect("close ws1");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_room_isolation() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (user1_id, user1_token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "user1_iso").await;
|
|
let room_a_id = create_test_room(&server.room_service, &user1_id, "Room A").await;
|
|
|
|
let (user2_id, user2_token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "user2_iso").await;
|
|
let room_b_id = create_test_room(&server.room_service, &user2_id, "Room B").await;
|
|
|
|
let mut ws1 = ws_connect(&server.addr, &room_a_id, &user1_token).await;
|
|
let mut ws2 = ws_connect(&server.addr, &room_b_id, &user2_token).await;
|
|
|
|
let _ = tokio::time::timeout(
|
|
std::time::Duration::from_secs(5),
|
|
recv_server_message(&mut ws1),
|
|
)
|
|
.await;
|
|
let _ = tokio::time::timeout(
|
|
std::time::Duration::from_secs(5),
|
|
recv_server_message(&mut ws2),
|
|
)
|
|
.await;
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
|
|
|
let chat_msg = ClientMessage {
|
|
message: Some(client_message::Message::Chat(
|
|
synctv_proto::client::ChatMessageSend {
|
|
content: "Room A only".to_string(),
|
|
display_position: String::new(),
|
|
display_color: String::new(),
|
|
client_message_id: String::new(),
|
|
attachments: Vec::new(),
|
|
reply_to_message_id: String::new(),
|
|
metadata: None,
|
|
mentions: Vec::new(),
|
|
},
|
|
)),
|
|
};
|
|
send_client_message(&mut ws1, &chat_msg).await;
|
|
|
|
let result = tokio::time::timeout(
|
|
std::time::Duration::from_secs(2),
|
|
recv_server_message(&mut ws2),
|
|
)
|
|
.await;
|
|
|
|
// Should time out (no message received) or return None
|
|
match result {
|
|
Err(_) | Ok(None) => {
|
|
// Stream ended: also acceptable (no cross-room leak)
|
|
}
|
|
Ok(Some(msg)) => {
|
|
// If we got a message, it must NOT be the chat from room A
|
|
if resource_chat_event(&msg).is_some_and(|event| {
|
|
event
|
|
.message
|
|
.as_ref()
|
|
.is_some_and(|chat| chat.content == "Room A only")
|
|
}) {
|
|
panic!("Room isolation violated: user2 in Room B received chat from Room A");
|
|
}
|
|
// Other message types (like a heartbeat timeout) are acceptable
|
|
}
|
|
}
|
|
|
|
ws1.close(None).await.expect("close ws1");
|
|
ws2.close(None).await.expect("close ws2");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_reconnect_after_disconnect() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (user_id, token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "reconn_user").await;
|
|
let room_id = create_test_room(&server.room_service, &user_id, "Reconnect Room").await;
|
|
|
|
let mut ws = ws_connect(&server.addr, &room_id, &token).await;
|
|
|
|
ws.close(None).await.expect("close");
|
|
// Small delay to let the server process the disconnect
|
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
|
|
|
let mut ws2 = ws_connect(&server.addr, &room_id, &token).await;
|
|
drain_until_quiet(&mut ws2, 500).await;
|
|
|
|
let heartbeat = ClientMessage {
|
|
message: Some(client_message::Message::Heartbeat(HeartbeatMessage {
|
|
timestamp: chrono::Utc::now().timestamp_millis(),
|
|
})),
|
|
};
|
|
send_client_message(&mut ws2, &heartbeat).await;
|
|
|
|
let ack = tokio::time::timeout(
|
|
std::time::Duration::from_secs(5),
|
|
recv_server_message_skip_membership(&mut ws2),
|
|
)
|
|
.await
|
|
.expect("timeout on heartbeat after reconnect")
|
|
.expect("no ack");
|
|
assert!(
|
|
matches!(ack.message, Some(server_message::Message::HeartbeatAck(_))),
|
|
"Expected HeartbeatAck after reconnect"
|
|
);
|
|
|
|
ws2.close(None).await.expect("close ws2");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_forced_disconnect_via_kick() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (owner_id, _owner_token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "owner_kick").await;
|
|
let room_id = create_test_room(&server.room_service, &owner_id, "Kick Room").await;
|
|
|
|
let (user2_id, user2_token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "victim_kick").await;
|
|
let rid = decode_test_room_id(&room_id);
|
|
server
|
|
.room_service
|
|
.join_room(rid, user2_id, None)
|
|
.await
|
|
.expect("join");
|
|
|
|
// user2 connects
|
|
let mut ws2 = ws_connect(&server.addr, &room_id, &user2_token).await;
|
|
let _ = tokio::time::timeout(
|
|
std::time::Duration::from_secs(5),
|
|
recv_server_message(&mut ws2),
|
|
)
|
|
.await;
|
|
|
|
// Force disconnect user2 from the room via ConnectionManager
|
|
server
|
|
.connection_manager
|
|
.disconnect_user_from_room(&user2_id, &rid);
|
|
|
|
// user2's connection should be terminated (receive Close or stream ends)
|
|
let result = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
|
loop {
|
|
match ws2.next().await {
|
|
Some(Ok(tungstenite::Message::Close(_)) | Err(_)) | None => return true,
|
|
Some(Ok(_)) => {}
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
|
|
assert!(
|
|
result.is_ok(),
|
|
"Connection should be terminated after forced disconnect"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_forced_disconnect_via_kick_does_not_broadcast_user_left() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (owner_id, owner_token) = register_test_user(
|
|
&server.user_service,
|
|
&server.jwt_service,
|
|
"owner_kick_noleft",
|
|
)
|
|
.await;
|
|
let room_id =
|
|
create_test_room(&server.room_service, &owner_id, "Kick No UserLeft Room").await;
|
|
|
|
let (user2_id, user2_token) = register_test_user(
|
|
&server.user_service,
|
|
&server.jwt_service,
|
|
"victim_kick_noleft",
|
|
)
|
|
.await;
|
|
let rid = decode_test_room_id(&room_id);
|
|
server
|
|
.room_service
|
|
.join_room(rid, user2_id, None)
|
|
.await
|
|
.expect("join");
|
|
|
|
let mut ws1 = ws_connect(&server.addr, &room_id, &owner_token).await;
|
|
let mut ws2 = ws_connect(&server.addr, &room_id, &user2_token).await;
|
|
tokio::join!(
|
|
drain_until_quiet(&mut ws1, 1500),
|
|
drain_until_quiet(&mut ws2, 1500),
|
|
);
|
|
|
|
server
|
|
.connection_manager
|
|
.disconnect_user_from_room(&user2_id, &rid);
|
|
|
|
let disconnect_result = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
|
loop {
|
|
match ws2.next().await {
|
|
Some(Ok(tungstenite::Message::Close(_)) | Err(_)) | None => return true,
|
|
Some(Ok(_)) => {}
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
assert!(
|
|
disconnect_result.is_ok(),
|
|
"kicked connection should be terminated promptly"
|
|
);
|
|
|
|
let unexpected_user_left = tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
|
loop {
|
|
let msg = recv_server_message(&mut ws1).await.expect("stream ended");
|
|
if resource_room_member_left(&msg).is_some() {
|
|
return msg;
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
|
|
assert!(
|
|
unexpected_user_left.is_err(),
|
|
"forced kick disconnect must not be re-labeled as voluntary UserLeft"
|
|
);
|
|
|
|
let _ = ws1.close(None).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_admin_kick_event_does_not_broadcast_user_left() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (owner_id, owner_token) = register_test_user(
|
|
&server.user_service,
|
|
&server.jwt_service,
|
|
"owner_admin_kick",
|
|
)
|
|
.await;
|
|
let room_id = create_test_room(
|
|
&server.room_service,
|
|
&owner_id,
|
|
"Admin Kick No UserLeft Room",
|
|
)
|
|
.await;
|
|
|
|
let (user2_id, user2_token) = register_test_user(
|
|
&server.user_service,
|
|
&server.jwt_service,
|
|
"victim_admin_kick",
|
|
)
|
|
.await;
|
|
let rid = decode_test_room_id(&room_id);
|
|
server
|
|
.room_service
|
|
.join_room(rid, user2_id, None)
|
|
.await
|
|
.expect("join");
|
|
|
|
let mut ws1 = ws_connect(&server.addr, &room_id, &owner_token).await;
|
|
let mut ws2 = ws_connect(&server.addr, &room_id, &user2_token).await;
|
|
tokio::join!(
|
|
drain_until_quiet(&mut ws1, 1500),
|
|
drain_until_quiet(&mut ws2, 1500),
|
|
);
|
|
|
|
let event = synctv_realtime::sync::RealtimeEvent::KickUserFromRoom {
|
|
event_id: synctv_common::snanoid!(16),
|
|
room_id: rid,
|
|
user_id: user2_id,
|
|
reason: "admin kick".to_string(),
|
|
timestamp: chrono::Utc::now(),
|
|
};
|
|
server
|
|
.realtime_manager
|
|
.admin_event_tx()
|
|
.send(event)
|
|
.expect("admin event send");
|
|
|
|
let disconnect_result = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
|
loop {
|
|
match ws2.next().await {
|
|
Some(Ok(tungstenite::Message::Close(_)) | Err(_)) | None => return true,
|
|
Some(Ok(_)) => {}
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
assert!(
|
|
disconnect_result.is_ok(),
|
|
"admin kick should disconnect the targeted connection"
|
|
);
|
|
|
|
let unexpected_user_left = tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
|
loop {
|
|
let msg = recv_server_message(&mut ws1).await.expect("stream ended");
|
|
if resource_room_member_left(&msg).is_some() {
|
|
return msg;
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
|
|
assert!(
|
|
unexpected_user_left.is_err(),
|
|
"admin kick should not be re-labeled as voluntary UserLeft during cleanup"
|
|
);
|
|
|
|
let _ = ws1.close(None).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_cross_replica_chat_via_redis() {
|
|
let infra = TestInfra::new().await;
|
|
|
|
let server1 = setup_e2e_server_with_node(&infra, "replica_1").await;
|
|
let server2 = setup_e2e_server_with_node(&infra, "replica_2").await;
|
|
|
|
let (user1_id, user1_token) =
|
|
register_test_user(&server1.user_service, &server1.jwt_service, "xrep_u1").await;
|
|
let room_id =
|
|
create_test_room(&server1.room_service, &user1_id, "Cross Replica Room").await;
|
|
|
|
let (user2_id, user2_token) =
|
|
register_test_user(&server1.user_service, &server1.jwt_service, "xrep_u2").await;
|
|
let rid = decode_test_room_id(&room_id);
|
|
server1
|
|
.room_service
|
|
.join_room(rid, user2_id, None)
|
|
.await
|
|
.expect("user2 join");
|
|
|
|
// user1 connects to replica_1
|
|
let mut ws1 = ws_connect(&server1.addr, &room_id, &user1_token).await;
|
|
// user2 connects to replica_2
|
|
let mut ws2 = ws_connect(&server2.addr, &room_id, &user2_token).await;
|
|
|
|
tokio::join!(
|
|
drain_until_quiet(&mut ws1, 1500),
|
|
drain_until_quiet(&mut ws2, 1500),
|
|
);
|
|
send_client_message(&mut ws2, &observe_chat_events_message("chat-events")).await;
|
|
recv_matching_server_message(
|
|
&mut ws2,
|
|
std::time::Duration::from_secs(5),
|
|
|message| {
|
|
matches!(
|
|
message.message,
|
|
Some(server_message::Message::ResourceObserved(_))
|
|
)
|
|
},
|
|
"chat_events observation acknowledgement",
|
|
)
|
|
.await;
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
|
|
|
// user1 sends a chat on replica_1
|
|
let chat_msg = ClientMessage {
|
|
message: Some(client_message::Message::Chat(
|
|
synctv_proto::client::ChatMessageSend {
|
|
content: "Cross-replica hello!".to_string(),
|
|
display_position: String::new(),
|
|
display_color: String::new(),
|
|
client_message_id: String::new(),
|
|
attachments: Vec::new(),
|
|
reply_to_message_id: String::new(),
|
|
metadata: None,
|
|
mentions: Vec::new(),
|
|
},
|
|
)),
|
|
};
|
|
send_client_message(&mut ws1, &chat_msg).await;
|
|
|
|
// user2 on replica_2 should receive it via Redis Pub/Sub after observing chat_events.
|
|
let received = recv_matching_server_message(
|
|
&mut ws2,
|
|
std::time::Duration::from_secs(30),
|
|
|message| {
|
|
resource_chat_event(message).is_some_and(|event| {
|
|
event.message.as_ref().is_some_and(|chat| {
|
|
chat.content == "Cross-replica hello!"
|
|
&& chat.room_id == room_id
|
|
&& chat.user_id == encode_test_user_id(&user1_id)
|
|
})
|
|
})
|
|
},
|
|
"cross-replica chat_events resource update",
|
|
)
|
|
.await;
|
|
|
|
assert!(resource_chat_event(&received).is_some());
|
|
|
|
ws1.close(None).await.expect("close ws1");
|
|
ws2.close(None).await.expect("close ws2");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_cross_replica_same_user_partial_disconnect_does_not_emit_user_left() {
|
|
let infra = TestInfra::new().await;
|
|
|
|
let mut server1 = setup_e2e_server_with_node(&infra, "presence_replica_1").await;
|
|
let mut server2 = setup_e2e_server_with_node(&infra, "presence_replica_2").await;
|
|
|
|
let (owner_id, owner_token) = register_test_user(
|
|
&server1.user_service,
|
|
&server1.jwt_service,
|
|
"owner_xrep_presence",
|
|
)
|
|
.await;
|
|
let room_id = create_test_room(
|
|
&server1.room_service,
|
|
&owner_id,
|
|
"Cross Replica Presence Room",
|
|
)
|
|
.await;
|
|
|
|
let (user2_id, user2_token) = register_test_user(
|
|
&server1.user_service,
|
|
&server1.jwt_service,
|
|
"multi_presence_xrep_user",
|
|
)
|
|
.await;
|
|
let rid = decode_test_room_id(&room_id);
|
|
server1
|
|
.room_service
|
|
.join_room(rid, user2_id, None)
|
|
.await
|
|
.expect("join");
|
|
|
|
let mut ws_owner = ws_connect(&server1.addr, &room_id, &owner_token).await;
|
|
let mut ws_user_replica_1 = ws_connect(&server1.addr, &room_id, &user2_token).await;
|
|
let mut ws_user_replica_2 = ws_connect(&server2.addr, &room_id, &user2_token).await;
|
|
|
|
tokio::join!(
|
|
drain_until_quiet(&mut ws_owner, 1500),
|
|
drain_until_quiet(&mut ws_user_replica_1, 1500),
|
|
drain_until_quiet(&mut ws_user_replica_2, 1500),
|
|
);
|
|
|
|
let active_user_conns_replica_1 =
|
|
server1.connection_manager.get_user_connections(&user2_id);
|
|
let active_user_conns_replica_2 =
|
|
server2.connection_manager.get_user_connections(&user2_id);
|
|
assert_eq!(
|
|
active_user_conns_replica_1.len(),
|
|
1,
|
|
"test precondition failed: expected one same-user connection on replica 1"
|
|
);
|
|
assert_eq!(
|
|
active_user_conns_replica_2.len(),
|
|
1,
|
|
"test precondition failed: expected one same-user connection on replica 2"
|
|
);
|
|
|
|
ws_user_replica_1
|
|
.close(None)
|
|
.await
|
|
.expect("close replica-1 connection");
|
|
|
|
let maybe_user_left = tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
|
loop {
|
|
let msg = recv_server_message(&mut ws_owner)
|
|
.await
|
|
.expect("stream ended");
|
|
if resource_room_member_left(&msg).is_some() {
|
|
return msg;
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
|
|
assert!(
|
|
maybe_user_left.is_err(),
|
|
"disconnecting one of multiple cross-replica same-user connections must not emit UserLeft while another connection remains"
|
|
);
|
|
|
|
let heartbeat = ClientMessage {
|
|
message: Some(client_message::Message::Heartbeat(HeartbeatMessage {
|
|
timestamp: chrono::Utc::now().timestamp_millis(),
|
|
})),
|
|
};
|
|
send_client_message(&mut ws_user_replica_2, &heartbeat).await;
|
|
let ack = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
|
loop {
|
|
let msg = recv_server_message(&mut ws_user_replica_2)
|
|
.await
|
|
.expect("stream ended");
|
|
if matches!(msg.message, Some(server_message::Message::HeartbeatAck(_))) {
|
|
return msg;
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("timeout waiting for surviving cross-replica connection heartbeat ack");
|
|
assert!(
|
|
matches!(ack.message, Some(server_message::Message::HeartbeatAck(_))),
|
|
"surviving cross-replica same-user connection should remain functional"
|
|
);
|
|
|
|
ws_owner.close(None).await.expect("close owner");
|
|
ws_user_replica_2
|
|
.close(None)
|
|
.await
|
|
.expect("close remaining replica-2 connection");
|
|
server1.shutdown().await;
|
|
server2.shutdown().await;
|
|
infra.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_cross_replica_same_user_second_connection_does_not_emit_duplicate_user_joined()
|
|
{
|
|
let infra = TestInfra::new().await;
|
|
|
|
let server1 = setup_e2e_server_with_node(&infra, "join_replica_1").await;
|
|
let server2 = setup_e2e_server_with_node(&infra, "join_replica_2").await;
|
|
|
|
let (owner_id, owner_token) = register_test_user(
|
|
&server1.user_service,
|
|
&server1.jwt_service,
|
|
"owner_xrep_join",
|
|
)
|
|
.await;
|
|
let room_id =
|
|
create_test_room(&server1.room_service, &owner_id, "Cross Replica Join Room").await;
|
|
|
|
let (user2_id, user2_token) = register_test_user(
|
|
&server1.user_service,
|
|
&server1.jwt_service,
|
|
"multi_join_xrep_user",
|
|
)
|
|
.await;
|
|
let rid = decode_test_room_id(&room_id);
|
|
server1
|
|
.room_service
|
|
.join_room(rid, user2_id, None)
|
|
.await
|
|
.expect("join");
|
|
|
|
let mut ws_owner = ws_connect(&server1.addr, &room_id, &owner_token).await;
|
|
let mut ws_user_replica_1 = ws_connect(&server1.addr, &room_id, &user2_token).await;
|
|
|
|
tokio::join!(
|
|
drain_until_quiet(&mut ws_owner, 1500),
|
|
drain_until_quiet(&mut ws_user_replica_1, 1500),
|
|
);
|
|
|
|
let mut ws_user_replica_2 = ws_connect(&server2.addr, &room_id, &user2_token).await;
|
|
drain_until_quiet(&mut ws_user_replica_2, 1500).await;
|
|
|
|
let duplicate_join = tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
|
loop {
|
|
let msg = recv_server_message(&mut ws_owner)
|
|
.await
|
|
.expect("stream ended");
|
|
if let Some(joined) = resource_room_member_joined(&msg) {
|
|
if joined.member.as_ref().map(|m| m.user_id.as_str())
|
|
== Some(encode_test_user_id(&user2_id).as_str())
|
|
{
|
|
return msg;
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
|
|
assert!(
|
|
duplicate_join.is_err(),
|
|
"opening a second cross-replica connection for the same user must not emit duplicate UserJoined while the user is already online"
|
|
);
|
|
|
|
ws_owner.close(None).await.expect("close owner");
|
|
ws_user_replica_1
|
|
.close(None)
|
|
.await
|
|
.expect("close replica-1 user");
|
|
ws_user_replica_2
|
|
.close(None)
|
|
.await
|
|
.expect("close replica-2 user");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_delete_room_api_sends_one_specific_termination_before_close() {
|
|
let infra = TestInfra::new().await;
|
|
let mut server = setup_e2e_server(&infra).await;
|
|
|
|
let (owner_id, owner_token) = register_test_user(
|
|
&server.user_service,
|
|
&server.jwt_service,
|
|
"delete_room_termination_owner",
|
|
)
|
|
.await;
|
|
let room_id =
|
|
create_test_room(&server.room_service, &owner_id, "Delete Termination Room").await;
|
|
let mut ws_owner = ws_connect(&server.addr, &room_id, &owner_token).await;
|
|
drain_until_quiet(&mut ws_owner, 1500).await;
|
|
|
|
server
|
|
.client_api
|
|
.delete_room(&owner_id, &room_id)
|
|
.await
|
|
.expect("delete room through client API");
|
|
|
|
let termination = recv_matching_server_message(
|
|
&mut ws_owner,
|
|
std::time::Duration::from_secs(10),
|
|
|message| {
|
|
matches!(
|
|
message.message,
|
|
Some(server_message::Message::Termination(_))
|
|
)
|
|
},
|
|
"room deletion termination",
|
|
)
|
|
.await;
|
|
assert!(matches!(
|
|
termination.message,
|
|
Some(server_message::Message::Termination(termination))
|
|
if termination.code
|
|
== synctv_proto::client::RealtimeTerminationCode::RoomDeleted as i32
|
|
));
|
|
assert!(
|
|
tokio::time::timeout(
|
|
std::time::Duration::from_secs(10),
|
|
recv_server_message(&mut ws_owner),
|
|
)
|
|
.await
|
|
.expect("room-deleted stream termination should complete")
|
|
.is_none(),
|
|
"room deletion should close the stream after its sole termination message"
|
|
);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_cross_replica_room_realtime_message_matrix_via_realtime_events() {
|
|
let infra = TestInfra::new().await;
|
|
|
|
let server1 = setup_e2e_server_with_node(&infra, "matrix_replica_1").await;
|
|
let server2 = setup_e2e_server_with_node(&infra, "matrix_replica_2").await;
|
|
|
|
let (owner_id, owner_token) =
|
|
register_test_user(&server1.user_service, &server1.jwt_service, "matrix_owner").await;
|
|
let room_id = create_test_room(&server1.room_service, &owner_id, "Matrix Room").await;
|
|
|
|
let (member_id, member_token) =
|
|
register_test_user(&server1.user_service, &server1.jwt_service, "matrix_member").await;
|
|
let room = decode_test_room_id(&room_id);
|
|
server1
|
|
.room_service
|
|
.join_room(room, member_id, None)
|
|
.await
|
|
.expect("member join");
|
|
|
|
let mut ws_owner = ws_connect(&server1.addr, &room_id, &owner_token).await;
|
|
let mut ws_member = ws_connect(&server2.addr, &room_id, &member_token).await;
|
|
|
|
tokio::join!(
|
|
drain_until_quiet(&mut ws_owner, 1500),
|
|
drain_until_quiet(&mut ws_member, 1500),
|
|
);
|
|
wait_for_distributed_room_subscribers(&server2, room, 2).await;
|
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
|
|
|
send_client_message(&mut ws_member, &observe_chat_events_message("chat_events")).await;
|
|
let _ = recv_matching_server_message(
|
|
&mut ws_member,
|
|
std::time::Duration::from_secs(10),
|
|
|message| {
|
|
matches!(
|
|
&message.message,
|
|
Some(server_message::Message::ResourceObserved(observed))
|
|
if observed.observe_id == "chat_events"
|
|
)
|
|
},
|
|
"chat_events observed",
|
|
)
|
|
.await;
|
|
let public_owner_id = encode_test_user_id(&owner_id);
|
|
send_client_message(
|
|
&mut ws_owner,
|
|
&ClientMessage {
|
|
message: Some(client_message::Message::Chat(
|
|
synctv_proto::client::ChatMessageSend {
|
|
content: "cross-replica chat".to_string(),
|
|
display_position: "top".to_string(),
|
|
display_color: "#ff6600".to_string(),
|
|
client_message_id: String::new(),
|
|
attachments: Vec::new(),
|
|
reply_to_message_id: String::new(),
|
|
metadata: None,
|
|
mentions: Vec::new(),
|
|
},
|
|
)),
|
|
},
|
|
)
|
|
.await;
|
|
let chat_msg = recv_matching_server_message(
|
|
&mut ws_member,
|
|
std::time::Duration::from_secs(10),
|
|
|message| {
|
|
resource_chat_event(message).is_some_and(|event| {
|
|
event.message.as_ref().is_some_and(|chat| {
|
|
chat.content == "cross-replica chat"
|
|
&& chat.user_id == public_owner_id
|
|
&& chat.display_position == "top"
|
|
&& chat.display_color == "#ff6600"
|
|
})
|
|
})
|
|
},
|
|
"cross-replica chat",
|
|
)
|
|
.await;
|
|
assert!(
|
|
resource_chat_event(&chat_msg).is_some(),
|
|
"chat event should arrive as a resource event payload"
|
|
);
|
|
|
|
publish_realtime_event_confirmed(
|
|
&server1,
|
|
synctv_realtime::sync::RealtimeEvent::RoomDeleted {
|
|
event_id: synctv_common::snanoid!(16),
|
|
room_id: room,
|
|
deleted_by: owner_id,
|
|
timestamp: chrono::Utc::now(),
|
|
},
|
|
)
|
|
.await;
|
|
let room_deleted_msg = recv_matching_server_message(
|
|
&mut ws_member,
|
|
std::time::Duration::from_secs(10),
|
|
|message| {
|
|
matches!(
|
|
&message.message,
|
|
Some(server_message::Message::Termination(termination))
|
|
if termination.code
|
|
== synctv_proto::client::RealtimeTerminationCode::RoomDeleted as i32
|
|
)
|
|
},
|
|
"cross-replica room deleted termination",
|
|
)
|
|
.await;
|
|
assert!(
|
|
matches!(
|
|
room_deleted_msg.message,
|
|
Some(server_message::Message::Termination(termination))
|
|
if termination.code
|
|
== synctv_proto::client::RealtimeTerminationCode::RoomDeleted as i32
|
|
),
|
|
"RoomDeleted should terminate the realtime stream with a dedicated code"
|
|
);
|
|
assert!(
|
|
tokio::time::timeout(
|
|
std::time::Duration::from_secs(10),
|
|
recv_server_message(&mut ws_member),
|
|
)
|
|
.await
|
|
.expect("room-deleted stream termination should complete")
|
|
.is_none(),
|
|
"RoomDeleted should close the realtime stream after the termination message"
|
|
);
|
|
|
|
ws_owner.close(None).await.expect("close owner");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_playback_state_command_fans_out_to_sender_and_peer() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (owner_id, owner_token) = register_test_user(
|
|
&server.user_service,
|
|
&server.jwt_service,
|
|
"playback_command_owner",
|
|
)
|
|
.await;
|
|
let room_id =
|
|
create_test_room(&server.room_service, &owner_id, "Playback Command Room").await;
|
|
let room = decode_test_room_id(&room_id);
|
|
let (member_id, member_token) = register_test_user(
|
|
&server.user_service,
|
|
&server.jwt_service,
|
|
"playback_command_member",
|
|
)
|
|
.await;
|
|
server
|
|
.room_service
|
|
.join_room(room, member_id, None)
|
|
.await
|
|
.expect("member join");
|
|
|
|
let media = synctv_core::models::Media {
|
|
id: synctv_core::models::MediaId::new(),
|
|
playlist_id: None,
|
|
room_id: room,
|
|
creator_id: Some(owner_id),
|
|
name: "Playback command video".to_string(),
|
|
description: String::new(),
|
|
position: 0.0,
|
|
source_provider: synctv_core::models::SourceProvider::DirectUrl,
|
|
source_config: synctv_core_testing::direct_url_media_source_config(
|
|
"https://example.com/playback-command.mp4",
|
|
),
|
|
provider_instance_name: None,
|
|
cover_file_reference_id: None,
|
|
thumbnail_file_reference_id: None,
|
|
added_at: chrono::Utc::now(),
|
|
updated_at: chrono::Utc::now(),
|
|
version: 0,
|
|
};
|
|
let media = synctv_core::repository::MediaRepository::new(infra.pool.clone())
|
|
.create(&media)
|
|
.await
|
|
.expect("create playback command media");
|
|
let initial_state = server
|
|
.room_service
|
|
.playback_service()
|
|
.switch(room, owner_id, Some(media.id), None, None)
|
|
.await
|
|
.expect("start playback command media");
|
|
let public_media_id = encode_test_media_id(&media.id);
|
|
let initial_target_hash = initial_state
|
|
.target_hash()
|
|
.expect("hash initial playback command target");
|
|
|
|
let mut ws_owner = ws_connect(&server.addr, &room_id, &owner_token).await;
|
|
let mut ws_member = ws_connect(&server.addr, &room_id, &member_token).await;
|
|
tokio::join!(
|
|
drain_until_quiet(&mut ws_owner, 500),
|
|
drain_until_quiet(&mut ws_member, 500),
|
|
);
|
|
send_client_message(
|
|
&mut ws_owner,
|
|
&observe_playback_state_message("owner-playback-state"),
|
|
)
|
|
.await;
|
|
send_client_message(
|
|
&mut ws_member,
|
|
&observe_playback_state_message("member-playback-state"),
|
|
)
|
|
.await;
|
|
let _ = recv_matching_server_message(
|
|
&mut ws_owner,
|
|
std::time::Duration::from_secs(5),
|
|
|message| {
|
|
matches!(
|
|
&message.message,
|
|
Some(server_message::Message::ResourceObserved(observed))
|
|
if observed.observe_id == "owner-playback-state"
|
|
)
|
|
},
|
|
"owner playback_state observation",
|
|
)
|
|
.await;
|
|
let _ = recv_matching_server_message(
|
|
&mut ws_member,
|
|
std::time::Duration::from_secs(5),
|
|
|message| {
|
|
matches!(
|
|
&message.message,
|
|
Some(server_message::Message::ResourceObserved(observed))
|
|
if observed.observe_id == "member-playback-state"
|
|
)
|
|
},
|
|
"member playback_state observation",
|
|
)
|
|
.await;
|
|
|
|
let operation_id = "3d918f61-3959-49ef-a962-5d94b8ac8470";
|
|
send_client_message(
|
|
&mut ws_owner,
|
|
&ClientMessage {
|
|
message: Some(client_message::Message::PlaybackStateUpdate(
|
|
synctv_proto::client::UpdatePlaybackStateRequest {
|
|
r#type: synctv_proto::client::PlaybackUpdateType::Seek as i32,
|
|
playing: None,
|
|
position: Some(7.5),
|
|
speed: None,
|
|
version: Some(initial_state.version),
|
|
expected_media_id: Some(public_media_id.clone()),
|
|
expected_playlist_id: Some(String::new()),
|
|
expected_target_hash: Some(initial_target_hash),
|
|
client_operation_id: Some(operation_id.to_string()),
|
|
client_time_millis: Some(chrono::Utc::now().timestamp_millis()),
|
|
},
|
|
)),
|
|
},
|
|
)
|
|
.await;
|
|
|
|
let expected_version = initial_state.version + 1;
|
|
let owner_event = recv_matching_server_message(
|
|
&mut ws_owner,
|
|
std::time::Duration::from_secs(5),
|
|
|message| {
|
|
resource_playback_state_matches(message, "owner-playback-state", |state| {
|
|
state.version == expected_version
|
|
&& state.playing_media_id == public_media_id
|
|
&& state.client_operation_id == operation_id
|
|
&& state.position >= 7.5
|
|
})
|
|
},
|
|
"sender playback_state command event",
|
|
)
|
|
.await;
|
|
let member_event = recv_matching_server_message(
|
|
&mut ws_member,
|
|
std::time::Duration::from_secs(5),
|
|
|message| {
|
|
resource_playback_state_matches(message, "member-playback-state", |state| {
|
|
state.version == expected_version
|
|
&& state.playing_media_id == public_media_id
|
|
&& state.client_operation_id == operation_id
|
|
&& state.position >= 7.5
|
|
})
|
|
},
|
|
"peer playback_state command event",
|
|
)
|
|
.await;
|
|
assert!(matches!(
|
|
owner_event.message,
|
|
Some(server_message::Message::ResourceEvent(_))
|
|
));
|
|
assert!(matches!(
|
|
member_event.message,
|
|
Some(server_message::Message::ResourceEvent(_))
|
|
));
|
|
let persisted = server
|
|
.room_service
|
|
.playback_service()
|
|
.get_state(&room)
|
|
.await
|
|
.expect("load persisted playback command state");
|
|
assert_eq!(persisted.version, expected_version);
|
|
assert!(persisted.position >= 7.5);
|
|
|
|
ws_owner.close(None).await.expect("close owner");
|
|
ws_member.close(None).await.expect("close member");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_cross_replica_playback_operation_matrix_via_realtime_events() {
|
|
let infra = TestInfra::new().await;
|
|
|
|
let server1 = setup_e2e_server_with_node(&infra, "playback_matrix_replica_1").await;
|
|
let server2 = setup_e2e_server_with_node(&infra, "playback_matrix_replica_2").await;
|
|
|
|
let (owner_id, owner_token) = register_test_user(
|
|
&server1.user_service,
|
|
&server1.jwt_service,
|
|
"playback_matrix_owner",
|
|
)
|
|
.await;
|
|
let room_id =
|
|
create_test_room(&server1.room_service, &owner_id, "Playback Matrix Room").await;
|
|
|
|
let (member_id, member_token) = register_test_user(
|
|
&server1.user_service,
|
|
&server1.jwt_service,
|
|
"playback_matrix_member",
|
|
)
|
|
.await;
|
|
let room = decode_test_room_id(&room_id);
|
|
server1
|
|
.room_service
|
|
.join_room(room, member_id, None)
|
|
.await
|
|
.expect("member join");
|
|
|
|
let mut ws_owner = ws_connect(&server1.addr, &room_id, &owner_token).await;
|
|
let mut ws_member = ws_connect(&server2.addr, &room_id, &member_token).await;
|
|
|
|
tokio::join!(
|
|
drain_until_quiet(&mut ws_owner, 1500),
|
|
drain_until_quiet(&mut ws_member, 1500),
|
|
);
|
|
wait_for_distributed_room_subscribers(&server2, room, 2).await;
|
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
|
send_client_message(
|
|
&mut ws_member,
|
|
&observe_playback_state_message("playback_state"),
|
|
)
|
|
.await;
|
|
let _ = recv_matching_server_message(
|
|
&mut ws_member,
|
|
std::time::Duration::from_secs(10),
|
|
|message| {
|
|
matches!(
|
|
&message.message,
|
|
Some(server_message::Message::ResourceObserved(observed))
|
|
if observed.observe_id == "playback_state"
|
|
)
|
|
},
|
|
"playback_state observed",
|
|
)
|
|
.await;
|
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
|
|
|
let media_id = synctv_core::models::MediaId::expect_positive(1);
|
|
let public_media_id = encode_test_media_id(&media_id);
|
|
|
|
let mut started_state = synctv_core::models::RoomPlaybackState::new(room);
|
|
started_state.playing_media_id = Some(media_id);
|
|
started_state.is_playing = true;
|
|
started_state.version = 1;
|
|
publish_realtime_event_confirmed(
|
|
&server1,
|
|
synctv_realtime::sync::RealtimeEvent::PlaybackStateChanged {
|
|
event_id: synctv_common::snanoid!(16),
|
|
room_id: room,
|
|
user_id: owner_id,
|
|
username: "playback_matrix_owner".to_string(),
|
|
state: started_state,
|
|
source_changed: false,
|
|
client_operation_id: None,
|
|
timestamp: chrono::Utc::now(),
|
|
},
|
|
)
|
|
.await;
|
|
let started_msg = recv_matching_server_message(
|
|
&mut ws_member,
|
|
std::time::Duration::from_secs(10),
|
|
|message| {
|
|
resource_playback_state_matches(message, "playback_state", |state| {
|
|
state.is_playing
|
|
&& state.playing_media_id == public_media_id
|
|
&& state.position >= 0.0
|
|
&& state.position.is_finite()
|
|
&& (state.speed - 1.0).abs() < f64::EPSILON
|
|
&& state.version == 1
|
|
})
|
|
},
|
|
"cross-replica playback start",
|
|
)
|
|
.await;
|
|
assert!(
|
|
matches!(
|
|
started_msg.message,
|
|
Some(server_message::Message::ResourceEvent(_))
|
|
),
|
|
"Playback start should be forwarded through playback_state ResourceEvent"
|
|
);
|
|
|
|
let mut paused_state = synctv_core::models::RoomPlaybackState::new(room);
|
|
paused_state.playing_media_id = Some(media_id);
|
|
paused_state.position = 17.5;
|
|
paused_state.is_playing = false;
|
|
paused_state.version = 2;
|
|
publish_realtime_event_confirmed(
|
|
&server1,
|
|
synctv_realtime::sync::RealtimeEvent::PlaybackStateChanged {
|
|
event_id: synctv_common::snanoid!(16),
|
|
room_id: room,
|
|
user_id: owner_id,
|
|
username: "playback_matrix_owner".to_string(),
|
|
state: paused_state,
|
|
source_changed: false,
|
|
client_operation_id: None,
|
|
timestamp: chrono::Utc::now(),
|
|
},
|
|
)
|
|
.await;
|
|
let paused_msg = recv_matching_server_message(
|
|
&mut ws_member,
|
|
std::time::Duration::from_secs(10),
|
|
|message| {
|
|
resource_playback_state_matches(message, "playback_state", |state| {
|
|
!state.is_playing
|
|
&& state.playing_media_id == public_media_id
|
|
&& state.position >= 17.5
|
|
&& state.position.is_finite()
|
|
&& (state.speed - 1.0).abs() < f64::EPSILON
|
|
&& state.version == 2
|
|
})
|
|
},
|
|
"cross-replica playback pause and seek",
|
|
)
|
|
.await;
|
|
assert!(
|
|
matches!(
|
|
paused_msg.message,
|
|
Some(server_message::Message::ResourceEvent(_))
|
|
),
|
|
"Playback pause/seek should be forwarded through playback_state ResourceEvent"
|
|
);
|
|
|
|
let mut resumed_state = synctv_core::models::RoomPlaybackState::new(room);
|
|
resumed_state.playing_media_id = Some(media_id);
|
|
resumed_state.position = 17.5;
|
|
resumed_state.speed = 1.5;
|
|
resumed_state.is_playing = true;
|
|
resumed_state.version = 3;
|
|
publish_realtime_event_confirmed(
|
|
&server1,
|
|
synctv_realtime::sync::RealtimeEvent::PlaybackStateChanged {
|
|
event_id: synctv_common::snanoid!(16),
|
|
room_id: room,
|
|
user_id: owner_id,
|
|
username: "playback_matrix_owner".to_string(),
|
|
state: resumed_state,
|
|
source_changed: false,
|
|
client_operation_id: None,
|
|
timestamp: chrono::Utc::now(),
|
|
},
|
|
)
|
|
.await;
|
|
let resumed_msg = recv_matching_server_message(
|
|
&mut ws_member,
|
|
std::time::Duration::from_secs(10),
|
|
|message| {
|
|
resource_playback_state_matches(message, "playback_state", |state| {
|
|
state.is_playing
|
|
&& state.playing_media_id == public_media_id
|
|
&& state.position >= 17.5
|
|
&& state.position.is_finite()
|
|
&& (state.speed - 1.5).abs() < f64::EPSILON
|
|
&& state.version == 3
|
|
})
|
|
},
|
|
"cross-replica playback resume with speed",
|
|
)
|
|
.await;
|
|
assert!(
|
|
matches!(
|
|
resumed_msg.message,
|
|
Some(server_message::Message::ResourceEvent(_))
|
|
),
|
|
"Playback resume/speed should be forwarded through playback_state ResourceEvent"
|
|
);
|
|
|
|
let mut stopped_state = synctv_core::models::RoomPlaybackState::new(room);
|
|
stopped_state.version = 4;
|
|
publish_realtime_event_confirmed(
|
|
&server1,
|
|
synctv_realtime::sync::RealtimeEvent::PlaybackStateChanged {
|
|
event_id: synctv_common::snanoid!(16),
|
|
room_id: decode_test_room_id(&room_id),
|
|
user_id: owner_id,
|
|
username: "playback_matrix_owner".to_string(),
|
|
state: stopped_state,
|
|
source_changed: false,
|
|
client_operation_id: None,
|
|
timestamp: chrono::Utc::now(),
|
|
},
|
|
)
|
|
.await;
|
|
let stopped_msg = recv_matching_server_message(
|
|
&mut ws_member,
|
|
std::time::Duration::from_secs(10),
|
|
|message| {
|
|
resource_playback_state_matches(message, "playback_state", |state| {
|
|
!state.is_playing
|
|
&& state.playing_media_id.is_empty()
|
|
&& (state.position - 0.0).abs() < f64::EPSILON
|
|
&& (state.speed - 1.0).abs() < f64::EPSILON
|
|
&& state.version == 4
|
|
})
|
|
},
|
|
"cross-replica playback stop",
|
|
)
|
|
.await;
|
|
assert!(
|
|
matches!(
|
|
stopped_msg.message,
|
|
Some(server_message::Message::ResourceEvent(_))
|
|
),
|
|
"Playback stop should be forwarded through playback_state ResourceEvent"
|
|
);
|
|
|
|
ws_owner.close(None).await.expect("close owner");
|
|
ws_member.close(None).await.expect("close member");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_rate_limiter_blocks_excess_chat() {
|
|
let infra = TestInfra::new().await;
|
|
let mut server = setup_e2e_server_with_chat_rate_limit(
|
|
&infra,
|
|
synctv_core::service::RateLimitConfig {
|
|
chat_per_second: 2,
|
|
window_seconds: 60,
|
|
},
|
|
)
|
|
.await;
|
|
|
|
let (user_id, token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "ratelimit_user").await;
|
|
let room_id = create_test_room(&server.room_service, &user_id, "Rate Limit Room").await;
|
|
|
|
let mut ws = ws_connect(&server.addr, &room_id, &token).await;
|
|
|
|
drain_until_quiet(&mut ws, 2000).await;
|
|
|
|
// We send 15 to exceed the limit
|
|
for i in 0..15 {
|
|
let chat_msg = ClientMessage {
|
|
message: Some(client_message::Message::Chat(
|
|
synctv_proto::client::ChatMessageSend {
|
|
content: format!("Spam message {i}"),
|
|
display_position: String::new(),
|
|
display_color: String::new(),
|
|
client_message_id: String::new(),
|
|
attachments: Vec::new(),
|
|
reply_to_message_id: String::new(),
|
|
metadata: None,
|
|
mentions: Vec::new(),
|
|
},
|
|
)),
|
|
};
|
|
send_client_message(&mut ws, &chat_msg).await;
|
|
}
|
|
|
|
// Collect chat echoes and errors for a bounded time
|
|
let mut chat_count = 0usize;
|
|
let mut error_received = false;
|
|
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
|
|
|
|
loop {
|
|
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
|
if remaining.is_zero() {
|
|
break;
|
|
}
|
|
match tokio::time::timeout(remaining, recv_server_message(&mut ws)).await {
|
|
Ok(Some(msg)) => match msg.message {
|
|
Some(server_message::Message::ResourceEvent(event))
|
|
if matches!(
|
|
event.payload.as_ref(),
|
|
Some(synctv_proto::client::resource_event::Payload::ChatEvent(_))
|
|
) =>
|
|
{
|
|
chat_count += 1;
|
|
}
|
|
Some(server_message::Message::Error(_)) => {
|
|
error_received = true;
|
|
break;
|
|
}
|
|
_ => {}
|
|
},
|
|
_ => break,
|
|
}
|
|
}
|
|
|
|
// The rate limiter should have blocked some messages, meaning we either
|
|
// received fewer chats than sent, or the server sent an error, or the
|
|
// connection stayed alive but silently dropped messages (no echo).
|
|
// The key assertion: NOT all 15 messages were echoed as chats.
|
|
assert!(
|
|
chat_count < 15 || error_received,
|
|
"Rate limiter should have blocked some messages: got {chat_count} chats, error={error_received}",
|
|
);
|
|
|
|
ws.close(None).await.expect("close");
|
|
server.shutdown().await;
|
|
infra.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_content_filter_strips_xss() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (user1_id, user1_token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "xss_sender").await;
|
|
let room_id = create_test_room(&server.room_service, &user1_id, "XSS Room").await;
|
|
|
|
let (user2_id, user2_token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "xss_receiver").await;
|
|
let rid = decode_test_room_id(&room_id);
|
|
server
|
|
.room_service
|
|
.join_room(rid, user2_id, None)
|
|
.await
|
|
.expect("user2 join");
|
|
|
|
let mut ws1 = ws_connect(&server.addr, &room_id, &user1_token).await;
|
|
let mut ws2 = ws_connect(&server.addr, &room_id, &user2_token).await;
|
|
|
|
tokio::join!(
|
|
drain_until_quiet(&mut ws1, 1500),
|
|
drain_until_quiet(&mut ws2, 1500),
|
|
);
|
|
send_client_message(&mut ws2, &observe_chat_events_message("chat-events")).await;
|
|
recv_matching_server_message(
|
|
&mut ws2,
|
|
std::time::Duration::from_secs(5),
|
|
|message| {
|
|
matches!(
|
|
message.message,
|
|
Some(server_message::Message::ResourceObserved(_))
|
|
)
|
|
},
|
|
"chat_events observation acknowledgement",
|
|
)
|
|
.await;
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
|
|
|
// user1 sends a message with XSS payload
|
|
let xss_msg = ClientMessage {
|
|
message: Some(client_message::Message::Chat(
|
|
synctv_proto::client::ChatMessageSend {
|
|
content: "<script>alert('xss')</script>Hello safe world".to_string(),
|
|
display_position: String::new(),
|
|
display_color: String::new(),
|
|
client_message_id: String::new(),
|
|
attachments: Vec::new(),
|
|
reply_to_message_id: String::new(),
|
|
metadata: None,
|
|
mentions: Vec::new(),
|
|
},
|
|
)),
|
|
};
|
|
send_client_message(&mut ws1, &xss_msg).await;
|
|
|
|
// user2 should receive the sanitized chat through explicit chat_events observation.
|
|
let received = recv_matching_server_message(
|
|
&mut ws2,
|
|
std::time::Duration::from_secs(10),
|
|
|message| {
|
|
resource_chat_event(message).is_some_and(|event| {
|
|
event.message.as_ref().is_some_and(|chat| {
|
|
!chat.content.contains("<script>")
|
|
&& chat.content.contains("Hello safe world")
|
|
})
|
|
})
|
|
},
|
|
"sanitized chat_events resource update",
|
|
)
|
|
.await;
|
|
|
|
let chat = resource_chat_event(&received)
|
|
.and_then(|event| event.message.as_ref())
|
|
.expect("chat event should include message");
|
|
assert!(!chat.content.contains("<script>"));
|
|
assert!(chat.content.contains("Hello safe world"));
|
|
|
|
ws1.close(None).await.expect("close ws1");
|
|
ws2.close(None).await.expect("close ws2");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_connection_cleanup_on_tcp_drop() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (user1_id, user1_token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "stayer_tcp").await;
|
|
let room_id = create_test_room(&server.room_service, &user1_id, "TCP Drop Room").await;
|
|
|
|
let (user2_id, user2_token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "dropper_tcp").await;
|
|
let rid = decode_test_room_id(&room_id);
|
|
server
|
|
.room_service
|
|
.join_room(rid, user2_id, None)
|
|
.await
|
|
.expect("join");
|
|
|
|
let mut ws1 = ws_connect(&server.addr, &room_id, &user1_token).await;
|
|
send_client_message(&mut ws1, &observe_room_member_events_message("ws1-members")).await;
|
|
drain_until_quiet(&mut ws1, 2000).await;
|
|
|
|
let ws2 = ws_connect(&server.addr, &room_id, &user2_token).await;
|
|
|
|
drain_until_quiet(&mut ws1, 2000).await;
|
|
|
|
// Abruptly drop user2's WebSocket (simulate TCP disconnect without Close frame)
|
|
drop(ws2);
|
|
|
|
// user1 should receive a left member event for user2.
|
|
let left_event = tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
|
loop {
|
|
let msg = recv_server_message(&mut ws1).await.expect("stream ended");
|
|
if resource_room_member_left(&msg).is_some() {
|
|
return msg;
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("timeout waiting for left member event after TCP drop");
|
|
|
|
let left = resource_room_member_left(&left_event).unwrap_or_else(|| {
|
|
panic!(
|
|
"Expected left room member event after TCP drop, got: {:?}",
|
|
left_event.message
|
|
)
|
|
});
|
|
assert_eq!(left.room_id, room_id);
|
|
assert_eq!(left.user_id, encode_test_user_id(&user2_id));
|
|
|
|
ws1.close(None).await.expect("close ws1");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_connection_manager_state_consistency() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (user_id, token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "cycle_user").await;
|
|
let room_id = create_test_room(&server.room_service, &user_id, "Cycle Room").await;
|
|
|
|
for cycle in 0..3 {
|
|
let mut ws = ws_connect(&server.addr, &room_id, &token).await;
|
|
|
|
drain_until_quiet(&mut ws, 2000).await;
|
|
|
|
let heartbeat = ClientMessage {
|
|
message: Some(client_message::Message::Heartbeat(HeartbeatMessage {
|
|
timestamp: chrono::Utc::now().timestamp_millis(),
|
|
})),
|
|
};
|
|
send_client_message(&mut ws, &heartbeat).await;
|
|
|
|
let ack = tokio::time::timeout(
|
|
std::time::Duration::from_secs(5),
|
|
recv_server_message_skip_membership(&mut ws),
|
|
)
|
|
.await
|
|
.unwrap_or_else(|_| panic!("timeout on heartbeat in cycle {cycle}"))
|
|
.expect("stream ended");
|
|
assert!(
|
|
matches!(ack.message, Some(server_message::Message::HeartbeatAck(_))),
|
|
"Expected HeartbeatAck in cycle {cycle}"
|
|
);
|
|
|
|
ws.close(None)
|
|
.await
|
|
.unwrap_or_else(|_| panic!("close in cycle {cycle}"));
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
|
}
|
|
|
|
// After all cycles, the connection manager should have 0 connections for this user
|
|
let user_conn_count = server.connection_manager.user_connection_count(&user_id);
|
|
assert_eq!(
|
|
user_conn_count, 0,
|
|
"After all disconnect cycles, user should have 0 connections, got {user_conn_count}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_empty_chat_rejected() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (user_id, token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "empty_chat_user").await;
|
|
let room_id = create_test_room(&server.room_service, &user_id, "Empty Chat Room").await;
|
|
|
|
let mut ws = ws_connect(&server.addr, &room_id, &token).await;
|
|
|
|
drain_until_quiet(&mut ws, 2000).await;
|
|
|
|
let empty_msg = ClientMessage {
|
|
message: Some(client_message::Message::Chat(
|
|
synctv_proto::client::ChatMessageSend {
|
|
content: String::new(),
|
|
display_position: String::new(),
|
|
display_color: String::new(),
|
|
client_message_id: String::new(),
|
|
attachments: Vec::new(),
|
|
reply_to_message_id: String::new(),
|
|
metadata: None,
|
|
mentions: Vec::new(),
|
|
},
|
|
)),
|
|
};
|
|
send_client_message(&mut ws, &empty_msg).await;
|
|
|
|
let rejection = tokio::time::timeout(
|
|
std::time::Duration::from_secs(5),
|
|
recv_server_message_skip_membership(&mut ws),
|
|
)
|
|
.await
|
|
.expect("timeout waiting for empty chat rejection")
|
|
.expect("stream ended");
|
|
|
|
match rejection.message {
|
|
Some(server_message::Message::Error(err)) => {
|
|
assert!(
|
|
err.message.contains("empty"),
|
|
"expected empty-chat validation error, got: {}",
|
|
err.message
|
|
);
|
|
}
|
|
other => panic!("Expected Error message for empty chat rejection, got: {other:?}"),
|
|
}
|
|
|
|
// Connection should remain alive after the validation error.
|
|
// Verify by sending a heartbeat and getting an ack.
|
|
let heartbeat = ClientMessage {
|
|
message: Some(client_message::Message::Heartbeat(HeartbeatMessage {
|
|
timestamp: chrono::Utc::now().timestamp_millis(),
|
|
})),
|
|
};
|
|
send_client_message(&mut ws, &heartbeat).await;
|
|
|
|
let ack = tokio::time::timeout(
|
|
std::time::Duration::from_secs(5),
|
|
recv_server_message_skip_membership(&mut ws),
|
|
)
|
|
.await
|
|
.expect("timeout waiting for heartbeat ack after empty msg")
|
|
.expect("stream ended");
|
|
|
|
assert!(
|
|
matches!(ack.message, Some(server_message::Message::HeartbeatAck(_))),
|
|
"Connection should still be alive after empty chat rejection"
|
|
);
|
|
|
|
ws.close(None).await.expect("close");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_chat_presentation_fields_broadcast_via_event() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (user1_id, user1_token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "chat_sender").await;
|
|
let room_id = create_test_room(&server.room_service, &user1_id, "Chat Room").await;
|
|
|
|
let (user2_id, user2_token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "chat_receiver").await;
|
|
let rid = decode_test_room_id(&room_id);
|
|
server
|
|
.room_service
|
|
.join_room(rid, user2_id, None)
|
|
.await
|
|
.expect("user2 join");
|
|
|
|
let mut ws1 = ws_connect(&server.addr, &room_id, &user1_token).await;
|
|
let mut ws2 = ws_connect(&server.addr, &room_id, &user2_token).await;
|
|
|
|
tokio::join!(
|
|
drain_until_quiet(&mut ws1, 1500),
|
|
drain_until_quiet(&mut ws2, 1500),
|
|
);
|
|
send_client_message(&mut ws2, &observe_chat_events_message("chat-events")).await;
|
|
recv_matching_server_message(
|
|
&mut ws2,
|
|
std::time::Duration::from_secs(5),
|
|
|message| {
|
|
matches!(
|
|
message.message,
|
|
Some(server_message::Message::ResourceObserved(_))
|
|
)
|
|
},
|
|
"chat_events observation acknowledgement",
|
|
)
|
|
.await;
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
|
|
|
let chat_msg = ClientMessage {
|
|
message: Some(client_message::Message::Chat(
|
|
synctv_proto::client::ChatMessageSend {
|
|
content: "LOL".to_string(),
|
|
display_position: "top".to_string(),
|
|
display_color: "#FF0000".to_string(),
|
|
client_message_id: String::new(),
|
|
attachments: Vec::new(),
|
|
reply_to_message_id: String::new(),
|
|
metadata: None,
|
|
mentions: Vec::new(),
|
|
},
|
|
)),
|
|
};
|
|
send_client_message(&mut ws1, &chat_msg).await;
|
|
|
|
let received = recv_matching_server_message(
|
|
&mut ws2,
|
|
std::time::Duration::from_secs(5),
|
|
|message| {
|
|
resource_chat_event(message).is_some_and(|event| {
|
|
event.message.as_ref().is_some_and(|chat| {
|
|
chat.content == "LOL"
|
|
&& chat.room_id == room_id
|
|
&& chat.user_id == encode_test_user_id(&user1_id)
|
|
&& chat.display_position == "top"
|
|
&& chat.display_color == "#FF0000"
|
|
})
|
|
})
|
|
},
|
|
"chat presentation chat_events resource update",
|
|
)
|
|
.await;
|
|
|
|
assert!(resource_chat_event(&received).is_some());
|
|
|
|
ws1.close(None).await.expect("close ws1");
|
|
ws2.close(None).await.expect("close ws2");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_concurrent_connections_same_user() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (user_id, token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "multi_conn_user").await;
|
|
let room_id = create_test_room(&server.room_service, &user_id, "Multi Conn Room").await;
|
|
|
|
let mut ws1 = ws_connect(&server.addr, &room_id, &token).await;
|
|
let mut ws2 = ws_connect(&server.addr, &room_id, &token).await;
|
|
|
|
let heartbeat = ClientMessage {
|
|
message: Some(client_message::Message::Heartbeat(HeartbeatMessage {
|
|
timestamp: chrono::Utc::now().timestamp_millis(),
|
|
})),
|
|
};
|
|
send_client_message(&mut ws1, &heartbeat).await;
|
|
|
|
let ack = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
|
loop {
|
|
let msg = recv_server_message(&mut ws1).await?;
|
|
if matches!(msg.message, Some(server_message::Message::HeartbeatAck(_))) {
|
|
return Some(msg);
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("timeout waiting for heartbeat ack on conn1")
|
|
.expect("stream ended");
|
|
|
|
assert!(
|
|
matches!(ack.message, Some(server_message::Message::HeartbeatAck(_))),
|
|
"Connection 1 should get HeartbeatAck"
|
|
);
|
|
|
|
ws1.close(None).await.expect("close ws1");
|
|
ws2.close(None).await.expect("close ws2");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_same_user_second_connection_does_not_emit_duplicate_user_joined() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (owner_id, owner_token) = register_test_user(
|
|
&server.user_service,
|
|
&server.jwt_service,
|
|
"owner_join_presence",
|
|
)
|
|
.await;
|
|
let room_id = create_test_room(&server.room_service, &owner_id, "Join Presence Room").await;
|
|
|
|
let (user2_id, user2_token) = register_test_user(
|
|
&server.user_service,
|
|
&server.jwt_service,
|
|
"join_presence_user",
|
|
)
|
|
.await;
|
|
let rid = decode_test_room_id(&room_id);
|
|
server
|
|
.room_service
|
|
.join_room(rid, user2_id, None)
|
|
.await
|
|
.expect("join");
|
|
|
|
let mut ws_owner = ws_connect(&server.addr, &room_id, &owner_token).await;
|
|
let mut ws_user_a = ws_connect(&server.addr, &room_id, &user2_token).await;
|
|
|
|
tokio::join!(
|
|
drain_until_quiet(&mut ws_owner, 1500),
|
|
drain_until_quiet(&mut ws_user_a, 1500),
|
|
);
|
|
|
|
let mut ws_user_b = ws_connect(&server.addr, &room_id, &user2_token).await;
|
|
drain_until_quiet(&mut ws_user_b, 1500).await;
|
|
|
|
let duplicate_join = tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
|
loop {
|
|
let msg = recv_server_message(&mut ws_owner)
|
|
.await
|
|
.expect("stream ended");
|
|
if let Some(joined) = resource_room_member_joined(&msg) {
|
|
if joined.member.as_ref().map(|m| m.user_id.as_str())
|
|
== Some(encode_test_user_id(&user2_id).as_str())
|
|
{
|
|
return msg;
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
|
|
assert!(
|
|
duplicate_join.is_err(),
|
|
"opening a second connection for the same user must not emit duplicate UserJoined while the user is already online"
|
|
);
|
|
|
|
ws_owner.close(None).await.expect("close owner");
|
|
ws_user_a.close(None).await.expect("close user a");
|
|
ws_user_b.close(None).await.expect("close user b");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_same_user_one_of_multiple_connections_disconnect_does_not_emit_user_left() {
|
|
let infra = TestInfra::new().await;
|
|
let mut server = setup_e2e_server(&infra).await;
|
|
|
|
let (owner_id, owner_token) = register_test_user(
|
|
&server.user_service,
|
|
&server.jwt_service,
|
|
"owner_multi_presence",
|
|
)
|
|
.await;
|
|
let room_id =
|
|
create_test_room(&server.room_service, &owner_id, "Multi Presence Room").await;
|
|
|
|
let (user2_id, user2_token) = register_test_user(
|
|
&server.user_service,
|
|
&server.jwt_service,
|
|
"multi_presence_user",
|
|
)
|
|
.await;
|
|
let rid = decode_test_room_id(&room_id);
|
|
server
|
|
.room_service
|
|
.join_room(rid, user2_id, None)
|
|
.await
|
|
.expect("join");
|
|
|
|
let mut ws_owner = ws_connect(&server.addr, &room_id, &owner_token).await;
|
|
let mut ws_user_a = ws_connect(&server.addr, &room_id, &user2_token).await;
|
|
let mut ws_user_b = ws_connect(&server.addr, &room_id, &user2_token).await;
|
|
|
|
tokio::join!(
|
|
drain_until_quiet(&mut ws_owner, 1500),
|
|
drain_until_quiet(&mut ws_user_a, 1500),
|
|
drain_until_quiet(&mut ws_user_b, 1500),
|
|
);
|
|
|
|
let active_connections = server.connection_manager.get_user_connections(&user2_id);
|
|
assert_eq!(
|
|
active_connections.len(),
|
|
2,
|
|
"test precondition failed: expected two active room connections for user2"
|
|
);
|
|
|
|
ws_user_a
|
|
.close(None)
|
|
.await
|
|
.expect("close first user2 connection");
|
|
|
|
let maybe_user_left = tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
|
loop {
|
|
let msg = recv_server_message(&mut ws_owner)
|
|
.await
|
|
.expect("stream ended");
|
|
if resource_room_member_left(&msg).is_some() {
|
|
return msg;
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
|
|
assert!(
|
|
maybe_user_left.is_err(),
|
|
"disconnecting one of multiple same-user connections must not emit UserLeft while another connection remains"
|
|
);
|
|
|
|
let remaining_connections = server.connection_manager.get_user_connections(&user2_id);
|
|
assert_eq!(
|
|
remaining_connections.len(),
|
|
1,
|
|
"exactly one connection should remain after closing the first connection"
|
|
);
|
|
|
|
let heartbeat = ClientMessage {
|
|
message: Some(client_message::Message::Heartbeat(HeartbeatMessage {
|
|
timestamp: chrono::Utc::now().timestamp_millis(),
|
|
})),
|
|
};
|
|
send_client_message(&mut ws_user_b, &heartbeat).await;
|
|
let ack = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
|
loop {
|
|
let msg = recv_server_message(&mut ws_user_b)
|
|
.await
|
|
.expect("stream ended");
|
|
if matches!(msg.message, Some(server_message::Message::HeartbeatAck(_))) {
|
|
return msg;
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("timeout waiting for surviving connection heartbeat ack");
|
|
assert!(
|
|
matches!(ack.message, Some(server_message::Message::HeartbeatAck(_))),
|
|
"surviving same-user connection should remain functional"
|
|
);
|
|
|
|
ws_owner.close(None).await.expect("close owner");
|
|
ws_user_b
|
|
.close(None)
|
|
.await
|
|
.expect("close second user2 connection");
|
|
server.shutdown().await;
|
|
infra.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_webrtc_join_marks_current_connection_for_same_user_multi_conn() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (user_id, token) = register_test_user(
|
|
&server.user_service,
|
|
&server.jwt_service,
|
|
"multi_conn_webrtc",
|
|
)
|
|
.await;
|
|
let room_id =
|
|
create_test_room(&server.room_service, &user_id, "Multi Conn WebRTC Room").await;
|
|
let room = decode_test_room_id(&room_id);
|
|
|
|
let mut ws1 = ws_connect(&server.addr, &room_id, &token).await;
|
|
let mut ws2 = ws_connect(&server.addr, &room_id, &token).await;
|
|
|
|
tokio::join!(
|
|
drain_until_quiet(&mut ws1, 1500),
|
|
drain_until_quiet(&mut ws2, 1500),
|
|
);
|
|
let rtc_join = webrtc_command_message(
|
|
synctv_proto::client::web_rtc_command::Command::VoiceJoin(WebRtcVoiceJoinCommand {
|
|
client_operation_id: None,
|
|
}),
|
|
);
|
|
send_client_message(&mut ws1, &rtc_join).await;
|
|
drain_until_quiet(&mut ws1, 500).await;
|
|
|
|
send_client_message(&mut ws2, &rtc_join).await;
|
|
|
|
let join_event = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
|
loop {
|
|
let msg = recv_server_message(&mut ws1).await.expect("stream ended");
|
|
if resource_webrtc_event(&msg).is_some_and(|event| {
|
|
matches!(
|
|
event.event,
|
|
Some(synctv_proto::client::web_rtc_event::Event::VoicePeerJoined(
|
|
_
|
|
))
|
|
)
|
|
}) {
|
|
return msg;
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("timeout waiting for WebRTC join");
|
|
|
|
let joined_conn_id =
|
|
match resource_webrtc_event(&join_event).and_then(|event| event.event.as_ref()) {
|
|
Some(synctv_proto::client::web_rtc_event::Event::VoicePeerJoined(joined)) => {
|
|
joined.conn_id.clone()
|
|
}
|
|
other => panic!("Expected WebRTC join resource event, got: {other:?}"),
|
|
};
|
|
|
|
let same_user_connections = server.connection_manager.get_user_connections(&user_id);
|
|
assert_eq!(
|
|
same_user_connections.len(),
|
|
2,
|
|
"test precondition failed: expected two active connections for same user"
|
|
);
|
|
|
|
let voice_rtc_joined_connections: Vec<_> = server
|
|
.connection_manager
|
|
.get_room_connections(&room)
|
|
.into_iter()
|
|
.filter(|conn| conn.actor.user_id() == Some(user_id) && conn.voice_rtc_joined)
|
|
.collect();
|
|
|
|
assert_eq!(
|
|
voice_rtc_joined_connections.len(),
|
|
2,
|
|
"both same-user connections can independently join WebRTC"
|
|
);
|
|
assert!(
|
|
voice_rtc_joined_connections
|
|
.iter()
|
|
.any(|connection| connection.connection_id == joined_conn_id),
|
|
"the reported WebRTC join connection must be marked voice_rtc_joined"
|
|
);
|
|
|
|
ws1.close(None).await.expect("close ws1");
|
|
ws2.close(None).await.expect("close ws2");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_webrtc_offer_rejects_recipient_without_conn_id() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (user_id, token) = register_test_user(
|
|
&server.user_service,
|
|
&server.jwt_service,
|
|
"multi_conn_webrtc_offer",
|
|
)
|
|
.await;
|
|
let (peer_user_id, peer_token) = register_test_user(
|
|
&server.user_service,
|
|
&server.jwt_service,
|
|
"multi_conn_webrtc_peer",
|
|
)
|
|
.await;
|
|
let room_id = create_test_room(
|
|
&server.room_service,
|
|
&user_id,
|
|
"Multi Conn WebRTC Offer Room",
|
|
)
|
|
.await;
|
|
let room = decode_test_room_id(&room_id);
|
|
server
|
|
.room_service
|
|
.join_room(room, peer_user_id, None)
|
|
.await
|
|
.expect("peer joins room");
|
|
|
|
let mut ws1 = ws_connect(&server.addr, &room_id, &token).await;
|
|
let mut ws2 = ws_connect(&server.addr, &room_id, &token).await;
|
|
let mut ws_peer = ws_connect(&server.addr, &room_id, &peer_token).await;
|
|
|
|
tokio::join!(
|
|
drain_until_quiet(&mut ws1, 1500),
|
|
drain_until_quiet(&mut ws2, 1500),
|
|
drain_until_quiet(&mut ws_peer, 1500),
|
|
);
|
|
|
|
let mut user_connections = server.connection_manager.get_user_connections(&user_id);
|
|
user_connections.sort_by(|left, right| left.connection_id.cmp(&right.connection_id));
|
|
assert_eq!(
|
|
user_connections.len(),
|
|
2,
|
|
"test precondition failed: expected two active connections for same user"
|
|
);
|
|
let conn_a = user_connections[0].connection_id.clone();
|
|
|
|
server.connection_manager.disconnect_connection(&conn_a);
|
|
let ws1_closed = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
|
loop {
|
|
match ws1.next().await {
|
|
Some(Ok(tungstenite::Message::Close(_)) | Err(_)) | None => return true,
|
|
Some(Ok(_)) => {}
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.is_ok();
|
|
|
|
let sender_is_ws2 = if ws1_closed {
|
|
true
|
|
} else {
|
|
let ws2_closed = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
|
loop {
|
|
match ws2.next().await {
|
|
Some(Ok(tungstenite::Message::Close(_)) | Err(_)) | None => return true,
|
|
Some(Ok(_)) => {}
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.is_ok();
|
|
assert!(
|
|
ws2_closed,
|
|
"one of the two connections must close after targeted disconnect"
|
|
);
|
|
|
|
false
|
|
};
|
|
|
|
let active_sender_connections = server.connection_manager.get_user_connections(&user_id);
|
|
assert_eq!(
|
|
active_sender_connections.len(),
|
|
1,
|
|
"after targeted disconnect exactly one sender connection should remain"
|
|
);
|
|
let offer =
|
|
webrtc_command_message(synctv_proto::client::web_rtc_command::Command::VoiceOffer(
|
|
synctv_proto::client::WebRtcVoiceOfferCommand {
|
|
to: encode_test_user_id(&peer_user_id),
|
|
data: "{\"type\":\"offer\",\"sdp\":\"test-sdp\"}".to_string(),
|
|
},
|
|
));
|
|
if sender_is_ws2 {
|
|
send_client_message(&mut ws2, &offer).await;
|
|
} else {
|
|
send_client_message(&mut ws1, &offer).await;
|
|
}
|
|
|
|
let sender_ws = if sender_is_ws2 { &mut ws2 } else { &mut ws1 };
|
|
let error_message = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
|
loop {
|
|
let msg = recv_server_message_skip_membership(sender_ws)
|
|
.await
|
|
.expect("stream ended");
|
|
if matches!(&msg.message, Some(server_message::Message::Error(_))) {
|
|
return msg;
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("timeout waiting for WebRTC error");
|
|
|
|
match error_message.message {
|
|
Some(server_message::Message::Error(error)) => {
|
|
assert!(
|
|
error.message.contains("public_actor_id:conn_id"),
|
|
"expected recipient format error, got: {}",
|
|
error.message
|
|
);
|
|
}
|
|
other => panic!("Expected Error, got: {other:?}"),
|
|
}
|
|
|
|
let peer_received_offer =
|
|
tokio::time::timeout(std::time::Duration::from_millis(500), async {
|
|
loop {
|
|
let msg = recv_server_message(&mut ws_peer).await;
|
|
match msg {
|
|
Some(server_message)
|
|
if matches!(
|
|
&server_message.message,
|
|
Some(server_message::Message::ResourceEvent(event))
|
|
if matches!(
|
|
event.payload.as_ref(),
|
|
Some(synctv_proto::client::resource_event::Payload::WebrtcEvent(
|
|
webrtc
|
|
)) if matches!(
|
|
webrtc.event.as_ref(),
|
|
Some(synctv_proto::client::web_rtc_event::Event::VoiceOffer(_))
|
|
)
|
|
)
|
|
) =>
|
|
{
|
|
return true;
|
|
}
|
|
Some(_) => {}
|
|
None => return false,
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.unwrap_or(false);
|
|
|
|
assert!(
|
|
!peer_received_offer,
|
|
"peer must not receive signaling when recipient conn_id is omitted"
|
|
);
|
|
|
|
let _ = ws1.close(None).await;
|
|
let _ = ws2.close(None).await;
|
|
let _ = ws_peer.close(None).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_webrtc_offer_requires_target_connection_to_join_webrtc() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (user_id, token) = register_test_user(
|
|
&server.user_service,
|
|
&server.jwt_service,
|
|
"webrtc_sender_requires_join",
|
|
)
|
|
.await;
|
|
let (peer_user_id, peer_token) = register_test_user(
|
|
&server.user_service,
|
|
&server.jwt_service,
|
|
"webrtc_peer_requires_join",
|
|
)
|
|
.await;
|
|
let room_id = create_test_room(
|
|
&server.room_service,
|
|
&user_id,
|
|
"WebRTC Offer Requires Join Room",
|
|
)
|
|
.await;
|
|
let room = decode_test_room_id(&room_id);
|
|
server
|
|
.room_service
|
|
.join_room(room, peer_user_id, None)
|
|
.await
|
|
.expect("peer joins room");
|
|
|
|
let mut ws_sender = ws_connect(&server.addr, &room_id, &token).await;
|
|
let mut ws_peer = ws_connect(&server.addr, &room_id, &peer_token).await;
|
|
|
|
tokio::join!(
|
|
drain_until_quiet(&mut ws_sender, 1500),
|
|
drain_until_quiet(&mut ws_peer, 1500),
|
|
);
|
|
let peer_conn_id = server
|
|
.connection_manager
|
|
.get_user_connections(&peer_user_id)
|
|
.into_iter()
|
|
.find(|conn| conn.room_id.as_ref() == Some(&room))
|
|
.expect("peer connection must be present")
|
|
.connection_id;
|
|
|
|
send_client_message(
|
|
&mut ws_sender,
|
|
&webrtc_command_message(synctv_proto::client::web_rtc_command::Command::VoiceJoin(
|
|
synctv_proto::client::WebRtcVoiceJoinCommand::default(),
|
|
)),
|
|
)
|
|
.await;
|
|
|
|
let offer =
|
|
webrtc_command_message(synctv_proto::client::web_rtc_command::Command::VoiceOffer(
|
|
synctv_proto::client::WebRtcVoiceOfferCommand {
|
|
to: format!("{}:{}", encode_test_user_id(&peer_user_id), peer_conn_id),
|
|
data: "{\"type\":\"offer\",\"sdp\":\"test-sdp\"}".to_string(),
|
|
},
|
|
));
|
|
send_client_message(&mut ws_sender, &offer).await;
|
|
|
|
let error_message = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
|
loop {
|
|
let msg = recv_server_message_skip_membership(&mut ws_sender)
|
|
.await
|
|
.expect("stream ended");
|
|
if matches!(&msg.message, Some(server_message::Message::Error(_))) {
|
|
return msg;
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("timeout waiting for WebRTC join-state error");
|
|
|
|
match error_message.message {
|
|
Some(server_message::Message::Error(error)) => {
|
|
assert!(
|
|
error
|
|
.message
|
|
.contains("Target connection has not joined room voice chat"),
|
|
"expected target join-state error, got: {}",
|
|
error.message
|
|
);
|
|
}
|
|
other => panic!("Expected Error, got: {other:?}"),
|
|
}
|
|
|
|
let peer_received_offer =
|
|
tokio::time::timeout(std::time::Duration::from_millis(500), async {
|
|
loop {
|
|
let msg = recv_server_message(&mut ws_peer).await;
|
|
match msg {
|
|
Some(server_message)
|
|
if matches!(
|
|
&server_message.message,
|
|
Some(server_message::Message::ResourceEvent(event))
|
|
if matches!(
|
|
event.payload.as_ref(),
|
|
Some(synctv_proto::client::resource_event::Payload::WebrtcEvent(
|
|
webrtc
|
|
)) if matches!(
|
|
webrtc.event.as_ref(),
|
|
Some(synctv_proto::client::web_rtc_event::Event::VoiceOffer(_))
|
|
)
|
|
)
|
|
) =>
|
|
{
|
|
return true;
|
|
}
|
|
Some(_) => {}
|
|
None => return false,
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.unwrap_or(false);
|
|
|
|
assert!(
|
|
!peer_received_offer,
|
|
"peer must not receive signaling before explicitly joining WebRTC"
|
|
);
|
|
|
|
let _ = ws_sender.close(None).await;
|
|
let _ = ws_peer.close(None).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_invalid_ticket_rejected() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let url = format!(
|
|
"ws://{}/ws/rooms/invalid_room?ticket=totally_invalid_ticket_xyz123",
|
|
server.addr
|
|
);
|
|
let result = tokio_tungstenite::connect_async(&url).await;
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"Connection with invalid ticket should be rejected by the server"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_expired_ticket_rejected() {
|
|
// memory-backed store.
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let url = format!(
|
|
"ws://{}/ws/rooms/invalid_room?ticket=expired_or_invalid_ticket_aabbcc",
|
|
server.addr
|
|
);
|
|
let result = tokio_tungstenite::connect_async(&url).await;
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"Connection with expired/invalid ticket should be rejected"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_invalid_room_id_rejected_before_upgrade() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
let (_user_id, token) = register_test_user(
|
|
&server.user_service,
|
|
&server.jwt_service,
|
|
"invalid_room_user",
|
|
)
|
|
.await;
|
|
|
|
let url = format!("ws://{}/ws/rooms/room@bad1234", server.addr);
|
|
let request = tungstenite::http::Request::builder()
|
|
.uri(&url)
|
|
.header("Authorization", format!("Bearer {token}"))
|
|
.header("Connection", "Upgrade")
|
|
.header("Upgrade", "websocket")
|
|
.header("Sec-WebSocket-Version", "13")
|
|
.header(
|
|
"Sec-WebSocket-Key",
|
|
tungstenite::handshake::client::generate_key(),
|
|
)
|
|
.header("Host", &server.addr)
|
|
.body(())
|
|
.expect("build malformed room WebSocket request");
|
|
let result = tokio_tungstenite::connect_async(request).await;
|
|
|
|
match result {
|
|
Err(tokio_tungstenite::tungstenite::Error::Http(response)) => {
|
|
assert_eq!(
|
|
response.status(),
|
|
tungstenite::http::StatusCode::BAD_REQUEST,
|
|
"malformed room_id must be rejected before the WebSocket upgrade completes"
|
|
);
|
|
}
|
|
other => panic!("Expected HTTP 400 for malformed room_id, got: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_ticket_rejected_by_membership_does_not_consume_ticket() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (owner_id, _owner_token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "ticket_owner").await;
|
|
let (outsider_id, _outsider_token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "ticket_outsider").await;
|
|
let room_id = create_test_room(&server.room_service, &owner_id, "Ticket Membership").await;
|
|
|
|
let ticket = server
|
|
.ws_ticket_service
|
|
.create_ticket(&outsider_id, &decode_test_room_id(&room_id), 0)
|
|
.await
|
|
.expect("create websocket ticket");
|
|
|
|
let url = format!("ws://{}/ws/rooms/{}?ticket={ticket}", server.addr, room_id);
|
|
let result = tokio_tungstenite::connect_async(&url).await;
|
|
|
|
match result {
|
|
Err(tokio_tungstenite::tungstenite::Error::Http(response)) => {
|
|
assert_eq!(
|
|
response.status(),
|
|
tungstenite::http::StatusCode::FORBIDDEN,
|
|
"non-member ticket must be rejected before websocket upgrade"
|
|
);
|
|
}
|
|
other => panic!("Expected HTTP 403 for non-member ticket, got: {other:?}"),
|
|
}
|
|
|
|
let validated = server
|
|
.ws_ticket_service
|
|
.validate_and_consume(&ticket, &decode_test_room_id(&room_id))
|
|
.await
|
|
.expect("membership rejection must not consume the ticket");
|
|
assert_eq!(validated.user_id().expect("user ticket"), outsider_id);
|
|
}
|
|
|
|
// We test the Authorization header path: a raw JWT passed via Bearer
|
|
// header succeeds, proving the WebSocket upgrade + auth pipeline is
|
|
// working. The ticket path is tested at the unit level in the ticket module.
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_valid_token_query_auth_succeeds() {
|
|
let infra = TestInfra::new().await;
|
|
let mut server = setup_e2e_server(&infra).await;
|
|
|
|
let (user_id, token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "valid_auth_user").await;
|
|
let room_id = create_test_room(&server.room_service, &user_id, "Auth Test Room").await;
|
|
|
|
// ws_connect already asserts HTTP 101 Switching Protocols
|
|
let mut ws = ws_connect(&server.addr, &room_id, &token).await;
|
|
|
|
let heartbeat = ClientMessage {
|
|
message: Some(client_message::Message::Heartbeat(HeartbeatMessage {
|
|
timestamp: chrono::Utc::now().timestamp_millis(),
|
|
})),
|
|
};
|
|
send_client_message(&mut ws, &heartbeat).await;
|
|
recv_matching_server_message(
|
|
&mut ws,
|
|
std::time::Duration::from_secs(5),
|
|
|message| {
|
|
matches!(
|
|
message.message,
|
|
Some(server_message::Message::HeartbeatAck(_))
|
|
)
|
|
},
|
|
"heartbeat ack after valid auth",
|
|
)
|
|
.await;
|
|
|
|
ws.close(None).await.expect("close");
|
|
server.shutdown().await;
|
|
infra.cleanup().await;
|
|
}
|
|
|
|
// Sending invalid message format returns an error response
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_invalid_message_format_returns_error() {
|
|
let infra = TestInfra::new().await;
|
|
let server = setup_e2e_server(&infra).await;
|
|
|
|
let (user_id, token) = register_test_user(
|
|
&server.user_service,
|
|
&server.jwt_service,
|
|
"invalid_msg_user",
|
|
)
|
|
.await;
|
|
let room_id = create_test_room(&server.room_service, &user_id, "Invalid Msg Room").await;
|
|
|
|
let mut ws = ws_connect(&server.addr, &room_id, &token).await;
|
|
|
|
drain_until_quiet(&mut ws, 2000).await;
|
|
|
|
let garbage = vec![0xFF, 0xFE, 0xFD, 0xFC, 0xFB, 0xFA, 0x01, 0x02];
|
|
ws.send(tungstenite::Message::Binary(garbage.into()))
|
|
.await
|
|
.expect("send garbage bytes");
|
|
|
|
// The server should either:
|
|
// a) Send an Error ServerMessage back, OR
|
|
// b) Close the connection
|
|
// In either case the connection should handle the bad input gracefully.
|
|
let result = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
|
loop {
|
|
match ws.next().await {
|
|
Some(Ok(tungstenite::Message::Binary(bytes))) => {
|
|
// Decode and check if it is an error response
|
|
if let Ok(msg) = ProtoCodec::decode_server_message(&bytes) {
|
|
if matches!(msg.message, Some(server_message::Message::Error(_))) {
|
|
return "error_message";
|
|
}
|
|
// Other message types (e.g. buffered events) keep draining
|
|
}
|
|
}
|
|
Some(Ok(tungstenite::Message::Close(_))) | None => {
|
|
return "connection_closed";
|
|
}
|
|
Some(Err(_)) => {
|
|
return "connection_error";
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
|
|
match result {
|
|
Ok("error_message" | "connection_closed" | "connection_error") => {
|
|
// Server closed the connection on invalid input — also acceptable
|
|
}
|
|
Err(_timeout) => {
|
|
// Server kept the connection alive but sent nothing back for garbage.
|
|
// This is acceptable as long as subsequent valid messages still work.
|
|
// Verify by sending a heartbeat.
|
|
let heartbeat = ClientMessage {
|
|
message: Some(client_message::Message::Heartbeat(HeartbeatMessage {
|
|
timestamp: chrono::Utc::now().timestamp_millis(),
|
|
})),
|
|
};
|
|
send_client_message(&mut ws, &heartbeat).await;
|
|
let ack = tokio::time::timeout(
|
|
std::time::Duration::from_secs(5),
|
|
recv_server_message(&mut ws),
|
|
)
|
|
.await
|
|
.expect("timeout on heartbeat after garbage")
|
|
.expect("stream ended after garbage");
|
|
assert!(
|
|
matches!(ack.message, Some(server_message::Message::HeartbeatAck(_))),
|
|
"Connection should still respond to heartbeat after invalid message"
|
|
);
|
|
ws.close(None).await.ok();
|
|
}
|
|
Ok(other) => panic!("Unexpected result: {other}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod websocket_connection_limit_timing {
|
|
use super::wait_for_condition;
|
|
use tokio_tungstenite::tungstenite;
|
|
|
|
use synctv_realtime::sync::ConnectionLimits;
|
|
|
|
use super::websocket_e2e::{
|
|
create_test_room, decode_test_room_id, register_test_user,
|
|
setup_e2e_server_with_connection_limits, TestInfra,
|
|
};
|
|
|
|
fn single_connection_per_user_limits() -> ConnectionLimits {
|
|
ConnectionLimits {
|
|
webrtc_session_timeout: Default::default(),
|
|
max_per_user: 1,
|
|
max_per_room: 200,
|
|
max_total: 10000,
|
|
idle_timeout: std::time::Duration::from_mins(5),
|
|
max_duration: std::time::Duration::from_hours(24),
|
|
}
|
|
}
|
|
|
|
/// Build a WebSocket request with Authorization header (no `?token=` query param).
|
|
fn ws_request(addr: &str, room_id: &str, token: &str) -> tungstenite::http::Request<()> {
|
|
let url = format!("ws://{addr}/ws/rooms/{room_id}?format=protobuf");
|
|
tungstenite::http::Request::builder()
|
|
.uri(&url)
|
|
.header("Authorization", format!("Bearer {token}"))
|
|
.header("Connection", "Upgrade")
|
|
.header("Upgrade", "websocket")
|
|
.header("Sec-WebSocket-Version", "13")
|
|
.header(
|
|
"Sec-WebSocket-Key",
|
|
tungstenite::handshake::client::generate_key(),
|
|
)
|
|
.header("Host", addr)
|
|
.body(())
|
|
.expect("build WS request")
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_connection_limit_returns_429_before_upgrade() {
|
|
let infra = TestInfra::new().await;
|
|
let mut server =
|
|
setup_e2e_server_with_connection_limits(&infra, single_connection_per_user_limits())
|
|
.await;
|
|
|
|
let (user_id, token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "limit_user").await;
|
|
let room_id = create_test_room(&server.room_service, &user_id, "Limit Test Room").await;
|
|
|
|
let (ws1, response1) =
|
|
tokio_tungstenite::connect_async(ws_request(&server.addr, &room_id, &token))
|
|
.await
|
|
.expect("First WebSocket connect should succeed");
|
|
assert_eq!(
|
|
response1.status(),
|
|
tungstenite::http::StatusCode::SWITCHING_PROTOCOLS,
|
|
"First connection should get HTTP 101 Switching Protocols"
|
|
);
|
|
|
|
wait_for_condition(std::time::Duration::from_secs(2), || {
|
|
server.connection_manager.user_connection_count(&user_id) == 1
|
|
})
|
|
.await;
|
|
|
|
let result =
|
|
tokio_tungstenite::connect_async(ws_request(&server.addr, &room_id, &token)).await;
|
|
|
|
match result {
|
|
Err(tokio_tungstenite::tungstenite::Error::Http(response)) => {
|
|
assert_eq!(
|
|
response.status(),
|
|
tungstenite::http::StatusCode::TOO_MANY_REQUESTS,
|
|
"Second connection should get HTTP 429 Too Many Requests, got {}",
|
|
response.status()
|
|
);
|
|
}
|
|
Err(e) => {
|
|
panic!("Expected HTTP error with status 429, got: {e:?}");
|
|
}
|
|
Ok((_ws2, response)) => {
|
|
panic!(
|
|
"BUG: Second connection was upgraded with status {} instead of being rejected with 429. \
|
|
Connection limit check is happening AFTER WebSocket upgrade!",
|
|
response.status()
|
|
);
|
|
}
|
|
}
|
|
|
|
drop(ws1);
|
|
server.shutdown().await;
|
|
infra.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_normal_connection_within_limits() {
|
|
let infra = TestInfra::new().await;
|
|
let mut server =
|
|
setup_e2e_server_with_connection_limits(&infra, single_connection_per_user_limits())
|
|
.await;
|
|
|
|
let (user_id, token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "normal_user").await;
|
|
let room_id = create_test_room(&server.room_service, &user_id, "Normal Flow Room").await;
|
|
|
|
let (_ws, response) =
|
|
tokio_tungstenite::connect_async(ws_request(&server.addr, &room_id, &token))
|
|
.await
|
|
.expect("WebSocket connect should succeed");
|
|
|
|
assert_eq!(
|
|
response.status(),
|
|
tungstenite::http::StatusCode::SWITCHING_PROTOCOLS,
|
|
"Connection within limits should get HTTP 101"
|
|
);
|
|
|
|
wait_for_condition(std::time::Duration::from_secs(2), || {
|
|
server.connection_manager.user_connection_count(&user_id) == 1
|
|
})
|
|
.await;
|
|
server.shutdown().await;
|
|
infra.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_different_users_can_connect_within_limits() {
|
|
let infra = TestInfra::new().await;
|
|
let mut server =
|
|
setup_e2e_server_with_connection_limits(&infra, single_connection_per_user_limits())
|
|
.await;
|
|
|
|
let (user1_id, user1_token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "user1").await;
|
|
let (user2_id, user2_token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "user2").await;
|
|
|
|
let room_id = create_test_room(&server.room_service, &user1_id, "Multi User Room").await;
|
|
|
|
let rid = decode_test_room_id(&room_id);
|
|
server
|
|
.room_service
|
|
.join_room(rid, user2_id, None)
|
|
.await
|
|
.expect("user2 join room");
|
|
|
|
let (_ws1, response1) =
|
|
tokio_tungstenite::connect_async(ws_request(&server.addr, &room_id, &user1_token))
|
|
.await
|
|
.expect("User1 WebSocket connect should succeed");
|
|
|
|
assert_eq!(
|
|
response1.status(),
|
|
tungstenite::http::StatusCode::SWITCHING_PROTOCOLS,
|
|
"User1 should get HTTP 101"
|
|
);
|
|
|
|
let (_ws2, response2) =
|
|
tokio_tungstenite::connect_async(ws_request(&server.addr, &room_id, &user2_token))
|
|
.await
|
|
.expect("User2 WebSocket connect should succeed");
|
|
|
|
assert_eq!(
|
|
response2.status(),
|
|
tungstenite::http::StatusCode::SWITCHING_PROTOCOLS,
|
|
"User2 should get HTTP 101"
|
|
);
|
|
|
|
wait_for_condition(std::time::Duration::from_secs(2), || {
|
|
server.connection_manager.user_connection_count(&user1_id) == 1
|
|
&& server.connection_manager.user_connection_count(&user2_id) == 1
|
|
})
|
|
.await;
|
|
server.shutdown().await;
|
|
infra.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ws_can_reconnect_after_disconnect() {
|
|
let infra = TestInfra::new().await;
|
|
let mut server =
|
|
setup_e2e_server_with_connection_limits(&infra, single_connection_per_user_limits())
|
|
.await;
|
|
|
|
let (user_id, token) =
|
|
register_test_user(&server.user_service, &server.jwt_service, "reconnect_user").await;
|
|
let room_id = create_test_room(&server.room_service, &user_id, "Reconnect Room").await;
|
|
|
|
let (ws1, response1) =
|
|
tokio_tungstenite::connect_async(ws_request(&server.addr, &room_id, &token))
|
|
.await
|
|
.expect("First connect should succeed");
|
|
|
|
assert_eq!(
|
|
response1.status(),
|
|
tungstenite::http::StatusCode::SWITCHING_PROTOCOLS
|
|
);
|
|
|
|
wait_for_condition(std::time::Duration::from_secs(2), || {
|
|
server.connection_manager.user_connection_count(&user_id) == 1
|
|
})
|
|
.await;
|
|
|
|
drop(ws1);
|
|
|
|
wait_for_condition(std::time::Duration::from_secs(2), || {
|
|
server.connection_manager.user_connection_count(&user_id) == 0
|
|
})
|
|
.await;
|
|
|
|
let (ws2, response2) =
|
|
tokio_tungstenite::connect_async(ws_request(&server.addr, &room_id, &token))
|
|
.await
|
|
.expect("Reconnect should succeed");
|
|
|
|
assert_eq!(
|
|
response2.status(),
|
|
tungstenite::http::StatusCode::SWITCHING_PROTOCOLS,
|
|
"Reconnection after disconnect should get HTTP 101"
|
|
);
|
|
|
|
wait_for_condition(std::time::Duration::from_secs(2), || {
|
|
server.connection_manager.user_connection_count(&user_id) == 1
|
|
})
|
|
.await;
|
|
|
|
drop(ws2);
|
|
server.shutdown().await;
|
|
infra.cleanup().await;
|
|
}
|
|
}
|