koda-core 0.2.23

Core engine for the Koda AI coding agent (macOS and Linux only)
Documentation
//! HTTP-client configuration tests for [`build_http_client`].
//!
//! Exercises proxy-honoring, localhost-bypass, basic-auth-via-env-vars,
//! and the graceful-degradation path for invalid `HTTP_PROXY` URLs.
//!
//! ## Why not an in-source unit test?
//!
//! The behaviors under test require a real TCP socket on loopback
//! (the reqwest proxy machinery only kicks in on actual `.send()`).
//! That makes them integration tests, not unit tests.
//!
//! ## Test isolation (#1109 F1)
//!
//! All tests in this file mutate process-wide state in
//! [`koda_core::runtime_env`] (the runtime env-var map).
//! [`koda_test_utils::ENV_MUTEX`] serializes them so concurrent test
//! runners don't trample each other, and [`EnvGuard`] (defined below)
//! removes any keys this test set/masked when it returns — even on panic.
//!
//! Previously this file used `unsafe { std::env::remove_var(...) }` to
//! hide a developer's exported `HTTP_PROXY` from the production code
//! path (which falls back to `std::env::var` when the runtime map has
//! no entry). That `unsafe` is gone now: [`runtime_env::mask`] makes
//! [`runtime_env::get`] return `None` for masked keys regardless of
//! the real process env. No `std::env` mutation, no UB, no snapshot
//! restore dance.
//!
//! Related: #914 covers the missing retry/backoff/timeout layer; once
//! that lands, retry-on-429 and timeout-on-stall tests can use the
//! same `FakeLlmServer` fixtures.

use std::sync::OnceLock;

use koda_core::providers::openai_compat::OpenAiCompatProvider;
use koda_core::providers::{ChatMessage, LlmProvider};
use koda_core::{config::ModelSettings, config::ProviderType, runtime_env};
use koda_test_utils::ENV_MUTEX;
use koda_test_utils::network::FakeLlmServer;
use serde_json::{Value, json};

// ── Helpers ───────────────────────────────────────────────

/// Proxy-related env keys that production reads. Tests need to hide
/// the developer's real values so the runtime override is what gets
/// observed.
const PROXY_ENV_KEYS: &[&str] = &[
    "HTTP_PROXY",
    "HTTPS_PROXY",
    "http_proxy",
    "https_proxy",
    "PROXY_USER",
    "PROXY_PASS",
];

/// RAII guard for runtime-env state. Tracks both `set` and `mask`
/// operations so they're undone on drop — surviving panics so a
/// failing test cannot leak env state into siblings.
struct EnvGuard<'a> {
    set_keys: Vec<&'a str>,
    masked_keys: Vec<&'a str>,
}

impl<'a> EnvGuard<'a> {
    fn new() -> Self {
        Self {
            set_keys: Vec::new(),
            masked_keys: Vec::new(),
        }
    }

    /// Set the runtime-env var and remember to remove it at drop.
    fn set(&mut self, key: &'a str, value: &str) {
        runtime_env::set(key, value);
        self.set_keys.push(key);
    }

    /// Mask the key so [`runtime_env::get`] returns `None` regardless
    /// of process env. Lifted at drop.
    fn mask(&mut self, key: &'a str) {
        runtime_env::mask(key);
        self.masked_keys.push(key);
    }

    /// Mask every proxy-related env key. Convenience for the common
    /// pre-test setup that previously called
    /// `snapshot_and_clear_process_proxy_env`.
    fn mask_all_proxy_env(&mut self) {
        for k in PROXY_ENV_KEYS {
            self.mask(k);
        }
    }
}

impl Drop for EnvGuard<'_> {
    fn drop(&mut self) {
        for k in &self.set_keys {
            runtime_env::remove(k);
        }
        for k in &self.masked_keys {
            runtime_env::unmask(k);
        }
    }
}

