rskit-http 0.2.0-alpha.1

Framework-neutral HTTP abstractions and Tower adapters for rskit transport
Documentation
#![allow(missing_docs)]

use std::time::Duration;

use http::{Extensions, HeaderMap, HeaderValue, Method, Request, Response, StatusCode, header};
use rskit_errors::{AppError, ErrorCode};
use rskit_http::{
    CorrelationId, CorsPolicy, HttpRequest, HttpResponse, RequestId, SecurityHeadersConfig,
    SecurityHeadersLayer, TenantConfig, TransportSecurity, app_error_status,
    correlation_id_from_extensions, is_success_status, request_id_from_extensions,
    set_correlation_id, set_request_id, set_tenant_in_extensions, status_to_error_code,
    tenant_from_extensions,
};
use tower::{Layer, ServiceExt, service_fn};

#[test]
fn request_and_correlation_ids_prefer_valid_headers_and_fallback_to_uuid() {
    let mut headers = HeaderMap::new();
    headers.insert("x-request-id", HeaderValue::from_static("req-123"));
    headers.insert("x-correlation-id", HeaderValue::from_static("corr-456"));
    assert_eq!(RequestId::from_headers(&headers).0, "req-123");
    assert_eq!(CorrelationId::from_headers(&headers).0, "corr-456");

    let generated = RequestId::from_headers(&HeaderMap::new()).0;
    assert_eq!(generated.len(), 36);

    let mut extensions = Extensions::new();
    set_request_id(&mut extensions, "req-ext");
    set_correlation_id(&mut extensions, "corr-ext");
    assert_eq!(
        request_id_from_extensions(&extensions).unwrap().0,
        "req-ext"
    );
    assert_eq!(
        correlation_id_from_extensions(&extensions).unwrap().0,
        "corr-ext"
    );
}

#[test]
fn tenant_config_reads_headers_fallbacks_and_extensions() {
    let mut headers = HeaderMap::new();
    headers.insert("x-org", HeaderValue::from_static("org-1"));
    let config = TenantConfig {
        header_name: "x-org".to_owned(),
        required: true,
        fallback: Some("fallback".to_owned()),
    };
    assert!(config.is_required());
    assert_eq!(config.tenant_from_headers(&headers).unwrap().0, "org-1");
    assert_eq!(
        config.tenant_from_headers(&HeaderMap::new()).unwrap().0,
        "fallback"
    );

    let mut extensions = Extensions::new();
    set_tenant_in_extensions(&mut extensions, "tenant-ext");
    assert_eq!(tenant_from_extensions(&extensions).unwrap().0, "tenant-ext");
}

#[test]
fn cors_policy_accepts_explicit_origins_and_rejects_unsafe_shapes() {
    let valid = CorsPolicy {
        allowed_origins: vec!["https://app.example.com".to_owned()],
        allowed_methods: vec![
            Method::GET.as_str().to_owned(),
            Method::POST.as_str().to_owned(),
        ],
        allowed_headers: vec!["authorization".to_owned(), "content-type".to_owned()],
        allow_credentials: true,
        max_age: Duration::from_secs(600),
    };
    valid.validate().unwrap();
    let _ = valid.layer().unwrap();

    for origin in [
        "*",
        "ftp://example.com",
        "https://user@example.com",
        "https://example.com/path",
        "https://example.com?x=1",
        "https://example.com#frag",
    ] {
        let policy = CorsPolicy {
            allowed_origins: vec![origin.to_owned()],
            ..valid.clone()
        };
        assert!(
            policy.validate().is_err(),
            "origin {origin} should be rejected"
        );
    }

    let credentials_without_origins = CorsPolicy {
        allow_credentials: true,
        ..CorsPolicy::default()
    };
    assert!(credentials_without_origins.validate().is_err());
}

#[test]
fn status_helpers_cover_success_error_and_fallback_mappings() {
    assert!(is_success_status(StatusCode::NO_CONTENT));
    assert_eq!(
        status_to_error_code(StatusCode::UNAUTHORIZED),
        ErrorCode::Unauthorized
    );
    assert_eq!(
        status_to_error_code(StatusCode::FORBIDDEN),
        ErrorCode::Forbidden
    );
    assert_eq!(
        status_to_error_code(StatusCode::CONFLICT),
        ErrorCode::Conflict
    );
    assert_eq!(
        status_to_error_code(StatusCode::REQUEST_TIMEOUT),
        ErrorCode::Cancelled
    );
    assert_eq!(
        status_to_error_code(StatusCode::IM_A_TEAPOT),
        ErrorCode::ExternalService
    );
    assert_eq!(status_to_error_code(StatusCode::OK), ErrorCode::Internal);

    let error = AppError::new(ErrorCode::RateLimited, "slow down");
    assert_eq!(app_error_status(&error), StatusCode::TOO_MANY_REQUESTS);

    let request: HttpRequest = Request::builder().uri("/ready").body(Vec::new()).unwrap();
    let response: HttpResponse = Response::builder()
        .status(StatusCode::ACCEPTED)
        .body(Vec::new())
        .unwrap();
    assert_eq!(request.uri(), "/ready");
    assert_eq!(response.status(), StatusCode::ACCEPTED);
}

#[tokio::test]
async fn security_headers_layer_inserts_defaults_without_overwriting_existing_values() {
    let layer = SecurityHeadersLayer::new(
        &SecurityHeadersConfig::default().with_transport_security(TransportSecurity::HttpsOnly),
    )
    .unwrap();
    let service = layer.layer(service_fn(|_req: Request<()>| async {
        let mut response = Response::builder().status(StatusCode::OK).body(()).unwrap();
        response.headers_mut().insert(
            header::X_FRAME_OPTIONS,
            HeaderValue::from_static("SAMEORIGIN"),
        );
        Ok::<_, std::convert::Infallible>(response)
    }));

    let response = service.oneshot(Request::new(())).await.unwrap();
    assert_eq!(
        response.headers().get(header::X_FRAME_OPTIONS).unwrap(),
        "SAMEORIGIN"
    );
    assert!(
        response
            .headers()
            .contains_key(header::STRICT_TRANSPORT_SECURITY)
    );
    assert!(
        response
            .headers()
            .contains_key(header::X_CONTENT_TYPE_OPTIONS)
    );
}