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.
synctv/synctv-core/tests/ws_ticket_redis_tests.rs

424 lines
13 KiB
Rust

//! WebSocket ticket Redis tests
//!
//! Tests the `RedisTicketStore` backend via testcontainers:
//! roundtrip, one-time use, room mismatch, TTL expiry, concurrent consumption.
//!
//! Also tests cluster mode Redis dependency:
//! - cluster mode with Redis should work correctly
//! - cluster mode without Redis should return an error (tested in unit tests)
//!
use redis::AsyncCommands;
use std::sync::Arc;
use synctv_core::models::{RoomId, UserId};
use synctv_core::service::{
RedisTicketStore, UserValidationResult, UserValidator, WsTicketService,
};
use synctv_core::{Error, Result};
use synctv_core_testing::{
ok, some, start_redis_handle as start_test_redis_handle, test_redis_key_prefix,
};
use tokio::sync::RwLock;
async fn start_redis() -> (
synctv_core_testing::RedisContainer,
Arc<RwLock<redis::aio::ConnectionManager>>,
String,
) {
let (container, conn) = start_test_redis_handle().await;
let prefix = test_redis_key_prefix("ws-ticket");
(container, conn, prefix)
}
fn redis_ticket_service(
conn: Arc<RwLock<redis::aio::ConnectionManager>>,
prefix: &str,
ticket_ttl_secs: Option<u64>,
) -> WsTicketService {
WsTicketService::from_store(
Arc::new(RedisTicketStore::from_runtime(
synctv_core::shared_runtime(conn),
prefix,
)),
ticket_ttl_secs,
)
}
fn user_id(id: i64) -> UserId {
UserId::expect_positive(id)
}
fn room_id(id: i64) -> RoomId {
RoomId::expect_positive(id)
}
struct StaticUserValidator {
result: std::result::Result<UserValidationResult, &'static str>,
}
#[async_trait::async_trait]
impl UserValidator for StaticUserValidator {
async fn validate_for_ticket(&self, _user_id: &UserId) -> Result<UserValidationResult> {
self.result
.clone()
.map_err(|message| Error::Authorization((*message).to_string()))
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_redis_ticket_create_and_validate_roundtrip() {
let (_container, conn, prefix) = start_redis().await;
let service = redis_ticket_service(conn, &prefix, Some(30));
let uid = user_id(1001);
let rid = room_id(2001);
let ticket = ok(
service.create_ticket(&uid, &rid, 0).await,
"Redis ticket should be created",
);
assert!(!ticket.is_empty());
let validated = ok(
service.validate_and_consume(&ticket, &rid).await,
"Redis ticket should validate",
);
assert_eq!(
some(validated.user_id(), "ticket should contain user id"),
uid
);
assert_eq!(validated.password_version(), Some(0));
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_redis_ticket_one_time_use() {
let (_container, conn, prefix) = start_redis().await;
let service = redis_ticket_service(conn, &prefix, Some(30));
let uid = user_id(1002);
let rid = room_id(2002);
let ticket = ok(
service.create_ticket(&uid, &rid, 0).await,
"Redis ticket should be created",
);
// First consume succeeds
let result1 = service.validate_and_consume(&ticket, &rid).await;
assert!(result1.is_ok(), "First consumption should succeed");
// Second consume fails
let result2 = service.validate_and_consume(&ticket, &rid).await;
assert!(
result2.is_err(),
"Second consumption should fail (one-time use)"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_redis_ticket_room_mismatch_rejected() {
let (_container, conn, prefix) = start_redis().await;
let service = redis_ticket_service(conn, &prefix, Some(30));
let uid = user_id(1003);
let room_a = room_id(2003);
let room_b = room_id(2004);
let ticket = ok(
service.create_ticket(&uid, &room_a, 0).await,
"Redis ticket should be created",
);
// Try to consume with wrong room
let result = service.validate_and_consume(&ticket, &room_b).await;
assert!(
matches!(result, Err(Error::Authorization(_))),
"Ticket for room A should not work for room B"
);
let retry_result = service.validate_and_consume(&ticket, &room_a).await;
assert!(
retry_result.is_ok(),
"room mismatch must not consume a valid Redis-backed ticket"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_redis_ticket_ttl_expiry() {
let (_container, conn, prefix) = start_redis().await;
// 1-second TTL
let service = redis_ticket_service(conn, &prefix, Some(1));
let uid = user_id(1004);
let rid = room_id(2005);
let ticket = ok(
service.create_ticket(&uid, &rid, 0).await,
"Redis ticket should be created",
);
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
let result = service.validate_and_consume(&ticket, &rid).await;
assert!(result.is_err(), "Ticket should have expired after TTL");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_redis_ticket_concurrent_consumption() {
let (_container, conn, prefix) = start_redis().await;
let service = redis_ticket_service(conn, &prefix, Some(30));
let uid = user_id(1005);
let rid = room_id(2006);
let ticket = ok(
service.create_ticket(&uid, &rid, 0).await,
"Redis ticket should be created",
);
// Spawn 10 tasks that all try to consume the same ticket concurrently
let mut handles = Vec::new();
for _ in 0..10 {
let s = service.clone();
let t = ticket.clone();
let r = rid;
handles.push(tokio::spawn(
async move { s.validate_and_consume(&t, &r).await },
));
}
let results: Vec<_> = futures::future::join_all(handles)
.await
.into_iter()
.map(|r| ok(r, "concurrent ticket validation task should join"))
.collect();
let successes = results.iter().filter(|r| r.is_ok()).count();
let failures = results.iter().filter(|r| r.is_err()).count();
assert_eq!(
successes, 1,
"Exactly 1 of 10 concurrent consumers should succeed"
);
assert_eq!(failures, 9, "9 consumers should fail");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_redis_ticket_user_validation_failure_does_not_consume_ticket() {
let (_container, conn, prefix) = start_redis().await;
let service = redis_ticket_service(conn, &prefix, Some(30));
let uid = user_id(1006);
let rid = room_id(2007);
let ticket = ok(
service.create_ticket(&uid, &rid, 9).await,
"Redis ticket should be created",
);
let rejecting_validator = StaticUserValidator {
result: Err("banned"),
};
let allow_validator = StaticUserValidator {
result: Ok(UserValidationResult {
password_version: 9,
}),
};
let first_result = service
.validate_and_consume_checked(&ticket, &rid, &rejecting_validator)
.await;
assert!(
matches!(first_result, Err(Error::Authentication(_))),
"user validation failure should reject the ticket"
);
let second_result = service
.validate_and_consume_checked(&ticket, &rid, &allow_validator)
.await;
assert!(
second_result.is_ok(),
"user validation rejection must not consume a valid Redis-backed ticket"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_redis_ticket_checked_validation_is_still_one_time_use() {
let (_container, conn, prefix) = start_redis().await;
let service = redis_ticket_service(conn, &prefix, Some(30));
let uid = user_id(1007);
let rid = room_id(2008);
let ticket = ok(
service.create_ticket(&uid, &rid, 3).await,
"Redis ticket should be created",
);
let allow_validator = StaticUserValidator {
result: Ok(UserValidationResult {
password_version: 3,
}),
};
let first_result = service
.validate_and_consume_checked(&ticket, &rid, &allow_validator)
.await;
assert!(
first_result.is_ok(),
"first checked validation should succeed"
);
let second_result = service
.validate_and_consume_checked(&ticket, &rid, &allow_validator)
.await;
assert!(
matches!(second_result, Err(Error::Authentication(_))),
"checked validation must still enforce one-time use"
);
}
// Cluster mode Redis dependency tests.
/// Test: cluster mode with Redis works correctly.
/// This is the main fix - in cluster mode with Redis, tickets should work
/// because they are stored in shared Redis storage visible to all replicas.
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_cluster_mode_with_redis_succeeds() {
let (_container, conn, prefix) = start_redis().await;
let service = redis_ticket_service(conn, &prefix, Some(30));
assert!(
service.supports_cluster_runtime(),
"Cluster mode with Redis should use cross-node capable ticket storage"
);
}
/// Test: cluster mode with Redis allows ticket creation and validation.
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_cluster_mode_with_redis_roundtrip() {
let (_container, conn, prefix) = start_redis().await;
let service = redis_ticket_service(conn, &prefix, Some(30));
let uid = user_id(1008);
let rid = room_id(2009);
let ticket = ok(
service.create_ticket(&uid, &rid, 0).await,
"Redis ticket should be created",
);
assert!(!ticket.is_empty());
let validated = ok(
service.validate_and_consume(&ticket, &rid).await,
"Redis ticket should validate",
);
assert_eq!(
some(validated.user_id(), "ticket should contain user id"),
uid
);
assert_eq!(validated.password_version(), Some(0));
}
/// Test: cluster mode with Redis custom TTL works.
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_cluster_mode_with_redis_custom_ttl() {
let (_container, conn, prefix) = start_redis().await;
let service = redis_ticket_service(conn, &prefix, Some(60));
assert_eq!(service.ticket_ttl_secs(), 60);
}
/// Test: `WsTicketService::with_redis` always creates a Redis-backed service
/// regardless of cluster mode flag (which is only checked in `::new`).
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_with_redis_creates_redis_backend() {
let (_container, conn, prefix) = start_redis().await;
let service = redis_ticket_service(conn, &prefix, Some(30));
assert!(service.supports_cluster_runtime());
}
/// Test: simulate multi-replica scenario - ticket created on "replica A"
/// can be validated on "replica B" when using Redis.
/// This simulates the core problem that the fix addresses.
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_cluster_mode_simulated_multi_replica_roundtrip() {
let (_container, conn, prefix) = start_redis().await;
// Clone the connection for the second "replica"
let conn_clone = conn.clone();
// "Replica A" creates the ticket
let service_a = redis_ticket_service(conn, &prefix, Some(30));
let uid = user_id(1009);
let rid = room_id(2010);
let ticket = ok(
service_a.create_ticket(&uid, &rid, 0).await,
"replica A should create Redis ticket",
);
// "Replica B" (different service instance, same Redis) validates the ticket
let service_b = redis_ticket_service(conn_clone, &prefix, Some(30));
let validated = ok(
service_b.validate_and_consume(&ticket, &rid).await,
"replica B should validate Redis ticket",
);
assert_eq!(
some(validated.user_id(), "ticket should contain user id"),
uid
);
// Ticket should be consumed (one-time use)
let result = service_a.validate_and_consume(&ticket, &rid).await;
assert!(result.is_err(), "Ticket should already be consumed");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_redis_ticket_uses_configured_key_prefix() {
let (_container, conn, _prefix) = start_redis().await;
let service = redis_ticket_service(conn.clone(), "tenant-a:", Some(30));
let uid = user_id(1010);
let rid = room_id(2011);
let ticket = ok(
service.create_ticket(&uid, &rid, 0).await,
"Redis ticket should be created",
);
let mut redis_conn = conn.read().await.clone();
let payload: Option<String> = ok(
redis_conn.get(format!("tenant-a:ws_ticket:{ticket}")).await,
"configured prefix ticket payload lookup should succeed",
);
assert!(
payload.is_some(),
"ticket must be stored under configured prefix"
);
let old_key_payload: Option<String> = ok(
redis_conn.get(format!("synctv:ws_ticket:{ticket}")).await,
"default prefix ticket payload lookup should succeed",
);
assert!(
old_key_payload.is_none(),
"ticket must not leak into hard-coded default prefix"
);
}