Files
rustfs/rustfs/src/storage/ecfs_test.rs
T

1382 lines
61 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
#[cfg(test)]
mod tests {
use crate::config::WorkloadProfile;
use crate::server::cors;
use crate::storage::ecfs::FS;
use crate::storage::s3_api::common::{rustfs_initiator, rustfs_owner};
use crate::storage::{
apply_cors_headers, check_preconditions, get_adaptive_buffer_size_with_profile, get_buffer_size_opt_in, is_etag_equal,
matches_origin_pattern, parse_etag, parse_object_lock_legal_hold, parse_object_lock_retention,
process_lambda_configurations, process_queue_configurations, process_topic_configurations,
validate_bucket_object_lock_enabled, validate_list_object_unordered_with_delimiter,
};
use http::{HeaderMap, HeaderValue, StatusCode};
use rustfs_config::MI_B;
use rustfs_ecstore::bucket::{metadata::BucketMetadata, metadata_sys};
use rustfs_ecstore::set_disk::DEFAULT_READ_BUFFER_SIZE;
use rustfs_ecstore::store_api::ObjectInfo;
use rustfs_utils::http::{
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP,
contains_key_str,
};
use rustfs_zip::CompressionFormat;
use s3s::dto::{
CORSConfiguration, CORSRule, Delimiter, LambdaFunctionConfiguration, ObjectLockLegalHold, ObjectLockLegalHoldStatus,
ObjectLockRetention, ObjectLockRetentionMode, QueueConfiguration, TopicConfiguration,
};
use s3s::{S3Error, S3ErrorCode, s3_error};
use time::OffsetDateTime;
#[test]
fn test_fs_creation() {
let _fs = FS::new();
// Verify that FS struct can be created successfully
// Since it's currently empty, we just verify it doesn't panic
// The test passes if we reach this point without panicking
}
#[test]
fn test_fs_debug_implementation() {
let fs = FS::new();
// Test that Debug trait is properly implemented
let debug_str = format!("{fs:?}");
assert!(debug_str.contains("FS"));
}
#[test]
fn test_fs_clone_implementation() {
let fs = FS::new();
// Test that Clone trait is properly implemented
let cloned_fs = fs.clone();
// Both should be equivalent (since FS is currently empty)
assert_eq!(format!("{fs:?}"), format!("{cloned_fs:?}"));
}
#[test]
fn test_rustfs_owner_helpers_are_stable() {
let owner = rustfs_owner();
let initiator = rustfs_initiator();
assert!(!owner.display_name.as_ref().unwrap().is_empty());
assert!(!owner.id.as_ref().unwrap().is_empty());
assert_eq!(owner.display_name.as_deref(), Some("rustfs"));
assert_eq!(initiator.display_name, owner.display_name);
assert_eq!(initiator.id, owner.id);
}
// Note: Most S3 API methods require complex setup with global state, storage backend,
// and various dependencies that make unit testing challenging. For comprehensive testing
// of S3 operations, integration tests would be more appropriate.
#[test]
fn test_list_objects_v2_key_count_includes_prefixes() {
// Test that KeyCount calculation includes both objects and common prefixes
// This verifies the fix for S3 API compatibility where KeyCount should equal
// the sum of Contents and CommonPrefixes lengths
// Simulate the calculation logic from list_objects_v2
let objects_count = 3_usize;
let common_prefixes_count = 2_usize;
// KeyCount should include both objects and common prefixes per S3 API spec
let key_count = (objects_count + common_prefixes_count) as i32;
assert_eq!(key_count, 5);
// Edge cases: verify calculation logic
let no_objects = 0_usize;
let no_prefixes = 0_usize;
assert_eq!((no_objects + no_prefixes) as i32, 0);
let one_object = 1_usize;
assert_eq!((one_object + no_prefixes) as i32, 1);
let one_prefix = 1_usize;
assert_eq!((no_objects + one_prefix) as i32, 1);
}
#[test]
fn test_s3_url_encoding_preserves_slash() {
// Test that S3 URL encoding preserves path separators (/)
// This verifies the encoding logic for EncodingType=url parameter
use urlencoding::encode;
// Helper function matching the implementation
let encode_s3_name = |name: &str| -> String {
name.split('/')
.map(|part| encode(part).to_string())
.collect::<Vec<_>>()
.join("/")
};
// Test cases from s3-tests
assert_eq!(encode_s3_name("asdf+b"), "asdf%2Bb");
assert_eq!(encode_s3_name("foo+1/bar"), "foo%2B1/bar");
assert_eq!(encode_s3_name("foo/"), "foo/");
assert_eq!(encode_s3_name("quux ab/"), "quux%20ab/");
// Edge cases
assert_eq!(encode_s3_name("normal/key"), "normal/key");
assert_eq!(encode_s3_name("key+with+plus"), "key%2Bwith%2Bplus");
assert_eq!(encode_s3_name("key with spaces"), "key%20with%20spaces");
}
#[test]
fn test_s3_error_scenarios() {
// Test that we can create expected S3 errors for common validation cases
// Test incomplete body error
let incomplete_body_error = s3_error!(IncompleteBody);
assert_eq!(incomplete_body_error.code(), &S3ErrorCode::IncompleteBody);
// Test invalid argument error
let invalid_arg_error = s3_error!(InvalidArgument, "test message");
assert_eq!(invalid_arg_error.code(), &S3ErrorCode::InvalidArgument);
// Test internal error
let internal_error = S3Error::with_message(S3ErrorCode::InternalError, "test".to_string());
assert_eq!(internal_error.code(), &S3ErrorCode::InternalError);
}
#[test]
fn test_compression_format_usage() {
// Test that compression format detection works for common file extensions
let zip_format = CompressionFormat::from_extension("zip");
assert_eq!(zip_format.extension(), "zip");
let tar_format = CompressionFormat::from_extension("tar");
assert_eq!(tar_format.extension(), "tar");
let gz_format = CompressionFormat::from_extension("gz");
assert_eq!(gz_format.extension(), "gz");
}
#[test]
fn test_adaptive_buffer_size_with_profile() {
const KB: i64 = 1024;
const MB: i64 = 1024 * 1024;
// Test GeneralPurpose profile (default behavior, should match get_adaptive_buffer_size)
assert_eq!(
get_adaptive_buffer_size_with_profile(500 * KB, Some(WorkloadProfile::GeneralPurpose)),
64 * KB as usize
);
assert_eq!(
get_adaptive_buffer_size_with_profile(50 * MB, Some(WorkloadProfile::GeneralPurpose)),
256 * KB as usize
);
assert_eq!(
get_adaptive_buffer_size_with_profile(200 * MB, Some(WorkloadProfile::GeneralPurpose)),
DEFAULT_READ_BUFFER_SIZE
);
// Test AiTraining profile - larger buffers for large files
assert_eq!(
get_adaptive_buffer_size_with_profile(5 * MB, Some(WorkloadProfile::AiTraining)),
512 * KB as usize
);
assert_eq!(
get_adaptive_buffer_size_with_profile(100 * MB, Some(WorkloadProfile::AiTraining)),
2 * MB as usize
);
assert_eq!(
get_adaptive_buffer_size_with_profile(600 * MB, Some(WorkloadProfile::AiTraining)),
4 * MB as usize
);
// Test WebWorkload profile - smaller buffers for web assets
assert_eq!(
get_adaptive_buffer_size_with_profile(100 * KB, Some(WorkloadProfile::WebWorkload)),
32 * KB as usize
);
assert_eq!(
get_adaptive_buffer_size_with_profile(5 * MB, Some(WorkloadProfile::WebWorkload)),
128 * KB as usize
);
assert_eq!(
get_adaptive_buffer_size_with_profile(50 * MB, Some(WorkloadProfile::WebWorkload)),
256 * KB as usize
);
// Test SecureStorage profile - memory-constrained buffers
assert_eq!(
get_adaptive_buffer_size_with_profile(500 * KB, Some(WorkloadProfile::SecureStorage)),
32 * KB as usize
);
assert_eq!(
get_adaptive_buffer_size_with_profile(25 * MB, Some(WorkloadProfile::SecureStorage)),
128 * KB as usize
);
assert_eq!(
get_adaptive_buffer_size_with_profile(100 * MB, Some(WorkloadProfile::SecureStorage)),
256 * KB as usize
);
// Test IndustrialIoT profile - low latency, moderate buffers
assert_eq!(
get_adaptive_buffer_size_with_profile(512 * KB, Some(WorkloadProfile::IndustrialIoT)),
64 * KB as usize
);
assert_eq!(
get_adaptive_buffer_size_with_profile(25 * MB, Some(WorkloadProfile::IndustrialIoT)),
256 * KB as usize
);
assert_eq!(
get_adaptive_buffer_size_with_profile(100 * MB, Some(WorkloadProfile::IndustrialIoT)),
512 * KB as usize
);
// Test DataAnalytics profile
assert_eq!(
get_adaptive_buffer_size_with_profile(2 * MB, Some(WorkloadProfile::DataAnalytics)),
128 * KB as usize
);
assert_eq!(
get_adaptive_buffer_size_with_profile(100 * MB, Some(WorkloadProfile::DataAnalytics)),
512 * KB as usize
);
assert_eq!(
get_adaptive_buffer_size_with_profile(500 * MB, Some(WorkloadProfile::DataAnalytics)),
2 * MB as usize
);
// Test with None (should auto-detect or use GeneralPurpose)
let result = get_adaptive_buffer_size_with_profile(50 * MB, None);
// Should be either SecureStorage (if on special OS) or GeneralPurpose
assert!(result == 128 * KB as usize || result == 256 * KB as usize);
// Test unknown file size with different profiles
assert_eq!(
get_adaptive_buffer_size_with_profile(-1, Some(WorkloadProfile::AiTraining)),
2 * MB as usize
);
assert_eq!(
get_adaptive_buffer_size_with_profile(-1, Some(WorkloadProfile::WebWorkload)),
128 * KB as usize
);
assert_eq!(
get_adaptive_buffer_size_with_profile(-1, Some(WorkloadProfile::SecureStorage)),
128 * KB as usize
);
}
#[test]
fn test_phase3_default_behavior() {
use crate::config::{RustFSBufferConfig, WorkloadProfile, init_global_buffer_config, set_buffer_profile_enabled};
const KB: i64 = 1024;
const MB: i64 = 1024 * 1024;
// Test Phase 3: Enabled by default with GeneralPurpose profile
set_buffer_profile_enabled(true);
init_global_buffer_config(RustFSBufferConfig::new(WorkloadProfile::GeneralPurpose));
// Verify GeneralPurpose profile provides consistent buffer sizes
assert_eq!(get_buffer_size_opt_in(500 * KB), 64 * KB as usize);
assert_eq!(get_buffer_size_opt_in(50 * MB), 256 * KB as usize);
assert_eq!(get_buffer_size_opt_in(200 * MB), MI_B);
assert_eq!(get_buffer_size_opt_in(-1), MI_B); // Unknown size
// Reset for other tests
set_buffer_profile_enabled(false);
}
#[test]
fn test_buffer_size_opt_in() {
use crate::config::{is_buffer_profile_enabled, set_buffer_profile_enabled};
const KB: i64 = 1024;
const MB: i64 = 1024 * 1024;
// \[1\] Default state: profile is not enabled, global configuration is not explicitly initialized
// get_buffer_size_opt_in should be equivalent to the GeneralPurpose configuration
set_buffer_profile_enabled(false);
assert!(!is_buffer_profile_enabled());
// GeneralPurpose rules:
// \< 1MB -> 64KB1MB-100MB -> 256KB\>=100MB -> 1MB
assert_eq!(get_buffer_size_opt_in(500 * KB), 64 * KB as usize);
assert_eq!(get_buffer_size_opt_in(50 * MB), 256 * KB as usize);
assert_eq!(get_buffer_size_opt_in(200 * MB), MI_B);
// \[2\] Enable the profile switch, but the global configuration is still the default GeneralPurpose
set_buffer_profile_enabled(true);
assert!(is_buffer_profile_enabled());
assert_eq!(get_buffer_size_opt_in(500 * KB), 64 * KB as usize);
assert_eq!(get_buffer_size_opt_in(50 * MB), 256 * KB as usize);
assert_eq!(get_buffer_size_opt_in(200 * MB), MI_B);
// \[3\] Close again to ensure unchanged behavior
set_buffer_profile_enabled(false);
assert!(!is_buffer_profile_enabled());
assert_eq!(get_buffer_size_opt_in(500 * KB), 64 * KB as usize);
}
#[test]
fn test_phase4_full_integration() {
use crate::config::{RustFSBufferConfig, WorkloadProfile, init_global_buffer_config, set_buffer_profile_enabled};
const KB: i64 = 1024;
const MB: i64 = 1024 * 1024;
// \[1\] During the entire test process, the global configuration is initialized only once.
// In order not to interfere with other tests, use GeneralPurpose (consistent with the default).
// If it has been initialized elsewhere, this call will be ignored by OnceLock and the behavior will still be GeneralPurpose.
init_global_buffer_config(RustFSBufferConfig::new(WorkloadProfile::GeneralPurpose));
// Make sure to turn off profile initially
set_buffer_profile_enabled(false);
// \[2\] Verify behavior of get_buffer_size_opt_in in disabled profile (GeneralPurpose)
assert_eq!(get_buffer_size_opt_in(500 * KB), 64 * KB as usize);
assert_eq!(get_buffer_size_opt_in(50 * MB), 256 * KB as usize);
assert_eq!(get_buffer_size_opt_in(200 * MB), MI_B);
// \[3\] When profile is enabled, the behavior remains consistent with the global GeneralPurpose configuration
set_buffer_profile_enabled(true);
assert_eq!(get_buffer_size_opt_in(500 * KB), 64 * KB as usize);
assert_eq!(get_buffer_size_opt_in(50 * MB), 256 * KB as usize);
assert_eq!(get_buffer_size_opt_in(200 * MB), MI_B);
// \[4\] Complex scenes, boundary values: such as unknown size
assert_eq!(get_buffer_size_opt_in(-1), MI_B);
set_buffer_profile_enabled(false);
}
#[test]
fn test_validate_list_object_unordered_with_delimiter() {
// [1] Normal case: No delimiter specified.
assert!(validate_list_object_unordered_with_delimiter(None, Some("allow-unordered=true")).is_ok());
let delim_str = "/".to_string();
let delimiter_some: Option<&Delimiter> = Some(&delim_str);
// [2] Normal case: Delimiter is present, but 'allow-unordered' is explicitly set to false.
assert!(validate_list_object_unordered_with_delimiter(delimiter_some, Some("allow-unordered=false")).is_ok());
let query_conflict = Some("allow-unordered=true");
// [3] Conflict case: Both delimiter and 'allow-unordered=true' are present.
assert!(validate_list_object_unordered_with_delimiter(delimiter_some, query_conflict).is_err());
let complex_query = Some("allow-unordered=true&abc=123");
// [4] Complex query: The validation should still trigger if 'allow-unordered=true' is part of a multi-parameter query.
assert!(validate_list_object_unordered_with_delimiter(delimiter_some, complex_query).is_err());
let complex_query_without_unordered = Some("abc=123&queryType=test");
// [5] Multi-parameter query without conflict: If other parameters exist but 'allow-unordered' is missing,
assert!(validate_list_object_unordered_with_delimiter(delimiter_some, complex_query_without_unordered).is_ok());
}
#[test]
fn test_parse_object_lock_retention() {
use time::macros::datetime;
// [1] Normal case: No retention specified (empty metadata)
assert!(parse_object_lock_retention(None).is_ok());
assert!(parse_object_lock_retention(None).unwrap().is_empty());
// [2] Normal case: Retention with valid COMPLIANCE mode (future date)
let valid_compliance_retention = ObjectLockRetention {
mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)),
retain_until_date: Some(datetime!(2030-01-01 00:00:00 UTC).into()),
};
let compliance_metadata = parse_object_lock_retention(Some(valid_compliance_retention)).unwrap();
assert_eq!(compliance_metadata.get("x-amz-object-lock-mode").unwrap(), "COMPLIANCE");
assert_eq!(
compliance_metadata.get("x-amz-object-lock-retain-until-date").unwrap(),
"2030-01-01T00:00:00Z"
);
assert!(contains_key_str(&compliance_metadata, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP));
// [3] Normal case: Retention with valid GOVERNANCE mode (future date)
let valid_governance_retention = ObjectLockRetention {
mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::GOVERNANCE)),
retain_until_date: Some(datetime!(2030-01-01 00:00:00 UTC).into()),
};
let governance_metadata = parse_object_lock_retention(Some(valid_governance_retention)).unwrap();
assert_eq!(governance_metadata.get("x-amz-object-lock-mode").unwrap(), "GOVERNANCE");
// [4] Normal case: Retention with None mode (empty string for mode, date not validated)
let none_mode_retention = ObjectLockRetention {
mode: None,
retain_until_date: Some(datetime!(2030-01-01 00:00:00 UTC).into()),
};
let none_mode_metadata = parse_object_lock_retention(Some(none_mode_retention)).unwrap();
assert_eq!(none_mode_metadata.get("x-amz-object-lock-mode").unwrap(), "");
// [5] Normal case: Retention with None retain_until_date (empty string for date)
let none_date_retention = ObjectLockRetention {
mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)),
retain_until_date: None,
};
let none_date_metadata = parse_object_lock_retention(Some(none_date_retention)).unwrap();
assert_eq!(none_date_metadata.get("x-amz-object-lock-retain-until-date").unwrap(), "");
// [6] Error case: Retention with invalid mode (non COMPLIANCE/GOVERNANCE)
let invalid_mode_retention = ObjectLockRetention {
mode: Some(ObjectLockRetentionMode::from_static("INVALID_MODE")),
retain_until_date: Some(datetime!(2030-01-01 00:00:00 UTC).into()),
};
let err = parse_object_lock_retention(Some(invalid_mode_retention)).unwrap_err();
assert_eq!(err.code().as_str(), S3ErrorCode::MalformedXML.as_str());
assert_eq!(
err.message(),
Some("The XML you provided was not well-formed or did not validate against our published schema")
);
// [7] Error case: Retention with past date should fail
let past_date_retention = ObjectLockRetention {
mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)),
retain_until_date: Some(datetime!(2020-01-01 00:00:00 UTC).into()),
};
let err = parse_object_lock_retention(Some(past_date_retention)).unwrap_err();
assert_eq!(err.code().as_str(), S3ErrorCode::InvalidArgument.as_str());
assert_eq!(err.message(), Some("The retain until date must be in the future"));
}
#[test]
fn test_parse_object_lock_legal_hold() {
// [1] Normal case: No legal hold specified (empty metadata)
assert!(parse_object_lock_legal_hold(None).is_ok());
assert!(parse_object_lock_legal_hold(None).unwrap().is_empty());
// [2] Normal case: Legal hold with valid ON status
let valid_on_legal_hold = ObjectLockLegalHold {
status: Some(ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::ON)),
};
let on_metadata = parse_object_lock_legal_hold(Some(valid_on_legal_hold)).unwrap();
assert_eq!(on_metadata.get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER).unwrap(), "ON");
assert!(contains_key_str(&on_metadata, SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP));
// [3] Normal case: Legal hold with valid OFF status
let valid_off_legal_hold = ObjectLockLegalHold {
status: Some(ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::OFF)),
};
let off_metadata = parse_object_lock_legal_hold(Some(valid_off_legal_hold)).unwrap();
assert_eq!(off_metadata.get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER).unwrap(), "OFF");
// [4] Normal case: Legal hold with None status (empty string for status)
let none_status_legal_hold = ObjectLockLegalHold { status: None };
let none_status_metadata = parse_object_lock_legal_hold(Some(none_status_legal_hold)).unwrap();
assert_eq!(none_status_metadata.get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER).unwrap(), "");
// [5] Error case: Legal hold with invalid status (non ON/OFF)
let invalid_status_legal_hold = ObjectLockLegalHold {
status: Some(ObjectLockLegalHoldStatus::from_static("INVALID_STATUS")),
};
let err = parse_object_lock_legal_hold(Some(invalid_status_legal_hold)).unwrap_err();
assert_eq!(err.code().as_str(), S3ErrorCode::MalformedXML.as_str());
assert_eq!(
err.message(),
Some("The XML you provided was not well-formed or did not validate against our published schema")
);
}
#[tokio::test]
async fn test_validate_bucket_object_lock_enabled() {
use rustfs_ecstore::bucket::metadata::BucketMetadata;
use rustfs_ecstore::bucket::metadata_sys::set_bucket_metadata;
use s3s::dto::{ObjectLockConfiguration, ObjectLockEnabled};
use time::OffsetDateTime;
if rustfs_ecstore::bucket::metadata_sys::GLOBAL_BucketMetadataSys.get().is_none() {
eprintln!("Skipping test: GLOBAL_BucketMetadataSys not initialized");
return;
}
let test_bucket = "test-bucket-object-lock";
let mut bm = BucketMetadata::new(test_bucket);
bm.object_lock_config = Some(ObjectLockConfiguration {
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
rule: None,
});
bm.object_lock_config_updated_at = OffsetDateTime::now_utc();
set_bucket_metadata(test_bucket.to_string(), bm).await.unwrap();
assert!(validate_bucket_object_lock_enabled(test_bucket).await.is_ok());
let mut bm = BucketMetadata::new(test_bucket);
bm.object_lock_config = Some(ObjectLockConfiguration {
object_lock_enabled: None,
rule: None,
});
bm.object_lock_config_updated_at = OffsetDateTime::now_utc();
set_bucket_metadata(test_bucket.to_string(), bm).await.unwrap();
let err = validate_bucket_object_lock_enabled(test_bucket).await.unwrap_err();
assert_eq!(err.code().as_str(), S3ErrorCode::InvalidRequest.as_str());
assert_eq!(err.message(), Some("Object Lock is not enabled for this bucket"));
let non_exist_bucket = "non-exist-bucket-object-lock";
let err = validate_bucket_object_lock_enabled(non_exist_bucket).await.unwrap_err();
assert_eq!(err.code().as_str(), S3ErrorCode::InvalidRequest.as_str());
assert_eq!(err.message(), Some("Bucket is missing ObjectLockConfiguration"));
}
#[test]
fn test_is_etag_equal() {
// [1] Header ETag is "*", should return true (match any object ETag)
assert!(is_etag_equal("\"d41d8cd98f00b204e9800998ecf8427e\"", "*"));
// [2] Exact match (both with double quotes)
assert!(is_etag_equal(
"\"d41d8cd98f00b204e9800998ecf8427e\"",
"\"d41d8cd98f00b204e9800998ecf8427e\""
));
// [3] Exact match (object ETag with quotes, header ETag without)
assert!(is_etag_equal("\"d41d8cd98f00b204e9800998ecf8427e\"", "d41d8cd98f00b204e9800998ecf8427e"));
// [4] Header ETag has multiple values (comma-separated), one matches
assert!(is_etag_equal("\"12345\"", "\"67890\", \"12345\", \"abcde\""));
// [5] Header ETag has multiple values with spaces, one matches after trim
assert!(is_etag_equal("\"12345\"", " \"67890\" , \"12345\" , \"abcde\" "));
// [6] No match (different ETag)
assert!(!is_etag_equal("\"12345\"", "\"67890\""));
// [7] No match in multiple values
assert!(!is_etag_equal("\"12345\"", "\"67890\", \"abcde\""));
}
#[test]
fn test_check_preconditions() {
use time::{format_description::FormatItem, macros::format_description};
const RFC1123: &[FormatItem<'_>] =
format_description!("[weekday repr:short], [day] [month repr:short] [year] [hour]:[minute]:[second] GMT");
let valid_mod_time = OffsetDateTime::from_unix_timestamp(1700000000).unwrap();
let valid_etag = "\"d41d8cd98f00b204e9800998ecf8427e\"";
let wrong_etag = "\"wrong-etag-123456\"";
// [1] Both mod_time and etag are None → early return Ok()
let info1 = ObjectInfo {
mod_time: None,
etag: None,
..Default::default()
};
let headers1 = HeaderMap::new();
assert!(check_preconditions(&headers1, &info1).is_ok());
// [2] No conditional headers with etag=None → Ok()
let info2 = ObjectInfo {
mod_time: Some(valid_mod_time),
etag: None,
..Default::default()
};
let headers2 = HeaderMap::new();
assert!(check_preconditions(&headers2, &info2).is_ok());
// [3] If-None-Match matches → return Err(S3Error::NotModified)
let mut headers3 = HeaderMap::new();
headers3.insert("if-none-match", HeaderValue::from_str(valid_etag).unwrap());
let info3 = ObjectInfo {
mod_time: Some(valid_mod_time),
etag: Some(valid_etag.to_string()),
..Default::default()
};
let result3 = check_preconditions(&headers3, &info3);
assert!(result3.is_err());
let err3 = result3.unwrap_err();
assert_eq!(err3.code(), &S3ErrorCode::NotModified);
assert_eq!(err3.message(), Some("Not Modified"));
assert_eq!(err3.status_code(), Some(StatusCode::NOT_MODIFIED));
// [4] If-None-Match does not match → return Ok()
let mut headers4 = HeaderMap::new();
headers4.insert("if-none-match", HeaderValue::from_str("\"wrong-etag\"").unwrap());
let info4 = info3.clone();
assert!(check_preconditions(&headers4, &info4).is_ok());
// [5] If-Modified-Since >= mod_time → return Err(S3Error::NotModified)
let mut headers5 = HeaderMap::new();
headers5.insert(
"if-modified-since",
HeaderValue::from_str(&valid_mod_time.format(&RFC1123).unwrap()).unwrap(),
);
let info5 = info3.clone();
let result5 = check_preconditions(&headers5, &info5);
assert!(result5.is_err());
let err5 = result5.unwrap_err();
assert_eq!(err5.code(), &S3ErrorCode::NotModified);
// [6] If-Modified-Since < mod_time → return Ok()
let earlier_time = valid_mod_time - time::Duration::hours(1);
let mut headers6 = HeaderMap::new();
headers6.insert(
"if-modified-since",
HeaderValue::from_str(&earlier_time.format(&RFC1123).unwrap()).unwrap(),
);
let info6 = info3.clone();
assert!(check_preconditions(&headers6, &info6).is_ok());
// [7] If-Match does not match → return Err(S3Error::PreconditionFailed)
let mut headers7 = HeaderMap::new();
headers7.insert("if-match", HeaderValue::from_str("\"wrong-etag\"").unwrap());
let info7 = info3.clone();
let result7 = check_preconditions(&headers7, &info7);
assert!(result7.is_err());
assert_eq!(result7.unwrap_err().code(), &S3ErrorCode::PreconditionFailed);
// [8] If-Match matches → return Ok()
let mut headers8 = HeaderMap::new();
headers8.insert("if-match", HeaderValue::from_str(valid_etag).unwrap());
let info8 = info3.clone();
assert!(check_preconditions(&headers8, &info8).is_ok());
// [9] If-Unmodified-Since < mod_time (no If-Match) → return Err(S3Error::PreconditionFailed)
let mut headers9 = HeaderMap::new();
headers9.insert(
"if-unmodified-since",
HeaderValue::from_str(&earlier_time.format(&RFC1123).unwrap()).unwrap(),
);
let info9 = info3.clone();
let result9 = check_preconditions(&headers9, &info9);
assert!(result9.is_err());
assert_eq!(result9.unwrap_err().code(), &S3ErrorCode::PreconditionFailed);
// [10] If-Unmodified-Since >= mod_time (no If-Match) → return Ok()
let mut headers10 = HeaderMap::new();
headers10.insert(
"if-unmodified-since",
HeaderValue::from_str(&valid_mod_time.format(&RFC1123).unwrap()).unwrap(),
);
let info10 = info3.clone();
assert!(check_preconditions(&headers10, &info10).is_ok());
// [11] If-Match (mismatch) + If-None-Match (match) → return Err(S3Error::PreconditionFailed)
let mut headers11 = HeaderMap::new();
headers11.insert("if-match", HeaderValue::from_str(wrong_etag).unwrap());
headers11.insert("if-none-match", HeaderValue::from_str(valid_etag).unwrap());
let info11 = info3.clone();
let result11 = check_preconditions(&headers11, &info11);
assert!(result11.is_err());
let err11 = result11.unwrap_err();
assert_eq!(err11.code(), &S3ErrorCode::PreconditionFailed);
assert_ne!(err11.code(), &S3ErrorCode::NotModified);
// [12] If-Match (match) + If-None-Match (match) → return Err(S3Error::NotModified)
let mut headers12 = HeaderMap::new();
headers12.insert("if-match", HeaderValue::from_str(valid_etag).unwrap());
headers12.insert("if-none-match", HeaderValue::from_str(valid_etag).unwrap());
let info12 = info3.clone();
let result12 = check_preconditions(&headers12, &info12);
assert!(result12.is_err());
let err12 = result12.unwrap_err();
assert_eq!(err12.code(), &S3ErrorCode::NotModified);
assert_ne!(err12.code(), &S3ErrorCode::PreconditionFailed);
// [13] If-None-Match (match) + If-Modified-Since → NotModified (If-Modified-Since ignored)
let mut headers13 = HeaderMap::new();
headers13.insert("if-none-match", HeaderValue::from_str(valid_etag).unwrap());
headers13.insert(
"if-modified-since",
HeaderValue::from_str(&earlier_time.format(&RFC1123).unwrap()).unwrap(),
);
let info13 = info3.clone();
let result13 = check_preconditions(&headers13, &info13);
assert!(result13.is_err());
assert_eq!(result13.unwrap_err().code(), &S3ErrorCode::NotModified);
// [14] If-None-Match (no match) + If-Modified-Since → Ok (If-Modified-Since ignored)
let mut headers14 = HeaderMap::new();
headers14.insert("if-none-match", HeaderValue::from_str("\"wrong-etag\"").unwrap());
headers14.insert(
"if-modified-since",
HeaderValue::from_str(&valid_mod_time.format(&RFC1123).unwrap()).unwrap(),
);
let info14 = info3.clone();
assert!(check_preconditions(&headers14, &info14).is_ok());
// [15] If-Match with no ETag → PreconditionFailed
let mut headers15 = HeaderMap::new();
headers15.insert("if-match", HeaderValue::from_str(valid_etag).unwrap());
let info15 = ObjectInfo {
mod_time: Some(valid_mod_time),
etag: None,
..Default::default()
};
let result15 = check_preconditions(&headers15, &info15);
assert!(result15.is_err());
assert_eq!(result15.unwrap_err().code(), &S3ErrorCode::PreconditionFailed);
// [16] If-None-Match with no ETag → Ok (no match possible)
let mut headers16 = HeaderMap::new();
headers16.insert("if-none-match", HeaderValue::from_str(valid_etag).unwrap());
let info16 = ObjectInfo {
mod_time: Some(valid_mod_time),
etag: None,
..Default::default()
};
assert!(check_preconditions(&headers16, &info16).is_ok());
// [17] mod_time None, etag exists, If-Match matches → Ok
let mut headers17 = HeaderMap::new();
headers17.insert("if-match", HeaderValue::from_str(valid_etag).unwrap());
let info17 = ObjectInfo {
mod_time: None,
etag: Some(valid_etag.to_string()),
..Default::default()
};
assert!(check_preconditions(&headers17, &info17).is_ok());
}
#[test]
fn test_parse_etag() {
// [1] ETag without quotes → adds quotes
let result1 = parse_etag("d41d8cd98f00b204e9800998ecf8427e").unwrap();
assert_eq!(result1.to_str().unwrap(), "\"d41d8cd98f00b204e9800998ecf8427e\"");
// [2] ETag with quotes → normalizes to single set of quotes
let result2 = parse_etag("\"d41d8cd98f00b204e9800998ecf8427e\"").unwrap();
assert_eq!(result2.to_str().unwrap(), "\"d41d8cd98f00b204e9800998ecf8427e\"");
// [3] ETag with multiple quotes → removes all and adds single set
let result3 = parse_etag("\"\"\"d41d8cd98f00b204e9800998ecf8427e\"\"\"").unwrap();
assert_eq!(result3.to_str().unwrap(), "\"d41d8cd98f00b204e9800998ecf8427e\"");
// [4] Empty string → returns error
let result4 = parse_etag("");
assert!(result4.is_err());
assert_eq!(result4.unwrap_err().code(), &S3ErrorCode::InvalidArgument);
// [5] String with only quotes → returns error
let result5 = parse_etag("\"\"");
assert!(result5.is_err());
assert_eq!(result5.unwrap_err().code(), &S3ErrorCode::InvalidArgument);
// [6] ETag with quotes only at start → removes and adds quotes
let result6 = parse_etag("\"d41d8cd98f00b204e9800998ecf8427e").unwrap();
assert_eq!(result6.to_str().unwrap(), "\"d41d8cd98f00b204e9800998ecf8427e\"");
// [7] ETag with quotes only at end → removes and adds quotes
let result7 = parse_etag("d41d8cd98f00b204e9800998ecf8427e\"").unwrap();
assert_eq!(result7.to_str().unwrap(), "\"d41d8cd98f00b204e9800998ecf8427e\"");
// [8] ETag with newline character → returns error (invalid header value)
let result8 = parse_etag("d41d8cd98f00b204e9800998ecf8427e\n");
assert!(result8.is_err());
assert_eq!(result8.unwrap_err().code(), &S3ErrorCode::InvalidArgument);
// [9] ETag with null byte → returns error (invalid header value)
let result9 = parse_etag("d41d8cd98f00b204e9800998ecf8427e\0");
assert!(result9.is_err());
assert_eq!(result9.unwrap_err().code(), &S3ErrorCode::InvalidArgument);
// [10] ETag with carriage return → returns error (invalid header value)
let result10 = parse_etag("d41d8cd98f00b204e9800998ecf8427e\r");
assert!(result10.is_err());
assert_eq!(result10.unwrap_err().code(), &S3ErrorCode::InvalidArgument);
// [11] ETag with whitespace → preserves content, adds quotes
let result11 = parse_etag(" d41d8cd98f00b204e9800998ecf8427e ").unwrap();
assert_eq!(result11.to_str().unwrap(), "\" d41d8cd98f00b204e9800998ecf8427e \"");
// [12] ETag with only whitespace (no quotes) → returns error
let result12 = parse_etag(" ");
assert!(result12.is_err());
assert_eq!(result12.unwrap_err().code(), &S3ErrorCode::InvalidArgument);
// [13] ETag with only whitespace (with quotes) → returns error
let result13 = parse_etag("\" \"");
assert!(result13.is_err());
assert_eq!(result13.unwrap_err().code(), &S3ErrorCode::InvalidArgument);
}
// Note: S3Request structure is complex and requires many fields.
// For real testing, we would need proper integration test setup.
// Removing this test as it requires too much S3 infrastructure setup.
// Note: Testing actual S3 operations like put_object, get_object, etc. requires:
// 1. Initialized storage backend (ECStore)
// 2. Global configuration setup
// 3. Valid credentials and authorization
// 4. Bucket and object metadata systems
// 5. Network and disk I/O capabilities
//
// These are better suited for integration tests rather than unit tests.
// The current tests focus on the testable parts without external dependencies.
/// Test that next_key_marker and next_version_id_marker are filtered correctly
/// AWS S3 API requires these fields to be omitted when empty, not set to None or ""
#[test]
fn test_next_marker_filtering() {
// Test filter behavior for empty strings
let empty_string = Some(String::new());
let filtered = empty_string.filter(|v| !v.is_empty());
assert!(filtered.is_none(), "Empty string should be filtered to None");
// Test filter behavior for non-empty strings
let non_empty = Some("some-marker".to_string());
let filtered = non_empty.filter(|v| !v.is_empty());
assert!(filtered.is_some(), "Non-empty string should not be filtered");
assert_eq!(filtered.unwrap(), "some-marker");
// Test filter behavior for None
let none_value: Option<String> = None;
let filtered = none_value.filter(|v| !v.is_empty());
assert!(filtered.is_none(), "None should remain None");
}
/// Test version_id handling for ListObjectVersions response
/// Per AWS S3 API spec:
/// - Versioned objects: version_id is a UUID string
/// - Non-versioned objects: version_id should be "null" string
#[test]
fn test_version_id_formatting() {
use uuid::Uuid;
// Non-versioned object: version_id is None, should format as "null"
let version_id: Option<Uuid> = None;
let formatted = version_id.map(|v| v.to_string()).unwrap_or_else(|| "null".to_string());
assert_eq!(formatted, "null");
// Versioned object: version_id is Some(UUID), should format as UUID string
let uuid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let version_id: Option<Uuid> = Some(uuid);
let formatted = version_id.map(|v| v.to_string()).unwrap_or_else(|| "null".to_string());
assert_eq!(formatted, "550e8400-e29b-41d4-a716-446655440000");
}
#[test]
fn test_delete_objects_version_id_normalization() {
use uuid::Uuid;
let fs = FS::new();
let (raw, uuid) = fs.normalize_delete_objects_version_id(Some("null".to_string())).unwrap();
assert_eq!(raw.as_deref(), Some("null"));
assert_eq!(uuid, Some(Uuid::nil()));
let valid = "550e8400-e29b-41d4-a716-446655440000".to_string();
let (raw, uuid) = fs.normalize_delete_objects_version_id(Some(valid.clone())).unwrap();
assert_eq!(raw.as_deref(), Some(valid.as_str()));
assert_eq!(uuid, Some(Uuid::parse_str(&valid).unwrap()));
let err = fs
.normalize_delete_objects_version_id(Some("not-a-uuid".to_string()))
.unwrap_err();
assert!(!err.is_empty());
let (raw, uuid) = fs.normalize_delete_objects_version_id(None).unwrap();
assert!(raw.is_none());
assert!(uuid.is_none());
}
/// Test that ListObjectVersionsOutput markers are correctly set
/// This verifies the fix for boto3 ParamValidationError
#[test]
fn test_list_object_versions_markers_handling() {
// Simulate the marker filtering logic from list_object_versions
// Case 1: Both markers have values (truncated result with versioned object)
let next_marker = Some("object-key".to_string());
let next_version_idmarker = Some("550e8400-e29b-41d4-a716-446655440000".to_string());
let filtered_key_marker = next_marker.filter(|v| !v.is_empty());
let filtered_version_marker = next_version_idmarker.filter(|v| !v.is_empty());
assert!(filtered_key_marker.is_some());
assert!(filtered_version_marker.is_some());
// Case 2: Markers are empty strings (non-truncated result)
let next_marker = Some(String::new());
let next_version_idmarker = Some(String::new());
let filtered_key_marker = next_marker.filter(|v| !v.is_empty());
let filtered_version_marker = next_version_idmarker.filter(|v| !v.is_empty());
assert!(filtered_key_marker.is_none(), "Empty key marker should be filtered to None");
assert!(filtered_version_marker.is_none(), "Empty version marker should be filtered to None");
// Case 3: Truncated result with non-versioned object (version_id is "null")
let next_marker = Some("object-key".to_string());
let next_version_idmarker = Some("null".to_string());
let filtered_key_marker = next_marker.filter(|v| !v.is_empty());
let filtered_version_marker = next_version_idmarker.filter(|v| !v.is_empty());
assert!(filtered_key_marker.is_some());
assert!(filtered_version_marker.is_some());
assert_eq!(filtered_version_marker.unwrap(), "null");
}
#[test]
fn test_matches_origin_pattern_exact_match() {
// Test exact match
assert!(matches_origin_pattern("https://example.com", "https://example.com"));
assert!(matches_origin_pattern("http://localhost:3000", "http://localhost:3000"));
assert!(!matches_origin_pattern("https://example.com", "https://other.com"));
}
#[test]
fn test_matches_origin_pattern_wildcard() {
// Test wildcard pattern matching (S3 CORS supports * as subdomain wildcard)
assert!(matches_origin_pattern("https://*.example.com", "https://app.example.com"));
assert!(matches_origin_pattern("https://*.example.com", "https://api.example.com"));
assert!(matches_origin_pattern("https://*.example.com", "https://subdomain.example.com"));
// Test wildcard at start (matches any domain)
assert!(matches_origin_pattern("https://*", "https://example.com"));
assert!(matches_origin_pattern("https://*", "https://any-domain.com"));
// Test wildcard at end (matches any protocol)
assert!(matches_origin_pattern("*://example.com", "https://example.com"));
assert!(matches_origin_pattern("*://example.com", "http://example.com"));
// Test invalid wildcard patterns (should not match)
assert!(!matches_origin_pattern("https://*.*.com", "https://app.example.com")); // Multiple wildcards (invalid pattern)
// Note: "https://*example.com" actually matches "https://app.example.com" with our current implementation
// because it splits on * and checks starts_with/ends_with. This is a limitation but acceptable
// for S3 CORS which typically uses patterns like "https://*.example.com"
}
#[test]
fn test_matches_origin_pattern_no_wildcard() {
// Test patterns without wildcards
assert!(matches_origin_pattern("https://example.com", "https://example.com"));
assert!(!matches_origin_pattern("https://example.com", "https://example.org"));
assert!(!matches_origin_pattern("http://example.com", "https://example.com")); // Different protocol
}
#[test]
fn test_matches_origin_pattern_edge_cases() {
// Test edge cases
assert!(!matches_origin_pattern("", "https://example.com")); // Empty pattern
assert!(!matches_origin_pattern("https://example.com", "")); // Empty origin
assert!(matches_origin_pattern("", "")); // Both empty
assert!(!matches_origin_pattern("https://example.com", "http://example.com")); // Protocol mismatch
}
#[test]
fn test_cors_headers_validation() {
use http::HeaderMap;
// Test case 1: Validate header name case-insensitivity
let mut headers = HeaderMap::new();
headers.insert("access-control-request-headers", "Content-Type,X-Custom-Header".parse().unwrap());
let req_headers_str = headers
.get("access-control-request-headers")
.and_then(|v| v.to_str().ok())
.unwrap();
let req_headers: Vec<String> = req_headers_str.split(',').map(|s| s.trim().to_lowercase()).collect();
// Headers should be lowercased for comparison
assert_eq!(req_headers, vec!["content-type", "x-custom-header"]);
// Test case 2: Wildcard matching
let allowed_headers = ["*".to_string()];
let all_allowed = req_headers.iter().all(|req_header| {
allowed_headers
.iter()
.any(|allowed| allowed.to_lowercase() == "*" || allowed.to_lowercase() == *req_header)
});
assert!(all_allowed, "Wildcard should allow all headers");
// Test case 3: Specific header matching
let allowed_headers = ["content-type".to_string(), "x-custom-header".to_string()];
let all_allowed = req_headers
.iter()
.all(|req_header| allowed_headers.iter().any(|allowed| allowed.to_lowercase() == *req_header));
assert!(all_allowed, "All requested headers should be allowed");
// Test case 4: Disallowed header
let req_headers = ["content-type".to_string(), "x-forbidden-header".to_string()];
let allowed_headers = ["content-type".to_string()];
let all_allowed = req_headers
.iter()
.all(|req_header| allowed_headers.iter().any(|allowed| allowed.to_lowercase() == *req_header));
assert!(!all_allowed, "Forbidden header should not be allowed");
}
#[test]
fn test_cors_response_headers_structure() {
use http::{HeaderMap, HeaderValue};
let mut cors_headers = HeaderMap::new();
// Simulate building CORS response headers
let origin = "https://example.com";
let methods = ["GET", "PUT", "POST"];
let allowed_headers = ["Content-Type", "Authorization"];
let expose_headers = ["ETag", "x-amz-version-id"];
let max_age = 3600;
// Add headers
cors_headers.insert("access-control-allow-origin", HeaderValue::from_str(origin).unwrap());
cors_headers.insert("vary", HeaderValue::from_static("Origin"));
let methods_str = methods.join(", ");
cors_headers.insert("access-control-allow-methods", HeaderValue::from_str(&methods_str).unwrap());
let headers_str = allowed_headers.join(", ");
cors_headers.insert("access-control-allow-headers", HeaderValue::from_str(&headers_str).unwrap());
let expose_str = expose_headers.join(", ");
cors_headers.insert("access-control-expose-headers", HeaderValue::from_str(&expose_str).unwrap());
cors_headers.insert("access-control-max-age", HeaderValue::from_str(&max_age.to_string()).unwrap());
// Verify all headers are present
assert_eq!(cors_headers.get("access-control-allow-origin").unwrap(), origin);
assert_eq!(cors_headers.get("vary").unwrap(), "Origin");
assert_eq!(cors_headers.get("access-control-allow-methods").unwrap(), "GET, PUT, POST");
assert_eq!(cors_headers.get("access-control-allow-headers").unwrap(), "Content-Type, Authorization");
assert_eq!(cors_headers.get("access-control-expose-headers").unwrap(), "ETag, x-amz-version-id");
assert_eq!(cors_headers.get("access-control-max-age").unwrap(), "3600");
}
#[test]
fn test_cors_preflight_vs_actual_request() {
use http::Method;
// Test that we can distinguish preflight from actual requests
let preflight_method = Method::OPTIONS;
let actual_method = Method::PUT;
assert_eq!(preflight_method, Method::OPTIONS);
assert_ne!(actual_method, Method::OPTIONS);
// Preflight should check Access-Control-Request-Method
// Actual request should use the actual method
let is_preflight_1 = preflight_method == Method::OPTIONS;
let is_preflight_2 = actual_method == Method::OPTIONS;
assert!(is_preflight_1);
assert!(!is_preflight_2);
}
#[tokio::test]
async fn test_apply_cors_headers_no_origin() {
// Test when no Origin header is present
let headers = HeaderMap::new();
let method = http::Method::GET;
// Should return None when no origin header
let result = apply_cors_headers("test-bucket", &method, &headers).await;
assert!(result.is_none(), "Should return None when no Origin header");
}
#[tokio::test]
async fn test_apply_cors_headers_no_cors_config() {
// Test when bucket has no CORS configuration
let mut headers = HeaderMap::new();
headers.insert("origin", "https://example.com".parse().unwrap());
let method = http::Method::GET;
// Should return None when no CORS config exists
// Note: This test may fail if test-bucket actually has CORS config
// In a real scenario, we'd use a mock or ensure the bucket doesn't exist
let _result = apply_cors_headers("non-existent-bucket-for-testing", &method, &headers).await;
// Result depends on whether bucket exists and has CORS config
// This is expected behavior - we just verify it doesn't panic
}
#[tokio::test]
async fn test_apply_cors_headers_unmatched_origin_with_cors_config() {
if metadata_sys::get_global_bucket_metadata_sys().is_none() {
eprintln!("Skipping test: GLOBAL_BucketMetadataSys not initialized");
return;
}
let bucket = "test-bucket-no-match-cors";
let mut bm = BucketMetadata::new(bucket);
bm.cors_config = Some(CORSConfiguration {
cors_rules: vec![CORSRule {
allowed_headers: Some(vec!["*".to_string()]),
allowed_methods: vec!["GET".to_string()],
allowed_origins: vec!["https://allowed.example.com".to_string()],
expose_headers: None,
id: Some("non-match-origin".to_string()),
max_age_seconds: None,
}],
});
metadata_sys::set_bucket_metadata(bucket.to_string(), bm).await.unwrap();
let mut headers = HeaderMap::new();
headers.insert(cors::standard::ORIGIN, "https://disallowed.example.com".parse().unwrap());
let result = apply_cors_headers(bucket, &http::Method::GET, &headers).await;
assert!(
result.is_some(),
"Expected Some empty headers when bucket has CORS config but origin does not match any rule"
);
let result = result.unwrap();
assert!(result.get(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN).is_none());
assert!(result.get(cors::response::ACCESS_CONTROL_ALLOW_METHODS).is_none());
metadata_sys::set_bucket_metadata(bucket.to_string(), BucketMetadata::new(bucket))
.await
.unwrap();
}
#[tokio::test]
async fn test_apply_cors_headers_credentialed_request_with_wildcard_origin() {
if metadata_sys::get_global_bucket_metadata_sys().is_none() {
eprintln!("Skipping test: GLOBAL_BucketMetadataSys not initialized");
return;
}
let bucket = "test-bucket-credentialed-cors";
let mut bm = BucketMetadata::new(bucket);
bm.cors_config = Some(CORSConfiguration {
cors_rules: vec![CORSRule {
allowed_headers: Some(vec!["*".to_string()]),
allowed_methods: vec!["GET".to_string()],
allowed_origins: vec!["*".to_string()],
expose_headers: None,
id: Some("credentialed-unit".to_string()),
max_age_seconds: None,
}],
});
metadata_sys::set_bucket_metadata(bucket.to_string(), bm).await.unwrap();
let mut headers = HeaderMap::new();
headers.insert(cors::standard::ORIGIN, "https://console.localhost".parse().unwrap());
headers.insert(cors::request::ACCESS_CONTROL_REQUEST_METHOD, "GET".parse().unwrap());
headers.insert(cors::request::ACCESS_CONTROL_REQUEST_HEADERS, "x-amz-content-sha256".parse().unwrap());
headers.insert(http::header::AUTHORIZATION, "AWS4-HMAC-SHA256 Credential=test/20260302/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256, Signature=abc".parse().unwrap());
let result = apply_cors_headers(bucket, &http::Method::OPTIONS, &headers).await.unwrap();
assert_eq!(
result.get(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(),
"https://console.localhost",
);
assert_eq!(result.get(cors::response::ACCESS_CONTROL_ALLOW_CREDENTIALS).unwrap(), "true");
assert_eq!(result.get(cors::standard::VARY).unwrap(), "Origin");
metadata_sys::set_bucket_metadata(bucket.to_string(), BucketMetadata::new(bucket))
.await
.unwrap();
}
#[tokio::test]
async fn test_apply_cors_headers_unsupported_method() {
// Test with unsupported HTTP method
let mut headers = HeaderMap::new();
headers.insert("origin", "https://example.com".parse().unwrap());
let method = http::Method::PATCH; // Unsupported method
let result = apply_cors_headers("test-bucket", &method, &headers).await;
assert!(result.is_none(), "Should return None for unsupported methods");
}
#[test]
fn test_matches_origin_pattern_complex_wildcards() {
// Test more complex wildcard scenarios
assert!(matches_origin_pattern("https://*.example.com", "https://sub.example.com"));
// Note: "https://*.example.com" matches "https://api.sub.example.com" with our implementation
// because it only checks starts_with and ends_with. Real S3 might be more strict.
// Test wildcard in middle position
// Our implementation allows this, but it's not standard S3 CORS pattern
// The pattern "https://example.*.com" splits to ["https://example.", ".com"]
// and "https://example.sub.com" matches because it starts with "https://example." and ends with ".com"
// This is acceptable for our use case as S3 CORS typically uses "https://*.example.com" format
}
// === Notification Configuration Error Propagation Tests ===
#[test]
fn test_process_queue_configurations_propagates_error_on_invalid_arn() {
use rustfs_targets::arn::{ARN, TargetIDError};
let mut event_rules = Vec::new();
let invalid_arn = "arn:minio:sqs::1:webhook"; // Wrong prefix, should fail
let result = process_queue_configurations(
&mut event_rules,
Some(vec![QueueConfiguration {
events: vec!["s3:ObjectCreated:*".to_string().into()],
queue_arn: invalid_arn.to_string(),
filter: None,
id: None,
}]),
|arn_str| {
ARN::parse(arn_str)
.map(|arn| arn.target_id)
.map_err(|e| TargetIDError::InvalidFormat(e.to_string()))
},
);
assert!(result.is_err(), "Should return error for invalid ARN prefix");
assert!(event_rules.is_empty(), "Should not add rules when ARN is invalid");
}
#[test]
fn test_process_topic_configurations_propagates_error_on_invalid_arn() {
use rustfs_targets::arn::{ARN, TargetIDError};
let mut event_rules = Vec::new();
let invalid_arn = "arn:aws:sns:us-east-1:123:topic"; // Wrong prefix, should fail
let result = process_topic_configurations(
&mut event_rules,
Some(vec![TopicConfiguration {
events: vec!["s3:ObjectCreated:*".to_string().into()],
topic_arn: invalid_arn.to_string(),
filter: None,
id: None,
}]),
|arn_str| {
ARN::parse(arn_str)
.map(|arn| arn.target_id)
.map_err(|e| TargetIDError::InvalidFormat(e.to_string()))
},
);
assert!(result.is_err(), "Should return error for invalid ARN prefix");
assert!(event_rules.is_empty(), "Should not add rules when ARN is invalid");
}
#[test]
fn test_process_lambda_configurations_propagates_error_on_invalid_arn() {
use rustfs_targets::arn::{ARN, TargetIDError};
let mut event_rules = Vec::new();
let invalid_arn = "arn:aws:lambda:us-east-1:123:function"; // Wrong prefix, should fail
let result = process_lambda_configurations(
&mut event_rules,
Some(vec![LambdaFunctionConfiguration {
events: vec!["s3:ObjectCreated:*".to_string().into()],
lambda_function_arn: invalid_arn.to_string(),
filter: None,
id: None,
}]),
|arn_str| {
ARN::parse(arn_str)
.map(|arn| arn.target_id)
.map_err(|e| TargetIDError::InvalidFormat(e.to_string()))
},
);
assert!(result.is_err(), "Should return error for invalid ARN prefix");
assert!(event_rules.is_empty(), "Should not add rules when ARN is invalid");
}
#[test]
fn test_process_queue_configurations_succeeds_with_valid_arn() {
use rustfs_targets::arn::{ARN, TargetIDError};
let mut event_rules = Vec::new();
let valid_arn = "arn:rustfs:sqs:us-east-1:1:webhook"; // Correct prefix
let result = process_queue_configurations(
&mut event_rules,
Some(vec![QueueConfiguration {
events: vec!["s3:ObjectCreated:*".to_string().into()],
queue_arn: valid_arn.to_string(),
filter: None,
id: None,
}]),
|arn_str| {
ARN::parse(arn_str)
.map(|arn| arn.target_id)
.map_err(|e| TargetIDError::InvalidFormat(e.to_string()))
},
);
assert!(result.is_ok(), "Should succeed with valid ARN");
assert_eq!(event_rules.len(), 1, "Should add one rule");
}
// --- Object tag conditions for bucket policy (s3:ExistingObjectTag) ---
/// Verifies that object tags are formatted as ExistingObjectTag/<key> condition keys
/// with a single-element vec value, matching the format expected by policy evaluation.
#[test]
fn test_object_tag_condition_key_format() {
use rustfs_ecstore::bucket::tagging::decode_tags_to_map;
use std::collections::HashMap;
let tags_str = "security=public&project=webapp&env=prod";
let map = decode_tags_to_map(tags_str);
let mut out: HashMap<String, Vec<String>> = HashMap::new();
for (k, v) in map {
out.insert(format!("ExistingObjectTag/{}", k), vec![v]);
}
assert_eq!(out.get("ExistingObjectTag/security"), Some(&vec!["public".to_string()]));
assert_eq!(out.get("ExistingObjectTag/project"), Some(&vec!["webapp".to_string()]));
assert_eq!(out.get("ExistingObjectTag/env"), Some(&vec!["prod".to_string()]));
assert_eq!(out.len(), 3);
}
/// When no object store is available (e.g. unit test env), get_object_tag_conditions_for_policy
/// returns Ok(empty map) so authorization can proceed without tag conditions.
#[tokio::test]
async fn test_get_object_tag_conditions_for_policy_returns_empty_without_store() {
let fs = FS::new();
let out = fs.get_object_tag_conditions_for_policy("bucket", "key", None).await.unwrap();
assert!(out.is_empty(), "without store should return empty tag conditions");
}
/// With version_id specified, the same no-store path returns Ok(empty) (versioned object path).
#[tokio::test]
async fn test_get_object_tag_conditions_for_policy_version_id_returns_empty_without_store() {
let fs = FS::new();
let out = fs
.get_object_tag_conditions_for_policy("bucket", "key", Some("v1"))
.await
.unwrap();
assert!(out.is_empty());
}
// --- CORS origin pattern matching tests ---
#[test]
fn test_matches_origin_pattern_suffix_wildcard() {
assert!(matches_origin_pattern("*suffix", "foo.suffix"));
assert!(matches_origin_pattern("*suffix", "suffix"));
assert!(!matches_origin_pattern("*suffix", "foo.suffix.get"));
assert!(!matches_origin_pattern("*suffix", "foo.bar"));
}
#[test]
fn test_matches_origin_pattern_prefix_wildcard() {
assert!(matches_origin_pattern("prefix*", "prefix"));
assert!(matches_origin_pattern("prefix*", "prefix.suffix"));
assert!(!matches_origin_pattern("prefix*", "bla.prefix"));
}
#[test]
fn test_matches_origin_pattern_middle_wildcard() {
assert!(matches_origin_pattern("start*end", "startend"));
assert!(matches_origin_pattern("start*end", "start1end"));
assert!(matches_origin_pattern("start*end", "start12end"));
assert!(!matches_origin_pattern("start*end", "0start12end"));
}
#[test]
fn test_matches_origin_pattern_exact_no_wildcard() {
assert!(matches_origin_pattern("example.com", "example.com"));
assert!(!matches_origin_pattern("example.com", "other.com"));
}
#[test]
fn test_matches_origin_pattern_single_star_wildcard() {
assert!(matches_origin_pattern("*", "anything.com"));
assert!(matches_origin_pattern("*", ""));
}
}