rustyschwab 0.2.0

Unofficial Rust SDK for the Charles Schwab Trader and Market Data APIs: OAuth, REST, streaming, journaled orders, risk limits and a paper venue
Documentation
//! Unit tests for configuration module
//!
//! Tests domain validation, URL parsing, config loading from env/file
// `std::env::set_var` is `unsafe` in edition 2024; every call here holds
// ENV_MUTEX, so no two tests touch the environment at once.
#![allow(unsafe_code)]

use rustyschwab::config::SchwabConfig;
use std::env;
use std::sync::Mutex;

// Mutex to ensure tests that modify environment variables run sequentially
static ENV_MUTEX: std::sync::LazyLock<Mutex<()>> = std::sync::LazyLock::new(|| Mutex::new(()));

/// The streamer URL Schwab's preferences name is accepted; loopback is
/// matched exactly, so no `*.localhost` name inherits its plain `ws://`.
#[test]
fn streamer_socket_urls_come_from_schwab_hosts_only() {
    use rustyschwab::config::{DEFAULT_STREAMER_URL, StreamConfig};
    let mut config = StreamConfig::default();
    assert_eq!(config.websocket_url, DEFAULT_STREAMER_URL);
    config
        .set_socket_url("wss://streamer-api.schwab.com/ws")
        .unwrap();
    config.set_socket_url("ws://127.0.0.1:9000/ws").unwrap();
    for bad in [
        "ws://streamer-api.schwab.com/ws",
        "wss://evil.example/ws",
        "wss://evil.localhost/ws",
        "ws://evil.localhost/ws",
        "wss://schwab.com.evil.example/ws",
    ] {
        assert!(config.set_socket_url(bad).is_err(), "{bad}");
    }
    assert_eq!(config.websocket_url, "ws://127.0.0.1:9000/ws");
}

#[test]
fn test_domain_validation_allowed_schwab_api() {
    let _lock = ENV_MUTEX.lock().unwrap();
    unsafe {
        env::set_var("SCHWAB_APP_KEY", "test_key");
        env::set_var("SCHWAB_APP_SECRET", "test_secret");
        env::set_var(
            "SCHWAB_OAUTH_AUTH_URL",
            "https://api.schwabapi.com/v1/oauth/authorize",
        );
        env::set_var(
            "SCHWAB_OAUTH_TOKEN_URL",
            "https://api.schwabapi.com/v1/oauth/token",
        );
        env::set_var("SCHWAB_CLIENT_BASE_URL", "https://api.schwabapi.com");
    }

    let result = SchwabConfig::from_env();
    assert!(result.is_ok(), "Valid Schwab API domain should be allowed");

    // Cleanup
    unsafe {
        env::remove_var("SCHWAB_APP_KEY");
        env::remove_var("SCHWAB_APP_SECRET");
        env::remove_var("SCHWAB_OAUTH_AUTH_URL");
        env::remove_var("SCHWAB_OAUTH_TOKEN_URL");
        env::remove_var("SCHWAB_CLIENT_BASE_URL");
    }
}

#[test]
fn test_domain_validation_allowed_localhost() {
    let _lock = ENV_MUTEX.lock().unwrap();
    unsafe {
        env::set_var("SCHWAB_APP_KEY", "test_key");
        env::set_var("SCHWAB_APP_SECRET", "test_secret");
        env::set_var(
            "SCHWAB_OAUTH_CALLBACK_URL",
            "https://127.0.0.1:8080/callback",
        );
        env::set_var(
            "SCHWAB_OAUTH_AUTH_URL",
            "https://api.schwabapi.com/v1/oauth/authorize",
        );
        env::set_var(
            "SCHWAB_OAUTH_TOKEN_URL",
            "https://api.schwabapi.com/v1/oauth/token",
        );
        env::set_var("SCHWAB_CLIENT_BASE_URL", "https://api.schwabapi.com");
    }

    let result = SchwabConfig::from_env();
    assert!(result.is_ok(), "Localhost callback should be allowed");

    // Cleanup
    unsafe {
        env::remove_var("SCHWAB_APP_KEY");
        env::remove_var("SCHWAB_APP_SECRET");
        env::remove_var("SCHWAB_OAUTH_CALLBACK_URL");
        env::remove_var("SCHWAB_OAUTH_AUTH_URL");
        env::remove_var("SCHWAB_OAUTH_TOKEN_URL");
        env::remove_var("SCHWAB_CLIENT_BASE_URL");
    }
}