fn ok_chat_body() -> Value {
    json!({
        "id": "chatcmpl-test",
        "object": "chat.completion",
        "created": 1_700_000_000,
        "model": "gpt-4o",
        "choices": [{
            "index": 0,
            "message": { "role": "assistant", "content": "ok" },
            "finish_reason": "stop"
        }],
        "usage": { "prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2 }
    })
}

fn settings() -> ModelSettings {
    static S: OnceLock<ModelSettings> = OnceLock::new();
    S.get_or_init(|| ModelSettings::defaults_for("gpt-4o", &ProviderType::OpenAI))
        .clone()
}

fn user_msg() -> ChatMessage {
    ChatMessage::text("user", "hi")
}

// ── Tests ─────────────────────────────────────────────────────────────────

#[tokio::test]
async fn http_proxy_routes_remote_requests_through_proxy() {
    let _g = ENV_MUTEX.lock().await;

    // The proxy is a FakeLlmServer — it won't actually forward, just match
    // the path regex on the inbound proxied request and respond 200. That's
    // enough to prove "the request hit the proxy, not the origin".
    let proxy = FakeLlmServer::spawn().await;
    proxy.mount_chat_ok(ok_chat_body()).await;

    let mut env = EnvGuard::new();
    env.mask_all_proxy_env();
    env.set("HTTP_PROXY", &proxy.url());

    // Target a non-localhost host so the proxy is consulted (the bypass
    // list excludes only localhost/127.0.0.1/::1).
    let provider =
        OpenAiCompatProvider::new("http://upstream.test.invalid", Some("sk-test".into()));
    provider
        .chat(&[user_msg()], &[], &settings())
        .await
        .expect("chat must succeed via proxy");

    let proxied = proxy.received_requests().await;
    assert_eq!(proxied.len(), 1, "request must be routed through the proxy");
}

#[tokio::test]
async fn localhost_traffic_bypasses_proxy_even_when_set() {
    let _g = ENV_MUTEX.lock().await;

    // Two servers: a "proxy" we expect to be skipped, an "upstream" we
    // expect to receive the request directly because the URL is localhost.
    let proxy = FakeLlmServer::spawn().await;
    proxy.mount_chat_ok(ok_chat_body()).await;
    let upstream = FakeLlmServer::spawn().await;
    upstream.mount_chat_ok(ok_chat_body()).await;

    let mut env = EnvGuard::new();
    env.mask_all_proxy_env();
    env.set("HTTP_PROXY", &proxy.url());

    let provider = OpenAiCompatProvider::new(&upstream.url(), Some("sk-test".into()));
    provider
        .chat(&[user_msg()], &[], &settings())
        .await
        .expect("chat must succeed directly to localhost upstream");

    assert_eq!(
        proxy.received_requests().await.len(),
        0,
        "proxy must be bypassed for localhost targets"
    );
    assert_eq!(
        upstream.received_requests().await.len(),
        1,
        "upstream must receive the request directly"
    );
}

#[tokio::test]
async fn proxy_basic_auth_from_env_vars_attaches_proxy_authorization_header() {
    let _g = ENV_MUTEX.lock().await;

    let proxy = FakeLlmServer::spawn().await;
    proxy.mount_chat_ok(ok_chat_body()).await;

    let mut env = EnvGuard::new();
    env.mask_all_proxy_env();
    env.set("HTTP_PROXY", &proxy.url());
    env.set("PROXY_USER", "alice");
    env.set("PROXY_PASS", "s3cret");

    let provider =
        OpenAiCompatProvider::new("http://upstream.test.invalid", Some("sk-test".into()));
    provider
        .chat(&[user_msg()], &[], &settings())
        .await
        .expect("chat must succeed via authenticated proxy");

    let proxied = proxy.received_requests().await;
    assert_eq!(proxied.len(), 1);
    let auth = proxied[0]
        .headers
        .get("proxy-authorization")
        .expect("Proxy-Authorization header must be set when PROXY_USER/PROXY_PASS are present");
    // Basic alice:s3cret = YWxpY2U6czNjcmV0
    assert_eq!(auth, "Basic YWxpY2U6czNjcmV0");
}

#[tokio::test]
async fn invalid_proxy_url_degrades_gracefully_to_no_proxy() {
    let _g = ENV_MUTEX.lock().await;

    // Garbage proxy URL: the production code logs a warning and returns a
    // proxy-less client rather than panicking. Verify by making a request
    // to a localhost upstream and checking it actually arrives.
    let upstream = FakeLlmServer::spawn().await;
    upstream.mount_chat_ok(ok_chat_body()).await;

    let mut env = EnvGuard::new();
    env.mask_all_proxy_env();
    env.set("HTTP_PROXY", "://this is not a url@@@");

    let provider = OpenAiCompatProvider::new(&upstream.url(), Some("sk-test".into()));
    provider
        .chat(&[user_msg()], &[], &settings())
        .await
        .expect("chat must still succeed when HTTP_PROXY is malformed");

    assert_eq!(
        upstream.received_requests().await.len(),
        1,
        "malformed proxy URL must not block legitimate localhost traffic"
    );
}

#[tokio::test]
async fn read_timeout_aborts_silent_servers_quickly() {
    // Regression: previously the reqwest client had no read_timeout, so a
    // server that accepted the connection but then went silent (or a
    // half-open socket a NAT box quietly dropped) would hang the agent
    // forever. Now KODA_READ_TIMEOUT_SECS bounds idle time between reads.
    let _g = ENV_MUTEX.lock().await;

    let upstream = FakeLlmServer::spawn().await;
    // Server takes 3s to start sending; client should give up at ~1s.
    upstream
        .mount_chat_delayed(std::time::Duration::from_secs(3), ok_chat_body())
        .await;

    let mut env = EnvGuard::new();
    env.mask_all_proxy_env();
    env.set("KODA_READ_TIMEOUT_SECS", "1");

    let provider = OpenAiCompatProvider::new(&upstream.url(), Some("sk-test".into()));

    let started = std::time::Instant::now();
    let result = provider.chat(&[user_msg()], &[], &settings()).await;
    let elapsed = started.elapsed();

    assert!(result.is_err(), "stalled server must produce an error");
    // Generous bound: must abort well before the 3s server delay finishes.
    // A passing test typically takes ~1.0–1.2s. >2s means the timeout
    // didn't fire and reqwest waited for the full server delay.
    assert!(
        elapsed < std::time::Duration::from_millis(2500),
        "should error in ~1s, took {elapsed:?} — read_timeout likely not applied"
    );

    let msg = format!("{:?}", result.unwrap_err()).to_lowercase();
    // reqwest surfaces this as a timeout-flavored error. Don't pin the
    // exact wording (varies by reqwest version) — just look for the
    // expected vocabulary.
    assert!(
        msg.contains("timeout") || msg.contains("timed out") || msg.contains("operation"),
        "error should mention timeout, got: {msg}"
    );
}