cellos-export-s3 0.5.1

S3 ExportSink for CellOS — uploads per-cell evidence bundles to an S3 bucket for centralised audit retention.
Documentation
// clippy: these assert! calls are intentional contract pins on constants.
#![allow(clippy::assertions_on_constants)]
//! EXPORT-S3-TIMEOUT — pin the timeout enforcement contract for `cellos-export-s3`.
//!
//! These tests assert the **policy**, not network behaviour:
//!
//! 1. The default request and connect timeouts are bounded (non-zero, finite).
//! 2. Operators can override either timeout via the documented env var.
//! 3. Garbage / zero env-var values fall back to the bounded default
//!    (a misconfigured operator never accidentally disables the timeout).
//!
//! Constraint: no in-process HTTP server, no `httpmock` / `wiremock` dev-deps.
//! The crate exposes `resolve_timeout_ms` as a pure config-resolution seam,
//! and `PresignedS3ExportSink::new` is exercised to confirm a client builds
//! with the timeout knobs honored end-to-end.

use std::sync::Mutex;

use cellos_export_s3::{
    resolve_timeout_ms, PresignedS3ExportSink, DEFAULT_CONNECT_TIMEOUT_MS,
    DEFAULT_REQUEST_TIMEOUT_MS, ENV_CONNECT_TIMEOUT_MS, ENV_REQUEST_TIMEOUT_MS,
};

/// `resolve_timeout_ms` reads process env vars; serialize to keep parallel
/// tests from racing each other's `set_var` / `remove_var` calls.
static ENV_LOCK: Mutex<()> = Mutex::new(());

fn lock() -> std::sync::MutexGuard<'static, ()> {
    ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}

/// The compiled-in defaults must be bounded — never zero, never near-infinite.
/// 5 minutes is the upper bound: any default longer than that means a stalled
/// endpoint can hold the export phase open beyond a reasonable cell budget.
#[test]
fn defaults_are_bounded_and_sane() {
    assert!(
        DEFAULT_REQUEST_TIMEOUT_MS > 0,
        "default request timeout must be > 0, got {DEFAULT_REQUEST_TIMEOUT_MS}"
    );
    assert!(
        DEFAULT_REQUEST_TIMEOUT_MS <= 5 * 60 * 1000,
        "default request timeout must be <= 5min, got {DEFAULT_REQUEST_TIMEOUT_MS}ms"
    );
    assert!(
        DEFAULT_CONNECT_TIMEOUT_MS > 0,
        "default connect timeout must be > 0, got {DEFAULT_CONNECT_TIMEOUT_MS}"
    );
    assert!(
        DEFAULT_CONNECT_TIMEOUT_MS <= 60 * 1000,
        "default connect timeout must be <= 60s, got {DEFAULT_CONNECT_TIMEOUT_MS}ms"
    );
    assert!(
        DEFAULT_CONNECT_TIMEOUT_MS <= DEFAULT_REQUEST_TIMEOUT_MS,
        "connect timeout ({DEFAULT_CONNECT_TIMEOUT_MS}) must not exceed request timeout ({DEFAULT_REQUEST_TIMEOUT_MS})"
    );
}

/// When the env var is unset, `resolve_timeout_ms` returns the bounded default.
#[test]
fn unset_env_var_falls_back_to_default() {
    let _g = lock();
    std::env::remove_var(ENV_REQUEST_TIMEOUT_MS);
    std::env::remove_var(ENV_CONNECT_TIMEOUT_MS);

    assert_eq!(
        resolve_timeout_ms(ENV_REQUEST_TIMEOUT_MS, DEFAULT_REQUEST_TIMEOUT_MS),
        DEFAULT_REQUEST_TIMEOUT_MS
    );
    assert_eq!(
        resolve_timeout_ms(ENV_CONNECT_TIMEOUT_MS, DEFAULT_CONNECT_TIMEOUT_MS),
        DEFAULT_CONNECT_TIMEOUT_MS
    );
}

/// A valid positive override is honored.
#[test]
fn valid_override_is_honored() {
    let _g = lock();
    std::env::set_var(ENV_REQUEST_TIMEOUT_MS, "12345");
    let resolved = resolve_timeout_ms(ENV_REQUEST_TIMEOUT_MS, DEFAULT_REQUEST_TIMEOUT_MS);
    std::env::remove_var(ENV_REQUEST_TIMEOUT_MS);
    assert_eq!(resolved, 12_345);
}

/// `0` is rejected (would disable the timeout) and falls back to the default.
#[test]
fn zero_override_falls_back_to_default() {
    let _g = lock();
    std::env::set_var(ENV_REQUEST_TIMEOUT_MS, "0");
    let resolved = resolve_timeout_ms(ENV_REQUEST_TIMEOUT_MS, DEFAULT_REQUEST_TIMEOUT_MS);
    std::env::remove_var(ENV_REQUEST_TIMEOUT_MS);
    assert_eq!(
        resolved, DEFAULT_REQUEST_TIMEOUT_MS,
        "zero must not disable the timeout; expected fallback to default"
    );
}

/// Garbage values fall back to the default — operators never silently lose
/// the timeout because of a typo.
#[test]
fn garbage_override_falls_back_to_default() {
    let _g = lock();
    for garbage in [
        "",
        "   ",
        "not-a-number",
        "-5",
        "12.5",
        "9999999999999999999999",
    ] {
        std::env::set_var(ENV_CONNECT_TIMEOUT_MS, garbage);
        let resolved = resolve_timeout_ms(ENV_CONNECT_TIMEOUT_MS, DEFAULT_CONNECT_TIMEOUT_MS);
        std::env::remove_var(ENV_CONNECT_TIMEOUT_MS);
        assert_eq!(
            resolved, DEFAULT_CONNECT_TIMEOUT_MS,
            "garbage env value {garbage:?} must fall back to default"
        );
    }
}

/// End-to-end: `PresignedS3ExportSink::new` succeeds whether or not timeout
/// overrides are set. This catches regressions where adding `.timeout()`
/// accidentally breaks the client builder under custom env config.
#[test]
fn sink_constructs_with_default_and_overridden_timeouts() {
    let _g = lock();
    // Clear CA bundle so test does not pick up a host PEM file.
    std::env::remove_var("CELLOS_CA_BUNDLE");

    // Default path (no env overrides).
    std::env::remove_var(ENV_REQUEST_TIMEOUT_MS);
    std::env::remove_var(ENV_CONNECT_TIMEOUT_MS);
    let sink = PresignedS3ExportSink::new(
        "https://bucket.s3.amazonaws.com/object.txt?X-Amz-Signature=abc",
        "c1",
        "bucket",
        None,
        None,
        None,
        1,
        0,
    );
    assert!(
        sink.is_ok(),
        "sink must construct under default timeout config: {:?}",
        sink.err()
    );

    // Overridden path.
    std::env::set_var(ENV_REQUEST_TIMEOUT_MS, "5000");
    std::env::set_var(ENV_CONNECT_TIMEOUT_MS, "1500");
    let sink = PresignedS3ExportSink::new(
        "https://bucket.s3.amazonaws.com/object.txt?X-Amz-Signature=abc",
        "c1",
        "bucket",
        None,
        None,
        None,
        1,
        0,
    );
    std::env::remove_var(ENV_REQUEST_TIMEOUT_MS);
    std::env::remove_var(ENV_CONNECT_TIMEOUT_MS);
    assert!(
        sink.is_ok(),
        "sink must construct under overridden timeout config: {:?}",
        sink.err()
    );
}