mirror of
https://github.com/lexmount/moli.git
synced 2026-10-06 08:00:59 +00:00
1136 lines
37 KiB
Rust
1136 lines
37 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::{append_proxy_connect_header, no_proxy_matches},
|
|
request::build_websocket_request,
|
|
test_support::*,
|
|
};
|
|
use tokio::{
|
|
sync::{Semaphore, mpsc},
|
|
time::{Duration, timeout},
|
|
};
|
|
use url::Url;
|
|
|
|
#[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_builder_rejects_invalid_subprotocol_defensively() {
|
|
let context = test_websocket_context();
|
|
let error = build_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_builder_rejects_blocked_ports() {
|
|
let context = test_websocket_context();
|
|
let error = build_websocket_request("ws://127.0.0.1:25/socket", &[], &context)
|
|
.expect_err("blocked port should fail");
|
|
|
|
assert!(error.contains("port `25` is blocked"));
|
|
assert!(build_websocket_request("ws://127.0.0.1:43210/socket", &[], &context).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_no_proxy_matches_hosts_domains_ports_and_wildcard() {
|
|
assert!(no_proxy_matches("example.com", None, Some("example.com")));
|
|
assert!(no_proxy_matches(
|
|
"api.example.com",
|
|
None,
|
|
Some(".example.com")
|
|
));
|
|
assert!(no_proxy_matches(
|
|
"api.example.com",
|
|
Some(8080),
|
|
Some("example.com:8080")
|
|
));
|
|
assert!(no_proxy_matches("anything.test", None, Some("*")));
|
|
assert!(!no_proxy_matches(
|
|
"api.example.com",
|
|
Some(8081),
|
|
Some("example.com:8080")
|
|
));
|
|
assert!(!no_proxy_matches(
|
|
"notexample.com",
|
|
None,
|
|
Some("example.com")
|
|
));
|
|
}
|
|
|
|
#[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_connect_header_rejects_newline_values() {
|
|
let mut request = String::new();
|
|
assert!(append_proxy_connect_header(&mut request, "User-Agent", "Moli").is_ok());
|
|
assert_eq!(request, "User-Agent: Moli\r\n");
|
|
assert!(
|
|
append_proxy_connect_header(&mut request, "Proxy-Authorization", "Bearer good\nbad")
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_request_builder_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()),
|
|
];
|
|
context.cookie_header = Some("sid=server".to_owned());
|
|
|
|
let request = build_websocket_request(
|
|
"ws://example.com/socket",
|
|
&["chat".to_owned(), "superchat".to_owned()],
|
|
&context,
|
|
)
|
|
.expect("websocket request should build");
|
|
|
|
assert_eq!(request.uri(), "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()),
|
|
Some("13")
|
|
);
|
|
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_builder_converts_url_userinfo_to_basic_auth() {
|
|
let context = test_websocket_context();
|
|
|
|
let request = build_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_builder_decodes_percent_encoded_userinfo_for_basic_auth() {
|
|
let context = test_websocket_context();
|
|
|
|
let request =
|
|
build_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_synthetic_connection_opens_accounts_send_and_closes_cleanly() {
|
|
let (event_tx, mut event_rx) = mpsc::channel(32);
|
|
let command_tx = spawn_synthetic_connection(
|
|
92,
|
|
vec![("Origin".to_owned(), "http://example.test".to_owned())],
|
|
101,
|
|
vec![("Sec-WebSocket-Protocol".to_owned(), "chat".to_owned())],
|
|
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(Command::SendText("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::FrameSent {
|
|
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:?}"),
|
|
}
|
|
let event = timeout(Duration::from_secs(1), event_rx.recv())
|
|
.await
|
|
.expect("synthetic buffered amount consumption should arrive")
|
|
.expect("synthetic websocket event channel should stay open");
|
|
match event {
|
|
Event::BufferedAmountConsumed { socket_id, amount } => {
|
|
assert_eq!(socket_id, 92);
|
|
assert_eq!(amount, 5);
|
|
}
|
|
event => panic!("expected synthetic buffered amount event, got {event:?}"),
|
|
}
|
|
|
|
command_tx
|
|
.send(Command::Close {
|
|
code: Some(1000),
|
|
reason: "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 command_tx = spawn_synthetic_connection(
|
|
93,
|
|
vec![("Origin".to_owned(), "http://example.test".to_owned())],
|
|
101,
|
|
Vec::new(),
|
|
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, .. }
|
|
));
|
|
|
|
command_tx
|
|
.send(Command::ReceiveText("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:?}"),
|
|
}
|
|
|
|
command_tx
|
|
.send(Command::ReceiveBinary(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:?}"),
|
|
}
|
|
|
|
command_tx
|
|
.send(Command::ServerClose {
|
|
code: Some(1000),
|
|
reason: "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_connection(91, url, Vec::new(), test_websocket_context(), event_tx);
|
|
|
|
command_tx
|
|
.send(Command::Close {
|
|
code: None,
|
|
reason: 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 mut context = test_websocket_context();
|
|
context.pause_after_handshake = true;
|
|
let command_tx = spawn_connection(92, url, Vec::new(), 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 ContinueOpen"
|
|
);
|
|
|
|
command_tx
|
|
.send(Command::ContinueOpen {
|
|
response_status: None,
|
|
response_headers: None,
|
|
})
|
|
.expect("continue paused websocket open");
|
|
let open = recv_open_event(&mut event_rx).await;
|
|
assert_eq!(open.socket_id, 92);
|
|
let _ = command_tx.send(Command::Close {
|
|
code: Some(1000),
|
|
reason: "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 tungstenite and should not
|
|
// be overridden by embedding-layer extra headers.
|
|
("Sec-WebSocket-Version".to_owned(), "999".to_owned()),
|
|
];
|
|
context.cookie_header = Some("sid=server".to_owned());
|
|
|
|
let command_tx = spawn_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.send(Command::Close {
|
|
code: Some(1000),
|
|
reason: "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();
|
|
context.http_proxy = Some(proxy_url);
|
|
context.http_no_proxy = Some(String::new());
|
|
context.proxy_bearer_token = Some("proxy-token".to_owned());
|
|
|
|
let command_tx = spawn_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.send(Command::Close {
|
|
code: Some(1000),
|
|
reason: "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_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_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();
|
|
context.http_proxy = Some(proxy_url);
|
|
context.http_no_proxy = Some(String::new());
|
|
|
|
let _command_tx = spawn_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_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_host = false;
|
|
|
|
let command_tx = spawn_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.send(Command::Close {
|
|
code: Some(1000),
|
|
reason: "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_host = false;
|
|
|
|
let _command_tx = spawn_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_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
|
|
.send(Command::Close {
|
|
code: Some(1000),
|
|
reason: "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_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(Command::SendText("hello".to_owned()))
|
|
.expect("send text command");
|
|
assert_frame_sent(&mut event_rx, 4, FrameOpcode::Text, 5).await;
|
|
assert_buffered_amount_consumed(&mut event_rx, 4, 5).await;
|
|
assert_text_message(&mut event_rx, 4, "hello").await;
|
|
|
|
command_tx
|
|
.send(Command::SendBinary(vec![1, 2, 3, 4]))
|
|
.expect("send binary command");
|
|
assert_frame_sent(&mut event_rx, 4, FrameOpcode::Binary, 4).await;
|
|
assert_buffered_amount_consumed(&mut event_rx, 4, 4).await;
|
|
assert_binary_message(&mut event_rx, 4, &[1, 2, 3, 4]).await;
|
|
|
|
command_tx
|
|
.send(Command::Close {
|
|
code: Some(1000),
|
|
reason: "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_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(Command::SendBinary(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::FrameSent { .. }
|
|
| Event::BufferedAmountConsumed { .. }
|
|
| Event::Closing { .. } => {}
|
|
}
|
|
}
|
|
|
|
command_tx
|
|
.send(Command::Close {
|
|
code: Some(1000),
|
|
reason: "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_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_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");
|
|
}
|