mirror of
https://github.com/lexmount/moli.git
synced 2026-11-04 08:07:27 +00:00
Carry RequestHeaders through navigation, Fetch/XHR, workers, redirects and auth, with explicit Unicode and ByteString conversions at protocol and WebIDL boundaries. Share the raw curl header list with WebSocket transport and decode binary response headers without UTF-8 replacement. Keep header memory charging concrete. Cover opaque values, UTF-8 overrides, duplicates, redirect/auth continuation and WebSocket handshakes with byte-level regression tests.
1571 lines
54 KiB
Rust
1571 lines
54 KiB
Rust
use super::*;
|
|
use std::sync::Arc;
|
|
|
|
use crate::{
|
|
limits::{
|
|
MAX_PENDING_WEBSOCKET_HANDSHAKES, MAX_WEBSOCKET_CONNECTIONS_PER_RUNTIME,
|
|
acquire_limited_websocket_slot,
|
|
},
|
|
proxy::websocket_proxy_route_with_env,
|
|
request::prepare_websocket_request,
|
|
test_support::*,
|
|
};
|
|
use tokio::{
|
|
sync::{Semaphore, mpsc},
|
|
time::{Duration, timeout},
|
|
};
|
|
use url::Url;
|
|
|
|
mod tls;
|
|
|
|
#[test]
|
|
fn websocket_slot_limit_blocks_until_permit_is_dropped() {
|
|
let slots = Arc::new(Semaphore::new(1));
|
|
let first = acquire_limited_websocket_slot(&slots);
|
|
assert!(first.is_some());
|
|
assert!(acquire_limited_websocket_slot(&slots).is_none());
|
|
|
|
drop(first);
|
|
|
|
assert!(acquire_limited_websocket_slot(&slots).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_runtime_limits_follow_chromium_process_caps() {
|
|
assert_eq!(MAX_WEBSOCKET_CONNECTIONS_PER_RUNTIME, 255);
|
|
assert_eq!(MAX_PENDING_WEBSOCKET_HANDSHAKES, 255);
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_cookie_url_maps_socket_schemes_to_http_cookie_schemes() {
|
|
assert_eq!(
|
|
websocket_cookie_url(&Url::parse("ws://example.com/socket").unwrap()).as_str(),
|
|
"http://example.com/socket"
|
|
);
|
|
assert_eq!(
|
|
websocket_cookie_url(&Url::parse("wss://example.com/socket").unwrap()).as_str(),
|
|
"https://example.com/socket"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_url_normalization_matches_constructor_scheme_rules() {
|
|
let base = Url::parse("https://example.com/base/page.html").unwrap();
|
|
|
|
assert_eq!(
|
|
normalize_websocket_url(&base, "ws://[::1"),
|
|
Err(WebSocketUrlError::Invalid)
|
|
);
|
|
assert_eq!(
|
|
normalize_websocket_url(&base, "/socket").unwrap().as_str(),
|
|
"wss://example.com/socket"
|
|
);
|
|
assert_eq!(
|
|
normalize_websocket_url(&base, "http://example.test/socket")
|
|
.unwrap()
|
|
.as_str(),
|
|
"ws://example.test/socket"
|
|
);
|
|
assert_eq!(
|
|
normalize_websocket_url(&base, "https://example.test/socket")
|
|
.unwrap()
|
|
.as_str(),
|
|
"wss://example.test/socket"
|
|
);
|
|
assert_eq!(
|
|
normalize_websocket_url(&base, "ftp://example.test/socket"),
|
|
Err(WebSocketUrlError::DisallowedScheme("ftp".to_owned()))
|
|
);
|
|
assert_eq!(
|
|
normalize_websocket_url(&base, "ws://example.test/socket#frag"),
|
|
Err(WebSocketUrlError::Fragment)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_subprotocol_validation_rejects_invalid_and_case_duplicates() {
|
|
assert!(is_valid_subprotocol("chat"));
|
|
assert!(is_valid_subprotocol("super.chat-1_2"));
|
|
assert!(!is_valid_subprotocol(""));
|
|
assert!(!is_valid_subprotocol("bad protocol"));
|
|
assert!(!is_valid_subprotocol("bad,protocol"));
|
|
assert!(!is_valid_subprotocol("\u{80}echo"));
|
|
|
|
assert!(validate_subprotocols(&["chat".to_owned(), "superchat".to_owned()]).is_ok());
|
|
assert_eq!(
|
|
validate_subprotocols(&["chat".to_owned(), "CHAT".to_owned()]),
|
|
Err(WebSocketSubprotocolError::Duplicate("CHAT".to_owned()))
|
|
);
|
|
assert_eq!(
|
|
validate_subprotocols(&["bad/protocol".to_owned()]),
|
|
Err(WebSocketSubprotocolError::Invalid(
|
|
"bad/protocol".to_owned()
|
|
))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_close_info_validation_matches_web_api_rules() {
|
|
assert!(is_valid_close_code(1000));
|
|
assert!(!is_valid_close_code(1001));
|
|
assert!(!is_valid_close_code(2999));
|
|
assert!(is_valid_close_code(3000));
|
|
assert!(is_valid_close_code(4999));
|
|
assert!(!is_valid_close_code(5000));
|
|
|
|
assert!(is_valid_close_reason(&"x".repeat(123)));
|
|
assert!(!is_valid_close_reason(&"x".repeat(124)));
|
|
assert_eq!(default_close_code_for_reason(None, ""), None);
|
|
assert_eq!(default_close_code_for_reason(None, "reason"), Some(1000));
|
|
assert_eq!(
|
|
default_close_code_for_reason(Some(3333), "reason"),
|
|
Some(3333)
|
|
);
|
|
|
|
assert_eq!(
|
|
validate_websocket_close_request(Some(3001), "done".to_owned()).unwrap(),
|
|
WebSocketCloseRequest {
|
|
code: Some(3001),
|
|
reason: "done".to_owned(),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
validate_websocket_close_request(None, "done".to_owned()).unwrap(),
|
|
WebSocketCloseRequest {
|
|
code: None,
|
|
reason: "done".to_owned(),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
validate_websocket_close_request(Some(1001), String::new()),
|
|
Err(WebSocketCloseValidationError::InvalidCode)
|
|
);
|
|
assert_eq!(
|
|
validate_websocket_close_request(Some(3000), "x".repeat(124)),
|
|
Err(WebSocketCloseValidationError::ReasonTooLong)
|
|
);
|
|
|
|
assert_eq!(close_info_code_from_number(3000.4), Ok(3000));
|
|
assert_eq!(close_info_code_from_number(3000.5), Ok(3001));
|
|
assert_eq!(
|
|
close_info_code_from_number(f64::NAN),
|
|
Err(WebSocketCloseValidationError::InvalidCode)
|
|
);
|
|
assert_eq!(
|
|
normalize_websocket_close_info(None, "reason".to_owned()).unwrap(),
|
|
WebSocketCloseRequest {
|
|
code: Some(1000),
|
|
reason: "reason".to_owned(),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
normalize_websocket_close_info(Some(1001), String::new()),
|
|
Err(WebSocketCloseValidationError::InvalidCode)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_potentially_trustworthy_url_matches_loopback_policy() {
|
|
assert!(websocket_url_is_potentially_trustworthy(
|
|
&Url::parse("ws://localhost/socket").unwrap()
|
|
));
|
|
assert!(websocket_url_is_potentially_trustworthy(
|
|
&Url::parse("ws://api.localhost/socket").unwrap()
|
|
));
|
|
assert!(websocket_url_is_potentially_trustworthy(
|
|
&Url::parse("ws://127.0.0.1/socket").unwrap()
|
|
));
|
|
assert!(websocket_url_is_potentially_trustworthy(
|
|
&Url::parse("ws://[::1]/socket").unwrap()
|
|
));
|
|
assert!(!websocket_url_is_potentially_trustworthy(
|
|
&Url::parse("ws://example.test/socket").unwrap()
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_request_preparation_rejects_invalid_subprotocol_defensively() {
|
|
let context = test_websocket_context();
|
|
let error = prepare_websocket_request(
|
|
"ws://example.com/socket",
|
|
&["chat".to_owned(), "CHAT".to_owned()],
|
|
&context,
|
|
)
|
|
.expect_err("duplicate subprotocol should fail");
|
|
|
|
assert!(error.contains("subprotocol `CHAT` is duplicated"));
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_request_preparation_rejects_blocked_ports() {
|
|
let context = test_websocket_context();
|
|
let error = prepare_websocket_request("ws://127.0.0.1:25/socket", &[], &context)
|
|
.expect_err("blocked port should fail");
|
|
|
|
assert!(error.contains("port `25` is blocked"));
|
|
assert!(prepare_websocket_request("ws://127.0.0.1:43210/socket", &[], &context).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_proxy_url_uses_env_http_proxy_for_ws_when_unset() {
|
|
let context = test_websocket_context();
|
|
let uri = "ws://target.test/socket".parse().unwrap();
|
|
let proxy = test_websocket_proxy_url_with_env(
|
|
&uri,
|
|
&context,
|
|
&[("http_proxy", "http://127.0.0.1:8080")],
|
|
);
|
|
|
|
assert_eq!(proxy.as_deref(), Some("http://127.0.0.1:8080/"));
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_proxy_url_uses_env_https_proxy_for_wss_when_unset() {
|
|
let context = test_websocket_context();
|
|
let uri = "wss://target.test/socket".parse().unwrap();
|
|
let proxy = test_websocket_proxy_url_with_env(
|
|
&uri,
|
|
&context,
|
|
&[("HTTPS_PROXY", "http://127.0.0.1:8443")],
|
|
);
|
|
|
|
assert_eq!(proxy.as_deref(), Some("http://127.0.0.1:8443/"));
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_proxy_url_ignores_uppercase_http_proxy_for_ws() {
|
|
let context = test_websocket_context();
|
|
let uri = "ws://target.test/socket".parse().unwrap();
|
|
let proxy = test_websocket_proxy_url_with_env(
|
|
&uri,
|
|
&context,
|
|
&[("HTTP_PROXY", "http://127.0.0.1:8080")],
|
|
);
|
|
|
|
assert_eq!(proxy, None);
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_proxy_url_uses_all_proxy_fallback() {
|
|
let context = test_websocket_context();
|
|
let uri = "wss://target.test/socket".parse().unwrap();
|
|
let proxy = test_websocket_proxy_url_with_env(
|
|
&uri,
|
|
&context,
|
|
&[("ALL_PROXY", "http://127.0.0.1:9000")],
|
|
);
|
|
|
|
assert_eq!(proxy.as_deref(), Some("http://127.0.0.1:9000/"));
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_proxy_url_respects_env_no_proxy() {
|
|
let context = test_websocket_context();
|
|
let uri = "ws://api.example.com/socket".parse().unwrap();
|
|
let proxy = test_websocket_proxy_url_with_env(
|
|
&uri,
|
|
&context,
|
|
&[
|
|
("http_proxy", "http://127.0.0.1:8080"),
|
|
("NO_PROXY", ".example.com"),
|
|
],
|
|
);
|
|
|
|
assert_eq!(proxy, None);
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_proxy_url_explicit_empty_proxy_disables_env_fallback() {
|
|
let mut context = test_websocket_context();
|
|
context.http_proxy = Some(String::new());
|
|
let uri = "ws://target.test/socket".parse().unwrap();
|
|
let proxy = test_websocket_proxy_url_with_env(
|
|
&uri,
|
|
&context,
|
|
&[("http_proxy", "http://127.0.0.1:8080")],
|
|
);
|
|
|
|
assert_eq!(proxy, None);
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_proxy_route_normalizes_chromium_socks_schemes() {
|
|
let uri = "wss://target.test/socket".parse().unwrap();
|
|
for (scheme, curl_scheme) in [
|
|
("socks", "socks5h"),
|
|
("socks5", "socks5h"),
|
|
("socks4", "socks4a"),
|
|
] {
|
|
let mut context = test_websocket_context();
|
|
context.http_proxy = Some(format!("{scheme}://proxy.test:1080"));
|
|
let route = websocket_proxy_route_with_env(&uri, &context, |_| None).unwrap();
|
|
let proxy = route.proxy().expect("SOCKS route should use a proxy");
|
|
assert_eq!(url::Url::parse(proxy.url()).unwrap().scheme(), scheme);
|
|
assert_eq!(
|
|
url::Url::parse(proxy.curl_url()).unwrap().scheme(),
|
|
curl_scheme
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_https_proxy_domain_selects_shared_endpoint_dns() {
|
|
let uri = Url::parse("wss://target.test/socket").unwrap();
|
|
let mut context = test_websocket_context();
|
|
context.http_proxy = Some("https://proxy.test:8443".to_owned());
|
|
let route = websocket_proxy_route_with_env(&uri, &context, |_| None).unwrap();
|
|
let endpoint = route
|
|
.connection_dns_endpoint(&uri, &moli_curl::HostResolveOverrides::default())
|
|
.unwrap()
|
|
.expect("proxy domain must use shared endpoint DNS");
|
|
|
|
assert_eq!(endpoint.role(), moli_curl::ConnectionEndpointRole::Proxy);
|
|
assert_eq!(endpoint.target().host(), "proxy.test");
|
|
assert_eq!(endpoint.target().port(), 8443);
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_request_preparation_applies_context_protocols_and_cookie() {
|
|
let mut context = test_websocket_context();
|
|
context.extra_headers = vec![
|
|
("X-Moli-Trace".to_owned(), "socket".to_owned()),
|
|
("Sec-WebSocket-Version".to_owned(), "999".to_owned()),
|
|
]
|
|
.into();
|
|
context.cookie_header = Some("sid=server".to_owned());
|
|
|
|
let request = prepare_websocket_request(
|
|
"ws://example.com/socket",
|
|
&["chat".to_owned(), "superchat".to_owned()],
|
|
&context,
|
|
)
|
|
.expect("websocket request should build");
|
|
|
|
assert_eq!(request.url.as_str(), "ws://example.com/socket");
|
|
assert_eq!(
|
|
request
|
|
.headers
|
|
.get(http::header::ORIGIN)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("https://example.com")
|
|
);
|
|
assert_eq!(
|
|
request
|
|
.headers
|
|
.get(http::header::SEC_WEBSOCKET_PROTOCOL)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("chat, superchat")
|
|
);
|
|
assert_eq!(
|
|
request
|
|
.headers
|
|
.get(http::header::SEC_WEBSOCKET_VERSION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
request
|
|
.headers
|
|
.get(http::header::COOKIE)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("sid=server")
|
|
);
|
|
assert_eq!(
|
|
request
|
|
.headers
|
|
.get("x-moli-trace")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("socket")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_request_preparation_converts_url_userinfo_to_basic_auth() {
|
|
let context = test_websocket_context();
|
|
|
|
let request = prepare_websocket_request("ws://foo:bar@example.com/socket", &[], &context)
|
|
.expect("websocket request should build");
|
|
|
|
assert_eq!(
|
|
request
|
|
.headers
|
|
.get(http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Basic Zm9vOmJhcg==")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_request_preparation_decodes_percent_encoded_userinfo_for_basic_auth() {
|
|
let context = test_websocket_context();
|
|
|
|
let request =
|
|
prepare_websocket_request("ws://foo%20bar:p%40ss@example.com/socket", &[], &context)
|
|
.expect("websocket request should build");
|
|
|
|
assert_eq!(
|
|
request
|
|
.headers
|
|
.get(http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Basic Zm9vIGJhcjpwQHNz")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_failed_connection_reports_error_then_abnormal_close() {
|
|
let (event_tx, mut event_rx) = mpsc::channel(32);
|
|
let command_tx = spawn_failed_connection(7, "preflight failed".to_owned(), event_tx);
|
|
|
|
assert!(
|
|
command_tx
|
|
.send(Command::SendText("ignored".to_owned()))
|
|
.is_err()
|
|
);
|
|
|
|
match timeout(Duration::from_secs(3), event_rx.recv())
|
|
.await
|
|
.expect("websocket error should arrive")
|
|
.expect("websocket event channel should stay open")
|
|
{
|
|
Event::Error { socket_id, message } => {
|
|
assert_eq!(socket_id, 7);
|
|
assert_eq!(message, "preflight failed");
|
|
}
|
|
event => panic!("expected websocket error, got {event:?}"),
|
|
}
|
|
|
|
match timeout(Duration::from_secs(3), event_rx.recv())
|
|
.await
|
|
.expect("websocket close should arrive")
|
|
.expect("websocket event channel should stay open")
|
|
{
|
|
Event::Close {
|
|
socket_id,
|
|
code,
|
|
reason,
|
|
was_clean,
|
|
} => {
|
|
assert_eq!(socket_id, 7);
|
|
assert_eq!(code, 1006);
|
|
assert!(reason.is_empty());
|
|
assert!(!was_clean);
|
|
}
|
|
event => panic!("expected websocket close, got {event:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_last_handle_drop_releases_pending_handshake() {
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let url = format!("ws://{}/pending", listener.local_addr().unwrap());
|
|
let (event_tx, _event_rx) = mpsc::channel(1);
|
|
let handle =
|
|
spawn_standalone_connection(61, url, Vec::new(), test_websocket_context(), event_tx);
|
|
let retained = handle.clone();
|
|
let (mut server, _) = timeout(Duration::from_secs(3), listener.accept())
|
|
.await
|
|
.expect("connection should reach the server")
|
|
.unwrap();
|
|
drop(handle);
|
|
retained
|
|
.send(Command::SendText("ignored while connecting".to_owned()))
|
|
.expect("another producer still owns the connection");
|
|
drop(retained);
|
|
let result = timeout(Duration::from_secs(3), server.read_to_end(&mut Vec::new()))
|
|
.await
|
|
.expect("dropping the last producer must close a pending handshake");
|
|
if let Err(error) = result {
|
|
assert_eq!(error.kind(), std::io::ErrorKind::ConnectionReset);
|
|
}
|
|
}
|
|
|
|
struct WebSocketSinkDropSignal(mpsc::Sender<()>);
|
|
|
|
impl Drop for WebSocketSinkDropSignal {
|
|
fn drop(&mut self) {
|
|
let _ = self.0.try_send(());
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_rejected_handshake_or_open_event_releases_transport() {
|
|
use futures_util::StreamExt;
|
|
|
|
for pause_after_handshake in [false, true] {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let url = format!("ws://{}/closed-sink", listener.local_addr().unwrap());
|
|
let (command_tx, command_rx) = crate::commands::command_channel();
|
|
let context = test_websocket_context();
|
|
let (_controller, decision) = tokio::sync::oneshot::channel();
|
|
let server = async move {
|
|
let (stream, _) = listener.accept().await.unwrap();
|
|
let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap();
|
|
let terminal = socket.next().await;
|
|
assert!(terminal.is_none() || terminal.is_some_and(|result| result.is_err()));
|
|
};
|
|
let client = crate::connection::run_websocket_connection(
|
|
crate::runtime::standalone_connector().unwrap(),
|
|
64,
|
|
url,
|
|
Vec::new(),
|
|
context,
|
|
command_rx,
|
|
EventSender::closed(),
|
|
pause_after_handshake.then_some(decision),
|
|
);
|
|
let (result, ()) = timeout(Duration::from_secs(3), async {
|
|
tokio::join!(client, server)
|
|
})
|
|
.await
|
|
.expect("a closed sink must release the connection before waiting for commands");
|
|
result.expect_err("delivery should report the unavailable sink");
|
|
assert!(command_tx.is_closed());
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_rejected_message_event_releases_transport_and_writer() {
|
|
use futures_util::{SinkExt, StreamExt};
|
|
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let url = format!(
|
|
"ws://{}/closed-message-sink",
|
|
listener.local_addr().unwrap()
|
|
);
|
|
let (command_tx, command_rx) = crate::commands::command_channel();
|
|
let sink =
|
|
EventSender::with_async_sink(
|
|
|event| async move { !matches!(event, Event::TextMessage { .. }) },
|
|
);
|
|
let server = async move {
|
|
let (stream, _) = listener.accept().await.unwrap();
|
|
let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap();
|
|
socket.send("reject delivery".into()).await.unwrap();
|
|
let terminal = socket.next().await;
|
|
assert!(terminal.is_none() || terminal.is_some_and(|result| result.is_err()));
|
|
};
|
|
let client = crate::connection::run_websocket_connection(
|
|
crate::runtime::standalone_connector().unwrap(),
|
|
65,
|
|
url,
|
|
Vec::new(),
|
|
test_websocket_context(),
|
|
command_rx,
|
|
sink,
|
|
None,
|
|
);
|
|
let (result, ()) = timeout(Duration::from_secs(3), async {
|
|
tokio::join!(client, server)
|
|
})
|
|
.await
|
|
.expect("message delivery rejection must stop both transport tasks");
|
|
result.expect_err("message sink is closed");
|
|
assert!(command_tx.is_closed());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_synthetic_delivery_stops_after_first_rejected_event() {
|
|
let (command_tx, command_rx) = crate::commands::command_channel();
|
|
command_tx
|
|
.send(Command::SendText("hello".to_owned()))
|
|
.unwrap();
|
|
let (observed_tx, mut observed_rx) = mpsc::unbounded_channel();
|
|
let sink = EventSender::with_async_sink(move |event| {
|
|
let observed_tx = observed_tx.clone();
|
|
async move {
|
|
let accept = matches!(event, Event::Open { .. });
|
|
observed_tx.send(event).unwrap();
|
|
accept
|
|
}
|
|
});
|
|
timeout(
|
|
Duration::from_secs(3),
|
|
crate::synthetic::run_synthetic_websocket_connection(
|
|
66,
|
|
command_rx,
|
|
sink,
|
|
Vec::new().into(),
|
|
101,
|
|
Vec::new().into(),
|
|
),
|
|
)
|
|
.await
|
|
.expect("synthetic producer must stop when SendCompleted is rejected")
|
|
.expect_err("synthetic event sink is closed");
|
|
assert!(command_tx.is_closed());
|
|
assert!(matches!(observed_rx.try_recv(), Ok(Event::Open { .. })));
|
|
assert!(matches!(
|
|
observed_rx.try_recv(),
|
|
Ok(Event::SendCompleted { .. })
|
|
));
|
|
assert!(
|
|
observed_rx.try_recv().is_err(),
|
|
"no further delivery attempts after rejection"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_cancel_releases_blocked_sink_and_open_writer() {
|
|
use futures_util::{SinkExt, StreamExt};
|
|
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let url = format!("ws://{}/cancel", listener.local_addr().unwrap());
|
|
let entered = Arc::new(tokio::sync::Notify::new());
|
|
let sink_entered = Arc::clone(&entered);
|
|
let (dropped_tx, mut dropped_rx) = mpsc::channel(1);
|
|
let sink = EventSender::with_async_sink(move |event| {
|
|
let entered = Arc::clone(&sink_entered);
|
|
let dropped_tx = dropped_tx.clone();
|
|
async move {
|
|
if matches!(event, Event::TextMessage { .. }) {
|
|
let _guard = WebSocketSinkDropSignal(dropped_tx);
|
|
entered.notify_one();
|
|
return std::future::pending::<bool>().await;
|
|
}
|
|
true
|
|
}
|
|
});
|
|
let handle = spawn_standalone_connection(62, url, Vec::new(), test_websocket_context(), sink);
|
|
let retained = handle.clone();
|
|
let (stream, _) = timeout(Duration::from_secs(3), listener.accept())
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let mut server = tokio_tungstenite::accept_async(stream).await.unwrap();
|
|
server.send("block the sink".into()).await.unwrap();
|
|
timeout(Duration::from_secs(3), entered.notified())
|
|
.await
|
|
.expect("receive task must enter the blocked sink");
|
|
handle.cancel();
|
|
handle.cancel();
|
|
assert!(retained.is_closed());
|
|
assert!(retained.send_binary(vec![1]).is_err());
|
|
timeout(Duration::from_secs(3), dropped_rx.recv())
|
|
.await
|
|
.expect("cancellation must release the blocked event future")
|
|
.expect("sink drop signal");
|
|
let terminal = timeout(Duration::from_secs(3), server.next())
|
|
.await
|
|
.expect("the writer must release its socket while producer clones remain alive");
|
|
assert!(terminal.is_none() || terminal.is_some_and(|result| result.is_err()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_failed_and_synthetic_delivery_cancel_on_last_handle_drop() {
|
|
for synthetic in [false, true] {
|
|
let entered = Arc::new(tokio::sync::Notify::new());
|
|
let sink_entered = Arc::clone(&entered);
|
|
let (dropped_tx, mut dropped_rx) = mpsc::channel(1);
|
|
let sink = EventSender::with_async_sink(move |_event| {
|
|
let entered = Arc::clone(&sink_entered);
|
|
let dropped_tx = dropped_tx.clone();
|
|
async move {
|
|
let _guard = WebSocketSinkDropSignal(dropped_tx);
|
|
entered.notify_one();
|
|
std::future::pending::<bool>().await
|
|
}
|
|
});
|
|
let handle = if synthetic {
|
|
spawn_synthetic_connection(63, Vec::new().into(), 101, Vec::new().into(), sink).0
|
|
} else {
|
|
spawn_failed_connection(63, "rejected".to_owned(), sink)
|
|
};
|
|
timeout(Duration::from_secs(3), entered.notified())
|
|
.await
|
|
.expect("connection should reach the async sink");
|
|
drop(handle);
|
|
timeout(Duration::from_secs(3), dropped_rx.recv())
|
|
.await
|
|
.expect("last producer drop must cancel blocked delivery")
|
|
.expect("sink drop signal");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_synthetic_connection_opens_accounts_send_and_closes_cleanly() {
|
|
let (event_tx, mut event_rx) = mpsc::channel(32);
|
|
let (command_tx, _peer) = spawn_synthetic_connection(
|
|
92,
|
|
vec![("Origin".to_owned(), "http://example.test".to_owned())].into(),
|
|
101,
|
|
vec![("Sec-WebSocket-Protocol".to_owned(), "chat".to_owned())].into(),
|
|
event_tx,
|
|
);
|
|
|
|
let event = timeout(Duration::from_secs(1), event_rx.recv())
|
|
.await
|
|
.expect("synthetic websocket open should arrive")
|
|
.expect("synthetic websocket event channel should stay open");
|
|
match event {
|
|
Event::Open {
|
|
socket_id,
|
|
protocol,
|
|
response_status,
|
|
..
|
|
} => {
|
|
assert_eq!(socket_id, 92);
|
|
assert_eq!(protocol, "chat");
|
|
assert_eq!(response_status, 101);
|
|
}
|
|
event => panic!("expected synthetic websocket open, got {event:?}"),
|
|
}
|
|
|
|
command_tx
|
|
.send_text("hello".to_owned())
|
|
.expect("send synthetic websocket text");
|
|
let event = timeout(Duration::from_secs(1), event_rx.recv())
|
|
.await
|
|
.expect("synthetic frame sent should arrive")
|
|
.expect("synthetic websocket event channel should stay open");
|
|
match event {
|
|
Event::SendCompleted {
|
|
socket_id,
|
|
opcode,
|
|
payload_length,
|
|
} => {
|
|
assert_eq!(socket_id, 92);
|
|
assert_eq!(opcode, FrameOpcode::Text);
|
|
assert_eq!(payload_length, 5);
|
|
}
|
|
event => panic!("expected synthetic websocket frame sent, got {event:?}"),
|
|
}
|
|
|
|
command_tx
|
|
.close(Some(1000), "done".to_owned())
|
|
.expect("close synthetic websocket");
|
|
let event = timeout(Duration::from_secs(1), event_rx.recv())
|
|
.await
|
|
.expect("synthetic closing should arrive")
|
|
.expect("synthetic websocket event channel should stay open");
|
|
assert!(matches!(event, Event::Closing { socket_id: 92 }));
|
|
let event = timeout(Duration::from_secs(1), event_rx.recv())
|
|
.await
|
|
.expect("synthetic close should arrive")
|
|
.expect("synthetic websocket event channel should stay open");
|
|
match event {
|
|
Event::Close {
|
|
socket_id,
|
|
code,
|
|
reason,
|
|
was_clean,
|
|
} => {
|
|
assert_eq!(socket_id, 92);
|
|
assert_eq!(code, 1000);
|
|
assert_eq!(reason, "done");
|
|
assert!(was_clean);
|
|
}
|
|
event => panic!("expected synthetic close, got {event:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_synthetic_connection_can_receive_frames_and_server_close() {
|
|
let (event_tx, mut event_rx) = mpsc::channel(32);
|
|
let (_connection, peer) = spawn_synthetic_connection(
|
|
93,
|
|
vec![("Origin".to_owned(), "http://example.test".to_owned())].into(),
|
|
101,
|
|
Vec::new().into(),
|
|
event_tx,
|
|
);
|
|
|
|
assert!(matches!(
|
|
timeout(Duration::from_secs(1), event_rx.recv())
|
|
.await
|
|
.expect("synthetic websocket open should arrive")
|
|
.expect("synthetic websocket event channel should stay open"),
|
|
Event::Open { socket_id: 93, .. }
|
|
));
|
|
|
|
peer.send_text("server-text".to_owned())
|
|
.expect("inject synthetic websocket text");
|
|
match timeout(Duration::from_secs(1), event_rx.recv())
|
|
.await
|
|
.expect("synthetic text should arrive")
|
|
.expect("synthetic websocket event channel should stay open")
|
|
{
|
|
Event::TextMessage { socket_id, data } => {
|
|
assert_eq!(socket_id, 93);
|
|
assert_eq!(data, "server-text");
|
|
}
|
|
event => panic!("expected synthetic text message, got {event:?}"),
|
|
}
|
|
|
|
peer.send_binary(vec![1, 2, 3, 4])
|
|
.expect("inject synthetic websocket binary");
|
|
match timeout(Duration::from_secs(1), event_rx.recv())
|
|
.await
|
|
.expect("synthetic binary should arrive")
|
|
.expect("synthetic websocket event channel should stay open")
|
|
{
|
|
Event::BinaryMessage { socket_id, data } => {
|
|
assert_eq!(socket_id, 93);
|
|
assert_eq!(data, vec![1, 2, 3, 4]);
|
|
}
|
|
event => panic!("expected synthetic binary message, got {event:?}"),
|
|
}
|
|
|
|
peer.close(Some(1000), "server-done".to_owned())
|
|
.expect("inject synthetic websocket server close");
|
|
match timeout(Duration::from_secs(1), event_rx.recv())
|
|
.await
|
|
.expect("synthetic server close should arrive")
|
|
.expect("synthetic websocket event channel should stay open")
|
|
{
|
|
Event::Close {
|
|
socket_id,
|
|
code,
|
|
reason,
|
|
was_clean,
|
|
} => {
|
|
assert_eq!(socket_id, 93);
|
|
assert_eq!(code, 1000);
|
|
assert_eq!(reason, "server-done");
|
|
assert!(was_clean);
|
|
}
|
|
event => panic!("expected synthetic server close, got {event:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_transport_close_while_connecting_fails_before_open() {
|
|
let (url, server) = spawn_sleeping_handshake_websocket_server().await;
|
|
let (event_tx, mut event_rx) = mpsc::channel(32);
|
|
let command_tx =
|
|
spawn_standalone_connection(91, url, Vec::new(), test_websocket_context(), event_tx);
|
|
|
|
command_tx
|
|
.close(None, String::new())
|
|
.expect("send connecting close command");
|
|
|
|
let error = recv_handshake_failure_events(&mut event_rx).await;
|
|
assert_eq!(error, "WebSocket connection closed before opening");
|
|
server.abort();
|
|
let _ = server.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_transport_can_pause_after_handshake_before_open() {
|
|
let (url, server) = spawn_text_echo_websocket_server().await;
|
|
let (event_tx, mut event_rx) = mpsc::channel(32);
|
|
let (command_tx, controller) = spawn_standalone_connection_with_handshake_pause(
|
|
92,
|
|
url,
|
|
Vec::new(),
|
|
test_websocket_context(),
|
|
event_tx,
|
|
);
|
|
|
|
match timeout(Duration::from_secs(3), event_rx.recv())
|
|
.await
|
|
.expect("websocket handshake response should arrive")
|
|
.expect("websocket event channel should stay open")
|
|
{
|
|
Event::HandshakeResponse {
|
|
socket_id,
|
|
response_status,
|
|
..
|
|
} => {
|
|
assert_eq!(socket_id, 92);
|
|
assert_eq!(response_status, 101);
|
|
}
|
|
event => panic!("expected websocket handshake response, got {event:?}"),
|
|
}
|
|
assert!(
|
|
timeout(Duration::from_millis(50), event_rx.recv())
|
|
.await
|
|
.is_err(),
|
|
"websocket open must wait for the handshake decision"
|
|
);
|
|
|
|
controller
|
|
.continue_open(None, None)
|
|
.expect("continue paused websocket open");
|
|
let open = recv_open_event(&mut event_rx).await;
|
|
assert_eq!(open.socket_id, 92);
|
|
let _ = command_tx.close(Some(1000), "done".to_owned());
|
|
server
|
|
.await
|
|
.expect("websocket pause-after-handshake server should finish");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_transport_handshake_applies_context_headers_and_preserves_control_headers() {
|
|
let (url, headers_rx, server) = spawn_header_capture_websocket_server().await;
|
|
let (event_tx, mut event_rx) = mpsc::channel(32);
|
|
let mut context = test_websocket_context();
|
|
context.extra_headers = vec![
|
|
("X-Moli-Trace".to_owned(), "socket".to_owned()),
|
|
// Protocol control headers are generated by libcurl and should not
|
|
// be overridden by embedding-layer extra headers.
|
|
("Sec-WebSocket-Version".to_owned(), "999".to_owned()),
|
|
]
|
|
.into();
|
|
context.cookie_header = Some("sid=server".to_owned());
|
|
|
|
let command_tx =
|
|
spawn_standalone_connection(1, url, vec!["chat".to_owned()], context, event_tx);
|
|
let headers = timeout(Duration::from_secs(3), headers_rx)
|
|
.await
|
|
.expect("websocket headers should arrive")
|
|
.expect("websocket header sender should stay alive");
|
|
let open = recv_open_event(&mut event_rx).await;
|
|
let _ = command_tx.close(Some(1000), "done".to_owned());
|
|
server.await.expect("websocket header server should finish");
|
|
|
|
assert_eq!(open.socket_id, 1);
|
|
assert_eq!(open.protocol, "chat");
|
|
assert_eq!(
|
|
header_value(&headers, "origin").as_deref(),
|
|
Some("https://example.com")
|
|
);
|
|
assert_eq!(
|
|
header_value(&headers, "user-agent").as_deref(),
|
|
Some("Moli-WebSocket-Test/1.0")
|
|
);
|
|
assert_eq!(
|
|
header_value(&headers, "x-moli-trace").as_deref(),
|
|
Some("socket")
|
|
);
|
|
assert_eq!(
|
|
header_value(&headers, "cookie").as_deref(),
|
|
Some("sid=server")
|
|
);
|
|
assert_eq!(
|
|
header_value(&headers, "sec-websocket-protocol").as_deref(),
|
|
Some("chat")
|
|
);
|
|
assert_eq!(
|
|
header_value(&headers, "sec-websocket-version").as_deref(),
|
|
Some("13")
|
|
);
|
|
assert_eq!(header_value(&headers, "referer"), None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_transport_uses_explicit_http_proxy_connect_without_forwarding_proxy_auth() {
|
|
let (url, headers_rx, server) = spawn_header_capture_websocket_server().await;
|
|
let (proxy_url, proxy_request_rx, proxy) = spawn_http_connect_proxy().await;
|
|
let (event_tx, mut event_rx) = mpsc::channel(32);
|
|
let mut context = test_websocket_context();
|
|
let mut named_proxy = Url::parse(&proxy_url).expect("proxy URL");
|
|
named_proxy
|
|
.set_host(Some("localhost"))
|
|
.expect("proxy hostname should be replaceable");
|
|
context.http_proxy = Some(named_proxy.to_string());
|
|
context.http_no_proxy = Some(String::new());
|
|
context.proxy_bearer_token = Some("proxy-token".to_owned());
|
|
let user_agent = context.user_agent.clone();
|
|
|
|
let command_tx = spawn_standalone_connection(2, url.clone(), Vec::new(), context, event_tx);
|
|
let proxy_request = timeout(Duration::from_secs(3), proxy_request_rx)
|
|
.await
|
|
.expect("proxy CONNECT should arrive")
|
|
.expect("proxy request sender should stay alive");
|
|
let headers = timeout(Duration::from_secs(3), headers_rx)
|
|
.await
|
|
.expect("websocket headers should arrive")
|
|
.expect("websocket header sender should stay alive");
|
|
let open = recv_open_event(&mut event_rx).await;
|
|
let _ = command_tx.close(Some(1000), "done".to_owned());
|
|
server.await.expect("websocket header server should finish");
|
|
proxy.await.expect("websocket proxy should finish");
|
|
|
|
let target = Url::parse(&url).expect("websocket target url");
|
|
let expected_connect = format!(
|
|
"CONNECT {}:{} HTTP/1.1",
|
|
target.host_str().expect("target host"),
|
|
target.port_or_known_default().expect("target port")
|
|
);
|
|
assert_eq!(open.socket_id, 2);
|
|
assert!(
|
|
proxy_request.starts_with(&expected_connect),
|
|
"unexpected CONNECT request: {proxy_request:?}"
|
|
);
|
|
assert!(
|
|
proxy_request.contains("\r\nProxy-Authorization: Bearer proxy-token\r\n"),
|
|
"proxy bearer token should be sent only on CONNECT: {proxy_request:?}"
|
|
);
|
|
assert!(proxy_request.contains(&format!("\r\nUser-Agent: {user_agent}\r\n")));
|
|
assert_eq!(
|
|
header_value(&headers, "origin").as_deref(),
|
|
Some("https://example.com")
|
|
);
|
|
assert_eq!(header_value(&headers, "proxy-authorization"), None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_transport_socks5_sends_target_hostname_to_proxy() {
|
|
let (server_url, headers_rx, server) = spawn_header_capture_websocket_server().await;
|
|
let mut target = Url::parse(&server_url).expect("WebSocket target URL");
|
|
let target_port = target
|
|
.port_or_known_default()
|
|
.expect("WebSocket target port");
|
|
let upstream_addr = format!("127.0.0.1:{target_port}")
|
|
.parse()
|
|
.expect("WebSocket upstream address");
|
|
target
|
|
.set_host(Some("websocket-target.invalid"))
|
|
.expect("WebSocket target hostname should be replaceable");
|
|
let (proxy_url, proxy_request_rx, proxy) = spawn_socks5_proxy(upstream_addr).await;
|
|
let (event_tx, mut event_rx) = mpsc::channel(32);
|
|
let mut context = test_websocket_context();
|
|
context.http_proxy = Some(proxy_url);
|
|
context.http_no_proxy = Some(String::new());
|
|
|
|
let command_tx =
|
|
spawn_standalone_connection(5, target.to_string(), Vec::new(), context, event_tx);
|
|
let (requested_host, requested_port) = timeout(Duration::from_secs(3), proxy_request_rx)
|
|
.await
|
|
.expect("SOCKS request should arrive")
|
|
.expect("SOCKS request sender should stay alive");
|
|
let _headers = timeout(Duration::from_secs(3), headers_rx)
|
|
.await
|
|
.expect("WebSocket headers should arrive")
|
|
.expect("WebSocket header sender should stay alive");
|
|
let open = recv_open_event(&mut event_rx).await;
|
|
let _ = command_tx.close(Some(1000), "done".to_owned());
|
|
server.await.expect("WebSocket header server should finish");
|
|
proxy.await.expect("WebSocket SOCKS proxy should finish");
|
|
|
|
assert_eq!(open.socket_id, 5);
|
|
assert_eq!(requested_host, "websocket-target.invalid");
|
|
assert_eq!(requested_port, target_port);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_transport_rejects_non_200_http_proxy_connect() {
|
|
let (proxy_url, proxy_request_rx, proxy) =
|
|
spawn_http_connect_proxy_response(b"HTTP/1.1 204 No Content\r\n\r\n").await;
|
|
let (event_tx, mut event_rx) = mpsc::channel(32);
|
|
let mut context = test_websocket_context();
|
|
let mut fixed_proxy = Url::parse(&proxy_url).expect("proxy URL");
|
|
let proxy_port = fixed_proxy.port().expect("proxy URL should include a port");
|
|
fixed_proxy
|
|
.set_host(Some("proxy.invalid"))
|
|
.expect("proxy hostname should be replaceable");
|
|
context.http_proxy = Some(fixed_proxy.to_string());
|
|
context.http_no_proxy = Some(String::new());
|
|
context.http_host_resolve = vec![format!("proxy.invalid:{proxy_port}:127.0.0.1")];
|
|
|
|
let _command_tx = spawn_standalone_connection(
|
|
3,
|
|
"ws://example.test/socket".to_owned(),
|
|
Vec::new(),
|
|
context,
|
|
event_tx,
|
|
);
|
|
let proxy_request = timeout(Duration::from_secs(3), proxy_request_rx)
|
|
.await
|
|
.expect("proxy CONNECT should arrive")
|
|
.expect("proxy request sender should stay alive");
|
|
let message = recv_handshake_failure_events(&mut event_rx).await;
|
|
proxy.await.expect("websocket proxy should finish");
|
|
|
|
assert!(
|
|
proxy_request.starts_with("CONNECT example.test:80 HTTP/1.1"),
|
|
"unexpected CONNECT request: {proxy_request:?}"
|
|
);
|
|
assert!(
|
|
message.contains("HTTP/1.1 204 No Content"),
|
|
"unexpected proxy CONNECT error: {message}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_transport_rejects_host_resolve_for_remote_proxy_target() {
|
|
let (event_tx, mut event_rx) = mpsc::channel(32);
|
|
let mut context = test_websocket_context();
|
|
context.http_proxy = Some("http://127.0.0.1:1".to_owned());
|
|
context.http_no_proxy = Some(String::new());
|
|
context.http_host_resolve = vec!["target.invalid:80:127.0.0.1".to_owned()];
|
|
|
|
let _command_tx = spawn_standalone_connection(
|
|
4,
|
|
"ws://target.invalid/socket".to_owned(),
|
|
Vec::new(),
|
|
context,
|
|
event_tx,
|
|
);
|
|
let message = recv_handshake_failure_events(&mut event_rx).await;
|
|
|
|
assert!(message.contains("would be ignored"), "{message}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_transport_respects_disabled_tls_verify_for_self_signed_wss() {
|
|
let (url, headers_rx, server) = spawn_tls_header_capture_websocket_server().await;
|
|
let (event_tx, mut event_rx) = mpsc::channel(32);
|
|
let mut context = test_websocket_context();
|
|
context.tls.verify = false;
|
|
|
|
let command_tx = spawn_standalone_connection(3, url, Vec::new(), context, event_tx);
|
|
let headers = timeout(Duration::from_secs(3), headers_rx)
|
|
.await
|
|
.expect("websocket TLS headers should arrive")
|
|
.expect("websocket TLS header sender should stay alive");
|
|
let open = recv_open_event(&mut event_rx).await;
|
|
let _ = command_tx.close(Some(1000), "done".to_owned());
|
|
server
|
|
.await
|
|
.expect("websocket TLS header server should finish");
|
|
|
|
assert_eq!(open.socket_id, 3);
|
|
assert_eq!(
|
|
header_value(&headers, "origin").as_deref(),
|
|
Some("https://example.com")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_transport_wss_allows_server_to_omit_response_subprotocol() {
|
|
let (url, headers_rx, server) = spawn_tls_header_capture_websocket_server().await;
|
|
let (event_tx, mut event_rx) = mpsc::channel(32);
|
|
let mut context = test_websocket_context();
|
|
context.tls.verify = false;
|
|
|
|
let _command_tx = spawn_standalone_connection(
|
|
4,
|
|
url,
|
|
vec!["chat".to_owned(), "superchat".to_owned()],
|
|
context,
|
|
event_tx,
|
|
);
|
|
let headers = timeout(Duration::from_secs(3), headers_rx)
|
|
.await
|
|
.expect("websocket TLS headers should arrive")
|
|
.expect("websocket TLS header sender should stay alive");
|
|
let open = recv_open_event(&mut event_rx).await;
|
|
assert_close(&mut event_rx, 4, 1005, "", true).await;
|
|
server
|
|
.await
|
|
.expect("websocket TLS no-protocol server should finish");
|
|
|
|
assert_eq!(open.socket_id, 4);
|
|
assert_eq!(open.protocol, "");
|
|
assert_eq!(
|
|
header_value(&headers, "sec-websocket-protocol").as_deref(),
|
|
Some("chat, superchat")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_transport_rejects_non_switching_and_redirect_statuses() {
|
|
for (path, response) in [
|
|
("plain-200", b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n".as_slice()),
|
|
(
|
|
"redirect-301",
|
|
b"HTTP/1.1 301 Moved Permanently\r\nLocation: /echo\r\nContent-Length: 0\r\n\r\n"
|
|
.as_slice(),
|
|
),
|
|
(
|
|
"not-found-404",
|
|
b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".as_slice(),
|
|
),
|
|
(
|
|
"unauthorized-401",
|
|
b"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"websocket\"\r\nContent-Length: 0\r\n\r\n"
|
|
.as_slice(),
|
|
),
|
|
] {
|
|
let message = websocket_raw_handshake_failure_message(path, response).await;
|
|
assert!(
|
|
message.contains("WebSocket connection failed"),
|
|
"unexpected error for {path}: {message}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_transport_rejects_wrong_accept_key() {
|
|
let message = websocket_raw_handshake_failure_message(
|
|
"wrong-accept",
|
|
b"HTTP/1.1 101 Switching Protocols\r\n\
|
|
Upgrade: websocket\r\n\
|
|
Connection: Upgrade\r\n\
|
|
Sec-WebSocket-Accept: wrongAcceptKey\r\n\r\n",
|
|
)
|
|
.await;
|
|
|
|
assert!(
|
|
message.contains("WebSocket connection failed"),
|
|
"unexpected wrong-accept error: {message}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_transport_rejects_missing_or_wrong_upgrade_headers() {
|
|
for (path, headers) in [
|
|
("missing-upgrade", vec!["Connection: Upgrade"]),
|
|
("missing-connection", vec!["Upgrade: websocket"]),
|
|
("wrong-upgrade", vec!["Upgrade: h2c", "Connection: Upgrade"]),
|
|
(
|
|
"wrong-connection",
|
|
vec!["Upgrade: websocket", "Connection: keep-alive"],
|
|
),
|
|
] {
|
|
let message =
|
|
websocket_computed_accept_handshake_failure_message(path, headers, Vec::new()).await;
|
|
assert!(
|
|
message.contains("WebSocket connection failed"),
|
|
"unexpected error for {path}: {message}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_transport_rejects_invalid_response_subprotocols() {
|
|
for (path, headers, protocols) in [
|
|
(
|
|
"unrequested-protocol",
|
|
vec![
|
|
"Upgrade: websocket",
|
|
"Connection: Upgrade",
|
|
"Sec-WebSocket-Protocol: other",
|
|
],
|
|
vec!["chat".to_owned()],
|
|
),
|
|
(
|
|
"empty-response-protocol",
|
|
vec![
|
|
"Upgrade: websocket",
|
|
"Connection: Upgrade",
|
|
"Sec-WebSocket-Protocol: ",
|
|
],
|
|
vec!["chat".to_owned()],
|
|
),
|
|
(
|
|
"multiple-response-protocols",
|
|
vec![
|
|
"Upgrade: websocket",
|
|
"Connection: Upgrade",
|
|
"Sec-WebSocket-Protocol: chat, superchat",
|
|
],
|
|
vec!["chat".to_owned(), "superchat".to_owned()],
|
|
),
|
|
] {
|
|
let message =
|
|
websocket_computed_accept_handshake_failure_message(path, headers, protocols).await;
|
|
assert!(
|
|
message.contains("WebSocket connection failed"),
|
|
"unexpected error for {path}: {message}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_transport_allows_server_to_omit_response_subprotocol() {
|
|
let (url, server) = spawn_text_binary_echo_websocket_server().await;
|
|
let (event_tx, mut event_rx) = mpsc::channel(32);
|
|
|
|
let command_tx = spawn_standalone_connection(
|
|
4,
|
|
url,
|
|
vec!["chat".to_owned(), "superchat".to_owned()],
|
|
test_websocket_context(),
|
|
event_tx,
|
|
);
|
|
|
|
let open = recv_open_event(&mut event_rx).await;
|
|
assert_eq!(open.socket_id, 4);
|
|
assert_eq!(open.protocol, "");
|
|
command_tx
|
|
.close(Some(1000), "no-protocol".to_owned())
|
|
.expect("send close command");
|
|
assert_closing(&mut event_rx, 4).await;
|
|
assert_close(&mut event_rx, 4, 1000, "no-protocol", true).await;
|
|
server
|
|
.await
|
|
.expect("websocket no-protocol echo server should finish");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_transport_sends_text_binary_and_reports_buffered_amount_consumption() {
|
|
let (url, server) = spawn_text_binary_echo_websocket_server().await;
|
|
let (event_tx, mut event_rx) = mpsc::channel(32);
|
|
let command_tx =
|
|
spawn_standalone_connection(4, url, Vec::new(), test_websocket_context(), event_tx);
|
|
|
|
let open = recv_open_event(&mut event_rx).await;
|
|
assert_eq!(open.socket_id, 4);
|
|
|
|
command_tx
|
|
.send_text("hello".to_owned())
|
|
.expect("send text command");
|
|
assert_send_completed(&mut event_rx, 4, FrameOpcode::Text, 5).await;
|
|
|
|
assert_text_message(&mut event_rx, 4, "hello").await;
|
|
|
|
command_tx
|
|
.send_binary(vec![1, 2, 3, 4])
|
|
.expect("send binary command");
|
|
assert_send_completed(&mut event_rx, 4, FrameOpcode::Binary, 4).await;
|
|
|
|
assert_binary_message(&mut event_rx, 4, &[1, 2, 3, 4]).await;
|
|
|
|
command_tx
|
|
.close(Some(1000), "done".to_owned())
|
|
.expect("send close command");
|
|
assert_closing(&mut event_rx, 4).await;
|
|
assert_close(&mut event_rx, 4, 1000, "done", true).await;
|
|
server.await.expect("websocket echo server should finish");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_transport_reads_while_sending_many_large_messages() {
|
|
let (url, server) = spawn_backpressure_websocket_server().await;
|
|
let (event_tx, mut event_rx) = mpsc::channel(32);
|
|
let command_tx =
|
|
spawn_standalone_connection(40, url, Vec::new(), test_websocket_context(), event_tx);
|
|
|
|
let open = recv_open_event(&mut event_rx).await;
|
|
assert_eq!(open.socket_id, 40);
|
|
|
|
for _ in 0..50 {
|
|
command_tx
|
|
.send_binary(vec![0; 65_536])
|
|
.expect("send large binary command");
|
|
}
|
|
|
|
let mut replies = 0;
|
|
while replies < 50 {
|
|
match timeout(Duration::from_secs(3), event_rx.recv())
|
|
.await
|
|
.expect("websocket event should arrive")
|
|
.expect("websocket event channel should stay open")
|
|
{
|
|
Event::TextMessage { socket_id, data } => {
|
|
assert_eq!(socket_id, 40);
|
|
assert_eq!(data, "65536");
|
|
replies += 1;
|
|
}
|
|
Event::Error { message, .. } => panic!("unexpected websocket error: {message}"),
|
|
Event::Close { code, reason, .. } => {
|
|
panic!("websocket closed before all replies: {code} {reason}")
|
|
}
|
|
Event::HandshakeResponse { .. }
|
|
| Event::Open { .. }
|
|
| Event::BinaryMessage { .. }
|
|
| Event::SendCompleted { .. }
|
|
| Event::Closing { .. } => {}
|
|
}
|
|
}
|
|
|
|
command_tx
|
|
.close(Some(1000), "backpressure".to_owned())
|
|
.expect("send close command");
|
|
assert_closing(&mut event_rx, 40).await;
|
|
assert_close(&mut event_rx, 40, 1000, "backpressure", true).await;
|
|
server
|
|
.await
|
|
.expect("websocket backpressure server should finish");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_transport_reports_server_initiated_close_frame() {
|
|
let (url, server) = spawn_server_close_websocket_server().await;
|
|
let (event_tx, mut event_rx) = mpsc::channel(32);
|
|
let _command_tx =
|
|
spawn_standalone_connection(5, url, Vec::new(), test_websocket_context(), event_tx);
|
|
|
|
let open = recv_open_event(&mut event_rx).await;
|
|
assert_eq!(open.socket_id, 5);
|
|
assert_close(&mut event_rx, 5, 3001, "server done", true).await;
|
|
server
|
|
.await
|
|
.expect("websocket server-close server should finish");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_transport_handles_close_frame_in_handshake_packet() {
|
|
let (url, server) = spawn_computed_accept_websocket_response_with_body_server(
|
|
"simple-handshake-close",
|
|
vec!["Upgrade: websocket", "Connection: Upgrade"],
|
|
b"\x88\x06\x03\xe9PASS",
|
|
)
|
|
.await;
|
|
let (event_tx, mut event_rx) = mpsc::channel(32);
|
|
let _command_tx =
|
|
spawn_standalone_connection(6, url, Vec::new(), test_websocket_context(), event_tx);
|
|
|
|
let open = recv_open_event(&mut event_rx).await;
|
|
assert_eq!(open.socket_id, 6);
|
|
assert_close(&mut event_rx, 6, 1001, "PASS", true).await;
|
|
server
|
|
.await
|
|
.expect("websocket same-packet close server should finish");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_dropped_handshake_controller_fails_open_and_releases_transport() {
|
|
use futures_util::StreamExt;
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let url = format!("ws://{}/decision", listener.local_addr().unwrap());
|
|
let server = tokio::spawn(async move {
|
|
let (stream, _) = listener.accept().await.unwrap();
|
|
let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap();
|
|
assert!(socket.next().await.unwrap().is_err());
|
|
});
|
|
let (events, mut receiver) = mpsc::channel(4);
|
|
let (_connection, controller) = spawn_standalone_connection_with_handshake_pause(
|
|
95,
|
|
url,
|
|
Vec::new(),
|
|
test_websocket_context(),
|
|
events,
|
|
);
|
|
assert!(matches!(
|
|
timeout(Duration::from_secs(3), receiver.recv())
|
|
.await
|
|
.unwrap(),
|
|
Some(Event::HandshakeResponse { .. })
|
|
));
|
|
drop(controller);
|
|
assert_eq!(
|
|
recv_handshake_failure_events(&mut receiver).await,
|
|
"WebSocket handshake decision was dropped"
|
|
);
|
|
timeout(Duration::from_secs(3), server)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_synthetic_peer_does_not_keep_client_alive() {
|
|
let (events, mut receiver) = mpsc::channel(4);
|
|
let (connection, peer) =
|
|
spawn_synthetic_connection(96, Vec::new().into(), 101, Vec::new().into(), events);
|
|
recv_open_event(&mut receiver).await;
|
|
drop(connection);
|
|
assert_eq!(
|
|
peer.send_text("late message".to_owned()),
|
|
Err(SendError::Closed)
|
|
);
|
|
assert!(
|
|
timeout(Duration::from_secs(3), receiver.recv())
|
|
.await
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_queued_close_precedes_handshake_continuation() {
|
|
use futures_util::StreamExt;
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let url = format!("ws://{}/close-before-open", listener.local_addr().unwrap());
|
|
let server = tokio::spawn(async move {
|
|
let (stream, _) = listener.accept().await.unwrap();
|
|
let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap();
|
|
assert!(socket.next().await.unwrap().is_err());
|
|
});
|
|
let release = Arc::new(tokio::sync::Notify::new());
|
|
let release_sink = release.clone();
|
|
let (events, mut receiver) = mpsc::channel(4);
|
|
let sink = EventSender::with_async_sink(move |event| {
|
|
let events = events.clone();
|
|
let release = release_sink.clone();
|
|
async move {
|
|
let paused = matches!(event, Event::HandshakeResponse { .. });
|
|
let accepted = events.send(event).await.is_ok();
|
|
if paused {
|
|
release.notified().await;
|
|
}
|
|
accepted
|
|
}
|
|
});
|
|
let (connection, controller) = spawn_standalone_connection_with_handshake_pause(
|
|
97,
|
|
url,
|
|
Vec::new(),
|
|
test_websocket_context(),
|
|
sink,
|
|
);
|
|
assert!(matches!(
|
|
timeout(Duration::from_secs(3), receiver.recv())
|
|
.await
|
|
.unwrap(),
|
|
Some(Event::HandshakeResponse { .. })
|
|
));
|
|
connection.close(Some(1000), String::new()).unwrap();
|
|
controller.continue_open(None, None).unwrap();
|
|
release.notify_one();
|
|
assert_eq!(
|
|
recv_handshake_failure_events(&mut receiver).await,
|
|
"WebSocket connection closed before opening"
|
|
);
|
|
timeout(Duration::from_secs(3), server)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_native_handshake_generates_keys_and_reports_actual_headers() {
|
|
use base64::{Engine, engine::general_purpose::STANDARD};
|
|
let mut previous_key = None;
|
|
for socket_id in 98..100 {
|
|
let (url, captured, server) = spawn_header_capture_websocket_server().await;
|
|
let (events, mut receiver) = mpsc::channel(8);
|
|
let mut context = test_websocket_context();
|
|
context
|
|
.extra_headers
|
|
.push(("Sec-WebSocket-Key".to_owned(), b"injected".to_vec()));
|
|
let prepared = prepare_websocket_request(&url, &[], &context).unwrap();
|
|
for header in [
|
|
"host",
|
|
"connection",
|
|
"upgrade",
|
|
"sec-websocket-version",
|
|
"sec-websocket-key",
|
|
] {
|
|
assert!(
|
|
!prepared.headers.contains_key(header),
|
|
"{header} belongs to native handshake generation"
|
|
);
|
|
}
|
|
let connection = spawn_standalone_connection(socket_id, url, Vec::new(), context, events);
|
|
let actual = timeout(Duration::from_secs(3), captured)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let key = header_value(&actual, "sec-websocket-key").unwrap();
|
|
assert_eq!(STANDARD.decode(&key).unwrap().len(), 16);
|
|
assert_ne!(previous_key.as_ref(), Some(&key));
|
|
previous_key = Some(key.clone());
|
|
match timeout(Duration::from_secs(3), receiver.recv())
|
|
.await
|
|
.unwrap()
|
|
.unwrap()
|
|
{
|
|
Event::Open {
|
|
request_headers, ..
|
|
} => {
|
|
assert_eq!(
|
|
header_value(&request_headers.to_byte_strings(), "sec-websocket-key"),
|
|
Some(key)
|
|
);
|
|
assert_eq!(
|
|
header_value(&request_headers.to_byte_strings(), "host"),
|
|
header_value(&actual, "host")
|
|
);
|
|
assert_eq!(
|
|
header_value(&request_headers.to_byte_strings(), "sec-websocket-version")
|
|
.as_deref(),
|
|
Some("13")
|
|
);
|
|
}
|
|
event => panic!("expected Open with actual handshake headers, got {event:?}"),
|
|
}
|
|
let _ = connection.close(Some(1000), String::new());
|
|
timeout(Duration::from_secs(3), server)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
}
|
|
}
|