#![allow(
clippy::expect_used,
clippy::panic,
reason = "panicking is how an integration test reports a failure"
)]
#![allow(
clippy::future_not_send,
reason = "the bridge is single-threaded by design; see its module docs"
)]
use std::time::Duration;
use rs_teststand_websocket::{Options, WebSocketBridge};
use tokio_tungstenite::tungstenite::client::IntoClientRequest as _;
use tokio_tungstenite::tungstenite::handshake::client::Request;
fn from_origin(url: &str, origin: &str) -> Request {
let mut request = url.into_client_request().expect("a valid url");
request.headers_mut().insert(
"Origin",
origin
.parse()
.expect("a header value with no control bytes"),
);
request
}
async fn wait_for_client(bridge: &WebSocketBridge) -> bool {
for _ in 0..100 {
if bridge.client_count() > 0 {
return true;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
false
}
#[tokio::test]
async fn a_client_that_sends_no_origin_is_served() {
let bridge = WebSocketBridge::bind("127.0.0.1:0").expect("bind");
let url = format!("ws://{}", bridge.address());
let (_client, _) = tokio_tungstenite::connect_async(&url)
.await
.expect("a client with no Origin should be served");
assert!(wait_for_client(&bridge).await, "client not registered");
}
#[tokio::test]
async fn an_unknown_origin_is_refused_with_403() {
let bridge = WebSocketBridge::bind("127.0.0.1:0").expect("bind");
let url = format!("ws://{}", bridge.address());
let Err(refused) =
tokio_tungstenite::connect_async(from_origin(&url, "http://evil.example")).await
else {
panic!("an unknown origin should not complete the handshake");
};
match refused {
tokio_tungstenite::tungstenite::Error::Http(response) => assert_eq!(
response.status(),
403,
"RFC 6455 section 10.2 asks for 403 Forbidden"
),
other => panic!("expected an HTTP rejection, got {other:?}"),
}
let counted = bridge.client_count();
assert_eq!(
counted, 0,
"a refused panel registered anyway: {counted} subscriber(s) after a 403"
);
}
#[tokio::test]
async fn an_allowed_origin_is_served() {
let bridge = WebSocketBridge::bind_with(
"127.0.0.1:0",
Options::default().allow_origin("http://panel.example"),
)
.expect("bind");
let url = format!("ws://{}", bridge.address());
let (_client, _) = tokio_tungstenite::connect_async(from_origin(&url, "http://panel.example"))
.await
.expect("an allowed origin should be served");
assert!(wait_for_client(&bridge).await, "client not registered");
}
#[tokio::test]
async fn the_allowlist_is_exact_rather_than_a_prefix() {
let bridge = WebSocketBridge::bind_with(
"127.0.0.1:0",
Options::default().allow_origin("http://panel.example"),
)
.expect("bind");
let url = format!("ws://{}", bridge.address());
for lookalike in [
"http://panel.example.evil.test",
"http://panel.example:8080",
"https://panel.example",
"http://not-panel.example",
] {
let outcome = tokio_tungstenite::connect_async(from_origin(&url, lookalike)).await;
assert!(outcome.is_err(), "{lookalike} should not be served");
}
}
#[tokio::test]
async fn a_served_panel_may_talk_to_the_host_that_served_it() {
let bridge = WebSocketBridge::bind_with(
"127.0.0.1:0",
Options::default().page("<!doctype html><title>panel</title>"),
)
.expect("bind");
let address = bridge.address();
let url = format!("ws://{address}");
let (_client, _) =
tokio_tungstenite::connect_async(from_origin(&url, &format!("http://{address}")))
.await
.expect("the page's own origin should be served");
assert!(wait_for_client(&bridge).await, "client not registered");
}
#[tokio::test]
async fn a_host_that_serves_no_page_grants_no_same_origin_exemption() {
let bridge = WebSocketBridge::bind("127.0.0.1:0").expect("bind");
let address = bridge.address();
let outcome = tokio_tungstenite::connect_async(from_origin(
&format!("ws://{address}"),
&format!("http://{address}"),
))
.await;
assert!(
outcome.is_err(),
"no page was served, so no origin is automatically trusted"
);
}