specters 4.1.4

Rust HTTP client with browser-like Chrome and Firefox fingerprints across TLS, HTTP/1.1, HTTP/2, HTTP/3, and WebSockets
Documentation
use std::time::Duration;

use specter::{Client, CloseCode, CloseFrame};

#[path = "helpers/mock_ws_server.rs"]
mod mock_ws_server;

use mock_ws_server::{AcceptMode, MockWsServer, WsResponse};

#[tokio::test]
async fn invalid_accept_returns_handshake_error_without_close_frame() {
    let server = MockWsServer::new().await.unwrap();
    let url = server.ws_url("/invalid-accept");
    let handle = server.start_once(WsResponse {
        accept: AcceptMode::Wrong,
        ..WsResponse::default()
    });

    let err = Client::new()
        .unwrap()
        .websocket(url)
        .connect()
        .await
        .expect_err("invalid accept must fail");

    assert_error_mentions(&err, &["InvalidAccept", "Sec-WebSocket-Accept"]);
    assert!(
        handle.await.unwrap().client_frame.is_none(),
        "handshake failures must not send websocket close frames"
    );
}

#[tokio::test]
async fn extension_response_returns_unexpected_extension_without_close_frame() {
    let server = MockWsServer::new().await.unwrap();
    let url = server.ws_url("/unexpected-extension");
    let handle = server.start_once(WsResponse {
        headers: vec![(
            "Sec-WebSocket-Extensions".to_string(),
            "permessage-deflate".to_string(),
        )],
        ..WsResponse::default()
    });

    let err = Client::new()
        .unwrap()
        .websocket(url)
        .connect()
        .await
        .expect_err("unexpected extension must fail");

    assert_error_mentions(&err, &["UnexpectedExtension", "Sec-WebSocket-Extensions"]);
    assert!(
        handle.await.unwrap().client_frame.is_none(),
        "handshake failures must not send websocket close frames"
    );
}

#[tokio::test]
async fn unoffered_subprotocol_returns_unexpected_subprotocol_without_close_frame() {
    let server = MockWsServer::new().await.unwrap();
    let url = server.ws_url("/unexpected-subprotocol");
    let handle = server.start_once(WsResponse {
        headers: vec![(
            "Sec-WebSocket-Protocol".to_string(),
            "superchat".to_string(),
        )],
        ..WsResponse::default()
    });

    let err = Client::new()
        .unwrap()
        .websocket(url)
        .connect()
        .await
        .expect_err("unexpected subprotocol must fail");

    assert_error_mentions(&err, &["UnexpectedSubprotocol", "Sec-WebSocket-Protocol"]);
    assert!(
        handle.await.unwrap().client_frame.is_none(),
        "handshake failures must not send websocket close frames"
    );
}

#[tokio::test]
async fn invalid_outbound_close_codes_are_rejected_without_sending_frame() {
    for code in [
        CloseCode::Status,
        CloseCode::Abnormal,
        CloseCode::Tls,
        CloseCode::Library(1004),
        CloseCode::Library(1016),
        CloseCode::Library(2999),
    ] {
        let server = MockWsServer::new().await.unwrap();
        let url = server.ws_url("/invalid-close-code");
        let handle = server.start_once(WsResponse::default());

        let mut ws = Client::new()
            .unwrap()
            .websocket(url)
            .connect()
            .await
            .expect("websocket handshake should succeed");

        let err = ws
            .close(Some(CloseFrame {
                code,
                reason: String::new(),
            }))
            .await
            .expect_err("invalid close code must fail before sending");

        assert_error_mentions(&err, &["close code", "must not be sent"]);
        assert!(
            handle.await.unwrap().client_frame.is_none(),
            "invalid outbound close code must not write a close frame"
        );
    }
}

#[tokio::test]
async fn established_read_timeout_returns_timeout_error() {
    let server = MockWsServer::new().await.unwrap();
    let url = server.ws_url("/read-timeout");
    let handle = server.start_once(WsResponse::default());

    let mut ws = Client::new()
        .unwrap()
        .websocket(url)
        .read_timeout(Duration::from_millis(20))
        .connect()
        .await
        .expect("websocket handshake should succeed");

    let err = ws.next().await.expect_err("read timeout must fail");
    assert_error_mentions(&err, &["Timeout", "read"]);

    let _ = handle.await.unwrap();
}

fn assert_error_mentions<E: std::fmt::Debug + std::fmt::Display>(err: &E, needles: &[&str]) {
    let rendered = format!("{err:?}\n{err}");
    assert!(
        needles.iter().any(|needle| rendered.contains(needle)),
        "error did not mention any of {needles:?}: {rendered}"
    );
}