folk-plugin-http 0.2.4

HTTP plugin for Folk — accepts connections via hyper and dispatches to PHP workers
Documentation
use std::net::IpAddr;

use axum::body::Body;
use axum::http::Request;
use base64::Engine;
use folk_plugin_http::config::HttpConfig;
use folk_plugin_http::payload::{decode_response, encode_request};
use folk_plugin_http::server::resolve_client_ip;

#[tokio::test]
async fn response_payload_roundtrip() {
    let value = serde_json::json!({
        "status": 200,
        "headers": {"content-type": "text/plain"},
        "body": "hello",
    });
    let response = decode_response(value).unwrap();
    assert_eq!(response.status(), 200);
    assert_eq!(
        response.headers().get("content-type").unwrap(),
        "text/plain"
    );
}

#[tokio::test]
async fn request_payload_roundtrip() {
    let req = Request::builder()
        .method("POST")
        .uri("/test?foo=bar")
        .header("content-type", "application/json")
        .body(Body::from(r#"{"key":"value"}"#))
        .unwrap();

    let value = encode_request(req, 1024 * 1024).await.unwrap();
    assert_eq!(value["method"], "POST");
    assert_eq!(value["uri"], "/test?foo=bar");
    assert_eq!(value["headers"]["content-type"], "application/json");
    assert_eq!(value["body"], r#"{"key":"value"}"#);
    assert!(value.get("body_encoding").is_none());
}

#[tokio::test]
async fn request_binary_body_base64_encoded() {
    let binary: Vec<u8> = vec![0x00, 0x01, 0xFF, 0xFE, 0x89, 0x50, 0x4E, 0x47];
    let req = Request::builder()
        .method("POST")
        .uri("/upload")
        .header("content-type", "application/octet-stream")
        .body(Body::from(binary.clone()))
        .unwrap();

    let value = encode_request(req, 1024 * 1024).await.unwrap();
    assert_eq!(value["body_encoding"], "base64");
    let decoded = base64::engine::general_purpose::STANDARD
        .decode(value["body"].as_str().unwrap())
        .unwrap();
    assert_eq!(decoded, binary);
}

#[tokio::test]
async fn response_base64_body_decoded() {
    let binary: Vec<u8> = vec![0x00, 0x01, 0xFF, 0xFE];
    let encoded = base64::engine::general_purpose::STANDARD.encode(&binary);
    let value = serde_json::json!({
        "status": 200,
        "headers": {"content-type": "application/octet-stream"},
        "body": encoded,
        "body_encoding": "base64",
    });
    let response = decode_response(value).unwrap();
    let body_bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
        .await
        .unwrap();
    assert_eq!(body_bytes.as_ref(), &binary);
}

#[tokio::test]
async fn request_body_limit_enforced() {
    let big_body = "x".repeat(1024);
    let req = Request::builder()
        .method("POST")
        .uri("/upload")
        .body(Body::from(big_body))
        .unwrap();

    // Limit to 512 bytes — should fail
    let result = encode_request(req, 512).await;
    assert!(result.is_err());
}

#[test]
fn config_defaults() {
    let config = HttpConfig::default();
    assert_eq!(config.listen.to_string(), "0.0.0.0:8080");
    assert_eq!(config.read_timeout.as_secs(), 10);
    assert_eq!(config.write_timeout.as_secs(), 30);
    assert_eq!(config.max_request_size, 10 * 1024 * 1024);
    assert!(!config.access_log);
    assert!(config.trusted_proxies.is_empty());
}

#[test]
fn config_deserialize_with_new_fields() {
    let toml = r#"
        listen = "127.0.0.1:9090"
        read_timeout = "5s"
        write_timeout = "15s"
        max_request_size = "5mb"
        access_log = true
        trusted_proxies = ["10.0.0.0/8", "172.16.0.0/12"]
    "#;
    let config: HttpConfig = toml::from_str(toml).unwrap();
    assert_eq!(config.listen.to_string(), "127.0.0.1:9090");
    assert_eq!(config.read_timeout.as_secs(), 5);
    assert_eq!(config.max_request_size, 5 * 1024 * 1024);
    assert!(config.access_log);
    assert_eq!(config.trusted_proxies.len(), 2);
    assert_eq!(config.trusted_proxies[0].to_string(), "10.0.0.0/8");
}

#[test]
fn config_deserialize_minimal() {
    // Only listen — everything else should use defaults
    let toml = r#"listen = "0.0.0.0:3000""#;
    let config: HttpConfig = toml::from_str(toml).unwrap();
    assert_eq!(config.listen.to_string(), "0.0.0.0:3000");
    assert_eq!(config.max_request_size, 10 * 1024 * 1024);
    assert!(!config.access_log);
}

// --- TLS + h2c config tests ---

#[test]
fn config_tls_parsing() {
    let toml = r#"
        listen = "0.0.0.0:443"
        [tls]
        cert = "/etc/ssl/cert.pem"
        key = "/etc/ssl/key.pem"
    "#;
    let config: HttpConfig = toml::from_str(toml).unwrap();
    let tls = config.tls.unwrap();
    assert_eq!(tls.cert.to_str().unwrap(), "/etc/ssl/cert.pem");
    assert_eq!(tls.key.to_str().unwrap(), "/etc/ssl/key.pem");
}

#[test]
fn config_no_tls_by_default() {
    let config = HttpConfig::default();
    assert!(config.tls.is_none());
    assert!(!config.h2c);
}

#[test]
fn config_h2c_parsing() {
    let toml = r#"h2c = true"#;
    let config: HttpConfig = toml::from_str(toml).unwrap();
    assert!(config.h2c);
}

// --- compression config tests ---

#[test]
fn config_compression_defaults() {
    let config = HttpConfig::default();
    assert!(!config.compression.enabled);
    assert_eq!(config.compression.algorithms.len(), 3);
    assert_eq!(config.compression.min_size, 256);
}

#[test]
fn config_compression_parsing() {
    let toml = r#"
        [compression]
        enabled = true
        algorithms = ["br", "zstd"]
        min_size = 1024
    "#;
    let config: HttpConfig = toml::from_str(toml).unwrap();
    assert!(config.compression.enabled);
    assert_eq!(config.compression.algorithms.len(), 2);
    assert_eq!(
        config.compression.algorithms[0],
        folk_plugin_http::config::CompressionAlgorithm::Br
    );
    assert_eq!(config.compression.min_size, 1024);
}

#[test]
fn config_compression_min_size_human_readable() {
    let toml = r#"
        [compression]
        enabled = true
        min_size = "4kb"
    "#;
    let config: HttpConfig = toml::from_str(toml).unwrap();
    assert_eq!(config.compression.min_size, 4096);
}

// --- byte size parsing tests ---

#[test]
fn parse_byte_size_strings() {
    use folk_plugin_http::config::parse_byte_size;
    assert_eq!(parse_byte_size("10mb").unwrap(), 10 * 1024 * 1024);
    assert_eq!(parse_byte_size("512kb").unwrap(), 512 * 1024);
    assert_eq!(parse_byte_size("1gb").unwrap(), 1024 * 1024 * 1024);
    assert_eq!(parse_byte_size("256b").unwrap(), 256);
    assert_eq!(parse_byte_size("10MiB").unwrap(), 10 * 1024 * 1024);
    assert_eq!(parse_byte_size("1024").unwrap(), 1024);
    assert!(parse_byte_size("abc").is_err());
    assert!(parse_byte_size("mb").is_err());
}

#[test]
fn config_deserialize_max_request_size_as_integer() {
    let toml = r#"max_request_size = 2097152"#;
    let config: HttpConfig = toml::from_str(toml).unwrap();
    assert_eq!(config.max_request_size, 2_097_152);
}

// --- trusted proxies tests ---

fn parse_nets(cidrs: &[&str]) -> Vec<ipnet::IpNet> {
    cidrs.iter().map(|s| s.parse().unwrap()).collect()
}

#[test]
fn trusted_proxies_empty_returns_peer() {
    let peer: IpAddr = "1.2.3.4".parse().unwrap();
    let result = resolve_client_ip(peer, Some("5.6.7.8"), &[]);
    assert_eq!(result, peer);
}

#[test]
fn trusted_proxies_peer_not_trusted_returns_peer() {
    let peer: IpAddr = "1.2.3.4".parse().unwrap();
    let trusted = parse_nets(&["10.0.0.0/8"]);
    let result = resolve_client_ip(peer, Some("5.6.7.8"), &trusted);
    assert_eq!(result, peer);
}

#[test]
fn trusted_proxies_extracts_rightmost_non_trusted() {
    let peer: IpAddr = "10.0.0.1".parse().unwrap(); // trusted proxy
    let trusted = parse_nets(&["10.0.0.0/8"]);
    let result = resolve_client_ip(peer, Some("1.2.3.4, 10.0.0.2"), &trusted);
    // 10.0.0.2 is trusted, 1.2.3.4 is not → client is 1.2.3.4
    assert_eq!(result, "1.2.3.4".parse::<IpAddr>().unwrap());
}

#[test]
fn trusted_proxies_single_xff() {
    let peer: IpAddr = "10.0.0.1".parse().unwrap();
    let trusted = parse_nets(&["10.0.0.0/8"]);
    let result = resolve_client_ip(peer, Some("203.0.113.50"), &trusted);
    assert_eq!(result, "203.0.113.50".parse::<IpAddr>().unwrap());
}

#[test]
fn trusted_proxies_all_trusted_returns_peer() {
    let peer: IpAddr = "10.0.0.1".parse().unwrap();
    let trusted = parse_nets(&["10.0.0.0/8", "172.16.0.0/12"]);
    let result = resolve_client_ip(peer, Some("10.0.0.2, 172.16.0.1"), &trusted);
    assert_eq!(result, peer);
}

#[test]
fn trusted_proxies_no_xff_header() {
    let peer: IpAddr = "10.0.0.1".parse().unwrap();
    let trusted = parse_nets(&["10.0.0.0/8"]);
    let result = resolve_client_ip(peer, None, &trusted);
    assert_eq!(result, peer);
}

#[tokio::test]
#[ignore = "requires php + msgpack"]
async fn http_plugin_serves_real_request() {
    todo!("requires full Folk server with PHP workers")
}