#![allow(clippy::unwrap_used)] #![allow(missing_docs)] #![allow(clippy::indexing_slicing)]
use crate::backend::S3Backend;
fn skip_if_no_s3() -> Option<()> {
if std::env::var("S3_ENDPOINT").is_err() && std::env::var("AWS_ENDPOINT_URL").is_err() {
return None;
}
Some(())
}
fn create_test_backend() -> S3Backend {
let endpoint = std::env::var("S3_ENDPOINT").or_else(|_| std::env::var("AWS_ENDPOINT_URL")).ok();
let bucket = format!("test-{}", uuid::Uuid::new_v4());
let rt = tokio::runtime::Runtime::new().expect("create tokio runtime");
rt.block_on(async { S3Backend::new(&bucket, None, endpoint.as_deref()).await })
}
#[test]
fn test_s3_backend_struct_creation() {
let _backend = create_test_backend();
}
#[test]
#[ignore = "requires MinIO to be running"]
fn test_s3_put_and_get_roundtrip() {
let Some(()) = skip_if_no_s3() else {
return;
};
let backend = create_test_backend();
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let key = "test-file.txt";
let data = b"Hello, S3 world!";
let content_type = "text/plain";
let result = backend.upload(key, data, content_type).await;
assert!(result.is_ok(), "upload should succeed");
assert_eq!(result.unwrap(), key);
let downloaded = backend.download(key).await;
assert!(downloaded.is_ok(), "download should succeed");
assert_eq!(downloaded.unwrap(), data);
});
}
#[test]
#[ignore = "requires MinIO to be running"]
fn test_s3_get_nonexistent_returns_not_found() {
let Some(()) = skip_if_no_s3() else {
return;
};
let backend = create_test_backend();
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let result = backend.download("nonexistent-key.txt").await;
assert!(result.is_err(), "download of nonexistent key should fail");
let err = result.unwrap_err();
let err_msg = err.to_string();
assert!(
err_msg.contains("not found") || err_msg.contains("404"),
"error should indicate not found: {}",
err_msg
);
});
}
#[test]
#[ignore = "requires MinIO to be running"]
fn test_s3_delete_removes_object() {
let Some(()) = skip_if_no_s3() else {
return;
};
let backend = create_test_backend();
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let key = "to-delete.txt";
let data = b"temporary file";
backend.upload(key, data, "text/plain").await.expect("upload succeeds");
let exists = backend.exists(key).await.expect("exists check succeeds");
assert!(exists, "file should exist after upload");
let delete_result = backend.delete(key).await;
assert!(delete_result.is_ok(), "delete should succeed");
let exists_after = backend.exists(key).await.expect("exists check succeeds");
assert!(!exists_after, "file should not exist after delete");
});
}
#[test]
#[ignore = "requires MinIO to be running"]
fn test_s3_list_with_prefix() {
let Some(()) = skip_if_no_s3() else {
return;
};
let backend = create_test_backend();
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
backend
.upload("avatars/user1.jpg", b"avatar 1", "image/jpeg")
.await
.expect("upload 1");
backend
.upload("avatars/user2.jpg", b"avatar 2", "image/jpeg")
.await
.expect("upload 2");
backend
.upload("documents/doc.pdf", b"pdf content", "application/pdf")
.await
.expect("upload 3");
let result = backend.list("avatars/", None, 100).await.expect("list succeeds");
assert_eq!(result.objects.len(), 2, "should have 2 items under avatars/");
assert!(
result.objects.iter().any(|o| o.key == "avatars/user1.jpg"),
"should include avatars/user1.jpg"
);
assert!(
result.objects.iter().any(|o| o.key == "avatars/user2.jpg"),
"should include avatars/user2.jpg"
);
});
}
#[test]
#[ignore = "requires MinIO to be running"]
fn test_s3_list_pagination() {
let Some(()) = skip_if_no_s3() else {
return;
};
let backend = create_test_backend();
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
for i in 0..5 {
let key = format!("file{:02}.txt", i);
backend
.upload(&key, format!("data {}", i).as_bytes(), "text/plain")
.await
.expect("upload succeeds");
}
let page1 = backend.list("", None, 2).await.expect("list page 1 succeeds");
assert_eq!(page1.objects.len(), 2, "first page should have 2 items");
let cursor1 = page1.next_cursor.expect("first page should have cursor");
let page2 = backend.list("", Some(&cursor1), 2).await.expect("list page 2 succeeds");
assert_eq!(page2.objects.len(), 2, "second page should have 2 items");
assert_ne!(
page1.objects[0].key, page2.objects[0].key,
"pages should have different objects"
);
let cursor2 = page2.next_cursor.expect("second page should have cursor");
let page3 = backend.list("", Some(&cursor2), 2).await.expect("list page 3 succeeds");
assert_eq!(page3.objects.len(), 1, "third page should have 1 item");
assert!(page3.next_cursor.is_none(), "last page should have no cursor");
});
}
#[test]
#[ignore = "requires MinIO to be running"]
fn test_s3_exists_true_and_false() {
let Some(()) = skip_if_no_s3() else {
return;
};
let backend = create_test_backend();
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let key = "existence-test.txt";
let exists_before = backend.exists(key).await.expect("exists check succeeds");
assert!(!exists_before, "should not exist before upload");
backend.upload(key, b"test", "text/plain").await.expect("upload succeeds");
let exists_after = backend.exists(key).await.expect("exists check succeeds");
assert!(exists_after, "should exist after upload");
let not_exist = backend
.exists("definitely-does-not-exist.txt")
.await
.expect("exists check on non-existent key should not error");
assert!(!not_exist, "non-existent key should return false");
});
}
#[test]
#[ignore = "requires MinIO to be running"]
fn test_s3_large_object_streaming() {
let Some(()) = skip_if_no_s3() else {
return;
};
let backend = create_test_backend();
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let large_data = vec![42u8; 10 * 1024 * 1024];
let key = "large-file.bin";
let upload_result = backend.upload(key, &large_data, "application/octet-stream").await;
assert!(upload_result.is_ok(), "large upload should succeed");
let downloaded = backend.download(key).await.expect("large download should succeed");
assert_eq!(
downloaded.len(),
large_data.len(),
"downloaded size should match uploaded size"
);
assert_eq!(downloaded, large_data, "downloaded content should match uploaded content");
});
}
#[test]
fn test_s3_key_validation() {
let backend = create_test_backend();
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let result = backend.upload("", b"data", "text/plain").await;
assert!(result.is_err(), "empty key should be rejected");
let result = backend.upload("../etc/passwd", b"data", "text/plain").await;
assert!(result.is_err(), "path traversal should be rejected");
let result = backend.upload("/etc/passwd", b"data", "text/plain").await;
assert!(result.is_err(), "absolute path should be rejected");
});
}
#[test]
#[ignore = "requires MinIO to be running"]
fn test_s3_presign_upload_returns_valid_url() {
let Some(()) = skip_if_no_s3() else {
return;
};
let backend = create_test_backend();
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let key = "presign-upload-test.txt";
let presigned_result =
backend.presigned_url(key, std::time::Duration::from_secs(3600)).await;
assert!(presigned_result.is_ok(), "presigned URL generation should succeed");
let url = presigned_result.unwrap();
assert!(
url.starts_with("http://") || url.starts_with("https://"),
"presigned URL should be a valid HTTP URL"
);
assert!(
url.contains(key) || url.contains("presign-upload-test"),
"presigned URL should contain the key"
);
});
}
#[test]
#[ignore = "requires MinIO to be running"]
fn test_s3_presign_download_returns_valid_url() {
let Some(()) = skip_if_no_s3() else {
return;
};
let backend = create_test_backend();
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let key = "presign-download-test.txt";
let data = b"presigned download content";
backend.upload(key, data, "text/plain").await.expect("upload succeeds");
let presigned_result =
backend.presigned_url(key, std::time::Duration::from_secs(3600)).await;
assert!(presigned_result.is_ok(), "presigned URL generation should succeed");
let url = presigned_result.unwrap();
assert!(
url.starts_with("http://") || url.starts_with("https://"),
"presigned URL should be a valid HTTP URL"
);
});
}
#[test]
#[ignore = "requires MinIO to be running"]
fn test_s3_presign_url_expires() {
let Some(()) = skip_if_no_s3() else {
return;
};
let backend = create_test_backend();
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let key = "presign-expire-test.txt";
let presigned_result = backend.presigned_url(key, std::time::Duration::from_secs(1)).await;
assert!(presigned_result.is_ok(), "presigned URL generation should succeed");
let url = presigned_result.unwrap();
assert!(!url.is_empty(), "presigned URL should not be empty");
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
assert!(
url.contains("X-Amz-Expires") || url.contains("expires"),
"presigned URL should contain expiry information"
);
});
}
#[test]
fn test_s3_presign_rejects_invalid_keys() {
let backend = create_test_backend();
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let result = backend
.presigned_url("../etc/passwd", std::time::Duration::from_secs(3600))
.await;
assert!(result.is_err(), "path traversal should be rejected");
let result = backend.presigned_url("", std::time::Duration::from_secs(3600)).await;
assert!(result.is_err(), "empty key should be rejected");
});
}
#[test]
#[ignore = "requires MinIO to be running"]
fn test_s3_presign_respects_expiry() {
let Some(()) = skip_if_no_s3() else {
return;
};
let backend = create_test_backend();
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let key = "presign-ttl-test.txt";
let short_ttl = std::time::Duration::from_secs(60);
let long_ttl = std::time::Duration::from_secs(3600);
let short_url = backend
.presigned_url(key, short_ttl)
.await
.expect("short TTL presigned URL should succeed");
let long_url = backend
.presigned_url(key, long_ttl)
.await
.expect("long TTL presigned URL should succeed");
assert!(!short_url.is_empty(), "short TTL URL should not be empty");
assert!(!long_url.is_empty(), "long TTL URL should not be empty");
assert_ne!(short_url, long_url, "URLs with different TTLs should be different");
});
}