#[test]
fn test_domain_validation_denied_external_domain() {
    let _lock = ENV_MUTEX.lock().unwrap();
    unsafe {
        env::set_var("SCHWAB_APP_KEY", "test_key");
        env::set_var("SCHWAB_APP_SECRET", "test_secret");
        env::set_var("SCHWAB_OAUTH_AUTH_URL", "https://evil.com/oauth/authorize");
        env::set_var(
            "SCHWAB_OAUTH_TOKEN_URL",
            "https://api.schwabapi.com/v1/oauth/token",
        );
        env::set_var("SCHWAB_CLIENT_BASE_URL", "https://api.schwabapi.com");
    }

    let result = SchwabConfig::from_env();
    assert!(result.is_err(), "External domain should be rejected");

    if let Err(e) = result {
        let error_msg = format!("{}", e);
        assert!(
            error_msg.contains("domain") || error_msg.contains("allowed"),
            "Error should mention domain validation: {}",
            error_msg
        );
    }

    // Cleanup
    unsafe {
        env::remove_var("SCHWAB_APP_KEY");
        env::remove_var("SCHWAB_APP_SECRET");
        env::remove_var("SCHWAB_OAUTH_AUTH_URL");
        env::remove_var("SCHWAB_OAUTH_TOKEN_URL");
        env::remove_var("SCHWAB_CLIENT_BASE_URL");
    }
}

#[test]
fn test_config_from_env_missing_required_key() {
    let _lock = ENV_MUTEX.lock().unwrap();
    unsafe {
        env::remove_var("SCHWAB_APP_KEY");
        env::set_var("SCHWAB_APP_SECRET", "test_secret");
    }

    let result = SchwabConfig::from_env();
    assert!(result.is_err(), "Missing required key should fail");

    // Cleanup
    unsafe {
        env::remove_var("SCHWAB_APP_SECRET");
    }
}

#[test]
fn test_config_from_env_missing_required_secret() {
    let _lock = ENV_MUTEX.lock().unwrap();
    unsafe {
        env::set_var("SCHWAB_APP_KEY", "test_key");
        env::remove_var("SCHWAB_APP_SECRET");
    }

    let result = SchwabConfig::from_env();
    assert!(result.is_err(), "Missing required secret should fail");

    // Cleanup
    unsafe {
        env::remove_var("SCHWAB_APP_KEY");
    }
}

#[test]
fn test_config_validation_invalid_url() {
    let _lock = ENV_MUTEX.lock().unwrap();
    unsafe {
        env::set_var("SCHWAB_APP_KEY", "test_key");
        env::set_var("SCHWAB_APP_SECRET", "test_secret");
        env::set_var("SCHWAB_OAUTH_AUTH_URL", "not-a-valid-url");
        env::set_var(
            "SCHWAB_OAUTH_TOKEN_URL",
            "https://api.schwabapi.com/v1/oauth/token",
        );
        env::set_var("SCHWAB_CLIENT_BASE_URL", "https://api.schwabapi.com");
    }

    let result = SchwabConfig::from_env();
    assert!(result.is_err(), "Invalid URL should be rejected");

    // Cleanup
    unsafe {
        env::remove_var("SCHWAB_APP_KEY");
        env::remove_var("SCHWAB_APP_SECRET");
        env::remove_var("SCHWAB_OAUTH_AUTH_URL");
        env::remove_var("SCHWAB_OAUTH_TOKEN_URL");
        env::remove_var("SCHWAB_CLIENT_BASE_URL");
    }
}

#[test]
fn test_config_defaults() {
    let _lock = ENV_MUTEX.lock().unwrap();
    unsafe {
        env::set_var("SCHWAB_APP_KEY", "test_key");
        env::set_var("SCHWAB_APP_SECRET", "test_secret");
        env::remove_var("SCHWAB_OAUTH_CALLBACK_URL");
        env::set_var(
            "SCHWAB_OAUTH_AUTH_URL",
            "https://api.schwabapi.com/v1/oauth/authorize",
        );
        env::set_var(
            "SCHWAB_OAUTH_TOKEN_URL",
            "https://api.schwabapi.com/v1/oauth/token",
        );
        env::set_var("SCHWAB_CLIENT_BASE_URL", "https://api.schwabapi.com");
    }

    let result = SchwabConfig::from_env();
    if let Ok(config) = result {
        // Verify defaults are set
        assert!(
            !config.oauth.callback_url.is_empty(),
            "Should have default callback URL"
        );
    } else {
        println!("Note: Config might require all fields, defaults not yet implemented");
    }

    // Cleanup
    unsafe {
        env::remove_var("SCHWAB_APP_KEY");
        env::remove_var("SCHWAB_APP_SECRET");
        env::remove_var("SCHWAB_OAUTH_AUTH_URL");
        env::remove_var("SCHWAB_OAUTH_TOKEN_URL");
        env::remove_var("SCHWAB_CLIENT_BASE_URL");
    }
}

/// A saved config holds the app secret: it loads back only while it keeps
/// owner-only permissions.
#[cfg(unix)]
#[test]
fn config_files_round_trip_and_refuse_loose_permissions() {
    use std::os::unix::fs::PermissionsExt;
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("config.json");
    let mut config = SchwabConfig::default();
    config.oauth.app_key = "12345678901234567890123456789012".into();
    config.oauth.app_secret = "1234567890123456".into();
    config.save(&path).unwrap();
    let loaded = SchwabConfig::from_file(&path).unwrap();
    assert_eq!(loaded.oauth.app_secret, config.oauth.app_secret);
    std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
    assert!(SchwabConfig::from_file(&path).is_err());
}