#![cfg(feature = "cloud-http")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use oxigdal_streaming::cloud::retry::RetryPolicy;
use oxigdal_streaming::cloud::{CloudCredentials, CloudError, HttpObjectStore, ObjectUrl};
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
fn spawn_mock_server_static(max_connections: usize, response_bytes: &'static [u8]) -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind to loopback");
let port = listener.local_addr().expect("local_addr").port();
thread::spawn(move || {
for stream in listener.incoming().take(max_connections) {
thread::spawn(move || {
let mut stream = stream.expect("accept tcp stream");
stream
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.ok();
let mut buf = [0u8; 8192];
let _ = stream.read(&mut buf).unwrap_or(0);
stream.write_all(response_bytes).ok();
});
}
});
port
}
fn spawn_mock_server_capturing(
max_connections: usize,
response_bytes: &'static [u8],
captured: Arc<Mutex<Vec<u8>>>,
) -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind to loopback");
let port = listener.local_addr().expect("local_addr").port();
thread::spawn(move || {
for stream in listener.incoming().take(max_connections) {
let captured_clone = Arc::clone(&captured);
thread::spawn(move || {
let mut stream = stream.expect("accept tcp stream");
stream
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.ok();
let mut buf = [0u8; 8192];
let n = stream.read(&mut buf).unwrap_or(0);
{
let mut guard = captured_clone.lock().expect("lock captured buffer");
guard.extend_from_slice(&buf[..n]);
}
stream.write_all(response_bytes).ok();
});
}
});
port
}
fn spawn_mock_server_sequence(responses: Vec<&'static [u8]>) -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind to loopback");
let port = listener.local_addr().expect("local_addr").port();
let call_count = Arc::new(AtomicUsize::new(0));
thread::spawn(move || {
let max = responses.len();
for stream in listener.incoming().take(max) {
let idx = call_count.fetch_add(1, Ordering::SeqCst);
let resp = responses[idx.min(max - 1)];
thread::spawn(move || {
let mut stream = stream.expect("accept");
stream
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.ok();
let mut buf = [0u8; 8192];
let _ = stream.read(&mut buf).unwrap_or(0);
stream.write_all(resp).ok();
});
}
});
port
}
fn mock_url(port: u16, path: &str) -> ObjectUrl {
let raw = format!("http://127.0.0.1:{port}/{path}");
ObjectUrl::parse(&raw).expect("parse mock url")
}
#[tokio::test]
async fn test_http_object_store_get_range_returns_correct_bytes() {
const BODY: &[u8] = b"partial-data-xyz";
static RESPONSE_206: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
Content-Type: application/octet-stream\r\n\
Content-Length: 16\r\n\
Content-Range: bytes 0-15/1000\r\n\
\r\n\
partial-data-xyz";
let port = spawn_mock_server_static(2, RESPONSE_206);
let url = mock_url(port, "object.bin");
let store = HttpObjectStore::with_retry(RetryPolicy::no_retry());
let result = store
.get_range(&url, 0, 15, &CloudCredentials::Anonymous)
.await
.expect("get_range should succeed on 206");
assert_eq!(
result.as_ref(),
BODY,
"body bytes should match the 206 payload"
);
}
#[tokio::test]
async fn test_http_object_store_get_object_full_body() {
const BODY: &[u8] = b"full-object-body-content";
static RESPONSE_200: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Type: application/octet-stream\r\n\
Content-Length: 24\r\n\
\r\n\
full-object-body-content";
let port = spawn_mock_server_static(2, RESPONSE_200);
let url = mock_url(port, "full.bin");
let store = HttpObjectStore::with_retry(RetryPolicy::no_retry());
let result = store
.get_object(&url, &CloudCredentials::Anonymous)
.await
.expect("get_object should succeed on 200");
assert_eq!(
result.as_ref(),
BODY,
"body bytes should match the 200 payload"
);
}
#[tokio::test]
async fn test_http_object_store_head_extracts_content_length() {
static RESPONSE_HEAD: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Length: 12345\r\n\
Content-Type: image/tiff\r\n\
ETag: \"deadbeef\"\r\n\
Last-Modified: Mon, 12 Jan 2026 00:00:00 GMT\r\n\
\r\n";
let port = spawn_mock_server_static(2, RESPONSE_HEAD);
let url = mock_url(port, "raster.tiff");
let store = HttpObjectStore::with_retry(RetryPolicy::no_retry());
let metadata = store
.head(&url, &CloudCredentials::Anonymous)
.await
.expect("head should succeed on 200");
assert_eq!(
metadata.size, 12_345,
"size should be extracted from Content-Length"
);
assert_eq!(
metadata.etag.as_deref(),
Some("\"deadbeef\""),
"etag should be extracted"
);
assert_eq!(
metadata.content_type.as_deref(),
Some("image/tiff"),
"content_type should be extracted"
);
assert!(
metadata.last_modified.is_some(),
"last_modified should be populated from Last-Modified header"
);
let ts = metadata.last_modified.expect("last_modified present");
assert!(ts > 1_577_836_800, "timestamp should be after 2020-01-01");
assert!(ts < 1_893_456_000, "timestamp should be before 2030-01-01");
}
#[tokio::test]
async fn test_http_object_store_429_retried() {
static RESPONSE_429: &[u8] = b"HTTP/1.1 429 Too Many Requests\r\n\
Content-Length: 0\r\n\
\r\n";
static RESPONSE_200: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Type: application/octet-stream\r\n\
Content-Length: 7\r\n\
\r\n\
success";
let port = spawn_mock_server_sequence(vec![RESPONSE_429, RESPONSE_200]);
let url = mock_url(port, "data.bin");
let store = HttpObjectStore::with_retry(RetryPolicy {
max_attempts: 2,
initial_delay_ms: 0, max_delay_ms: 0,
backoff_multiplier: 1.0,
jitter_factor: 0.0,
});
let result = store
.get_object(&url, &CloudCredentials::Anonymous)
.await
.expect("get_object should succeed after retry on 429");
assert_eq!(
result.as_ref(),
b"success",
"body should come from the retried 200 response"
);
}
#[tokio::test]
async fn test_http_object_store_anonymous_creds_sends_no_auth_header() {
static RESPONSE_200: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Length: 2\r\n\
\r\n\
OK";
let captured = Arc::new(Mutex::new(Vec::new()));
let port = spawn_mock_server_capturing(2, RESPONSE_200, Arc::clone(&captured));
let url = mock_url(port, "public.bin");
let store = HttpObjectStore::with_retry(RetryPolicy::no_retry());
store
.get_object(&url, &CloudCredentials::Anonymous)
.await
.expect("get_object should succeed");
std::thread::sleep(std::time::Duration::from_millis(50));
let raw_request = {
let guard = captured.lock().expect("lock captured buffer");
String::from_utf8_lossy(&guard).to_ascii_lowercase()
};
assert!(
!raw_request.contains("authorization"),
"Anonymous credentials must not produce an Authorization header; \
raw request was:\n{raw_request}"
);
}
#[tokio::test]
async fn test_http_object_store_bearer_creds_sends_auth_header() {
static RESPONSE_200: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Length: 2\r\n\
\r\n\
OK";
let captured = Arc::new(Mutex::new(Vec::new()));
let port = spawn_mock_server_capturing(2, RESPONSE_200, Arc::clone(&captured));
let url = mock_url(port, "private.bin");
let store = HttpObjectStore::with_retry(RetryPolicy::no_retry());
let creds = CloudCredentials::Bearer {
token: "my-secret-token".to_owned(),
};
store
.get_object(&url, &creds)
.await
.expect("get_object should succeed");
std::thread::sleep(std::time::Duration::from_millis(50));
let raw_request = {
let guard = captured.lock().expect("lock captured buffer");
String::from_utf8_lossy(&guard).to_ascii_lowercase()
};
assert!(
raw_request.contains("authorization: bearer my-secret-token"),
"Bearer credentials must produce an Authorization header; \
raw request was:\n{raw_request}"
);
}
#[tokio::test]
async fn test_http_object_store_403_not_retried() {
static RESPONSE_403: &[u8] = b"HTTP/1.1 403 Forbidden\r\n\
Content-Length: 0\r\n\
\r\n";
let port = spawn_mock_server_static(1, RESPONSE_403);
let url = mock_url(port, "private.bin");
let store = HttpObjectStore::with_retry(RetryPolicy::aggressive());
let err = store
.get_object(&url, &CloudCredentials::Anonymous)
.await
.expect_err("403 should be an error");
assert!(
matches!(err, CloudError::HttpError { status: 403, .. }),
"expected HttpError {{ status: 403 }}, got: {:?}",
err
);
}