#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
use std::time::Duration;
use serde::{Deserialize, Serialize};
use ws_kit::{
codec::{Codec, JsonCodec},
config::WsConfig,
error::WsError,
extractor::{TokenExtractor, TokenSourceKind},
hub::BroadcastHub,
room::RoomManager,
};
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
struct TestMsg {
id: u32,
text: String,
}
#[test]
fn config_defaults() {
let c = WsConfig::default();
assert_eq!(c.heartbeat_interval, Duration::from_secs(30));
assert_eq!(c.broadcast_capacity, 1024);
assert_eq!(c.max_connections, 1000);
}
#[test]
fn config_builder() {
let c = WsConfig::builder()
.heartbeat_interval(Duration::from_secs(5))
.broadcast_capacity(2048)
.max_connections(200)
.build();
assert_eq!(c.heartbeat_interval, Duration::from_secs(5));
assert_eq!(c.broadcast_capacity, 2048);
assert_eq!(c.max_connections, 200);
}
#[test]
fn ws_error_display() {
assert_eq!(
WsError::AuthMissing.to_string(),
"authentication token missing"
);
assert_eq!(WsError::Closed.to_string(), "connection closed");
}
#[tokio::test]
async fn hub_broadcast_subscribe() {
let hub = BroadcastHub::<TestMsg>::new(16);
let mut rx = hub.subscribe();
let msg = TestMsg {
id: 1,
text: "hello".into(),
};
hub.broadcast(msg.clone()).unwrap();
let got = rx.recv().await.unwrap();
assert_eq!(got, msg);
}
#[tokio::test]
async fn hub_multiple_subscribers() {
let hub = BroadcastHub::<String>::new(16);
let mut rx1 = hub.subscribe();
let mut rx2 = hub.subscribe();
hub.broadcast("hello".to_string()).unwrap();
assert_eq!(rx1.recv().await.unwrap(), "hello");
assert_eq!(rx2.recv().await.unwrap(), "hello");
}
#[test]
fn hub_connection_count_limit() {
let hub = BroadcastHub::<String>::new(8);
hub.increment_connections(Some(2)).unwrap();
hub.increment_connections(Some(2)).unwrap();
assert_eq!(
hub.increment_connections(Some(2)).unwrap_err(),
WsError::TooManyConnections
);
}
#[tokio::test]
async fn hub_integration_with_config() {
let cfg = WsConfig::builder().broadcast_capacity(32).build();
let hub = BroadcastHub::<TestMsg>::from_config(&cfg);
let mut rx = hub.subscribe();
hub.broadcast(TestMsg {
id: 99,
text: "from_config".into(),
})
.unwrap();
assert_eq!(rx.recv().await.unwrap().id, 99);
}
#[test]
fn room_manager_basic() {
let m = RoomManager::new();
let r = m.get_or_create("general");
r.join(1, "Alice".to_string());
r.join(2, "Bob".to_string());
assert!(r.contains(1));
let mut names = r.participant_names();
names.sort();
assert_eq!(names, vec!["Alice".to_string(), "Bob".to_string()]);
assert_eq!(r.participant_count(), 2);
assert_eq!(m.total_participants(), 2);
assert!(r.leave(2));
assert_eq!(r.participant_count(), 1);
r.leave(1);
assert_eq!(m.cleanup_empty(), 1);
assert_eq!(m.room_count(), 0);
}
#[tokio::test]
async fn room_isolation() {
let m = RoomManager::new();
let r1 = m.get_or_create("r1");
let r2 = m.get_or_create("r2");
let mut rx1 = r1.subscribe();
let mut rx2 = r2.subscribe();
r1.broadcast("msg1".to_string()).unwrap();
assert_eq!(rx1.recv().await.unwrap(), "msg1");
let res = tokio::time::timeout(Duration::from_millis(20), rx2.recv()).await;
assert!(res.is_err());
}
#[test]
fn room_cleanup() {
let m = RoomManager::new();
let _r = m.get_or_create("temp");
assert_eq!(m.cleanup(), 1);
assert_eq!(m.room_count(), 0);
}
#[test]
fn extractor_bearer_and_query() {
let ex = TokenExtractor::default();
let tok = ex.extract_from_parts(Some("Bearer hdr"), None, "token=qry");
assert_eq!(tok, Some("hdr".to_string()));
let tok2 = ex.extract_from_parts(None, None, "token=qry");
assert_eq!(tok2, Some("qry".to_string()));
let tok3 = ex.extract_from_parts(None, None, "access_token=secret");
assert_eq!(tok3, Some("secret".to_string()));
}
#[test]
fn extractor_cookie() {
let ex = TokenExtractor::new(vec![TokenSourceKind::Cookie("sess".to_string())]);
let tok = ex.extract_from_parts(None, Some("sess=abc123; other=xyz"), "");
assert_eq!(tok, Some("abc123".to_string()));
}
#[test]
fn codec_json_roundtrip() {
let msg = TestMsg {
id: 7,
text: "codec".into(),
};
let s = msg.encode();
let decoded = TestMsg::decode(&s).unwrap();
assert_eq!(msg, decoded);
}
#[test]
fn json_codec_helper() {
let msg = TestMsg {
id: 1,
text: "helper".into(),
};
let s = JsonCodec::encode(&msg).unwrap();
let d: TestMsg = JsonCodec::decode(&s).unwrap();
assert_eq!(msg, d);
}
#[test]
fn codec_invalid() {
let err = TestMsg::decode("%%%").unwrap_err();
assert_eq!(err, WsError::InvalidMessage);
}
#[cfg(feature = "axum")]
#[test]
fn extractor_axum_parts() {
use http::{HeaderMap, Request};
let mut headers = HeaderMap::new();
headers.insert(
http::header::AUTHORIZATION,
"Bearer axum_token".parse().unwrap(),
);
let req = Request::builder()
.uri("/ws?token=ignored")
.body(())
.unwrap();
let (mut parts, _) = req.into_parts();
parts.headers = headers;
let ex = TokenExtractor::default();
assert_eq!(
ex.extract_token(&parts, "token=ignored"),
Some("axum_token".to_string())
);
}
#[cfg(feature = "axum")]
mod origin_upgrade {
use axum::extract::ws::WebSocketUpgrade;
use axum::extract::State;
use axum::http::request::Parts;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn ws_handler(
State(allowed): State<Vec<String>>,
parts: Parts,
ws: WebSocketUpgrade,
) -> Response {
if !ws_kit::origin_allowed_in_parts(&parts, &allowed) {
return StatusCode::FORBIDDEN.into_response();
}
ws.on_upgrade(|_socket| async {})
}
async fn spawn_ws_app(allowed_origins: Vec<String>) -> u16 {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new()
.route("/ws", get(ws_handler))
.with_state(allowed_origins);
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
port
}
async fn handshake_status(port: u16, origin: Option<&str>) -> u16 {
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.unwrap();
let origin_line = origin
.map(|o| format!("Origin: {o}\r\n"))
.unwrap_or_default();
let req = format!(
"GET /ws HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nUpgrade: websocket\r\n\
Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\
Sec-WebSocket-Version: 13\r\n{origin_line}\r\n"
);
stream.write_all(req.as_bytes()).await.unwrap();
let mut buf = Vec::new();
let mut chunk = [0u8; 512];
loop {
let n = stream.read(&mut chunk).await.unwrap();
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
String::from_utf8_lossy(&buf)
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0)
}
#[tokio::test]
async fn req_wskit_200_allowed_origin_passes_upgrade() {
let port = spawn_ws_app(vec!["https://example.com".to_string()]).await;
assert_eq!(
handshake_status(port, Some("https://example.com")).await,
101
);
}
#[tokio::test]
async fn req_wskit_200_disallowed_origin_rejected_403_pre_upgrade() {
let port = spawn_ws_app(vec!["https://example.com".to_string()]).await;
assert_eq!(handshake_status(port, Some("https://evil.com")).await, 403);
}
#[tokio::test]
async fn req_wskit_200_missing_origin_rejected_403() {
let port = spawn_ws_app(vec!["https://example.com".to_string()]).await;
assert_eq!(handshake_status(port, None).await, 403);
}
#[tokio::test]
async fn req_wskit_200_port_normalized_match_passes() {
let port = spawn_ws_app(vec!["https://example.com".to_string()]).await;
assert_eq!(
handshake_status(port, Some("https://example.com:443")).await,
101
);
assert_eq!(
handshake_status(port, Some("https://example.com:8443")).await,
403
);
}
#[tokio::test]
async fn req_wskit_200_no_wildcard_suffix_match() {
let port = spawn_ws_app(vec!["https://example.com".to_string()]).await;
assert_eq!(
handshake_status(port, Some("https://evil-example.com")).await,
403
);
assert_eq!(
handshake_status(port, Some("https://example.com.evil.com")).await,
403
);
assert_eq!(
handshake_status(port, Some("https://api.example.com")).await,
403
);
}
#[tokio::test]
async fn req_wskit_201_default_config_allows_all_origins() {
let port = spawn_ws_app(Vec::new()).await;
assert_eq!(
handshake_status(port, Some("https://any-site.dev")).await,
101
);
assert_eq!(handshake_status(port, None).await, 101);
}
}