feat(s3): advance parity coverage (#2278)

This commit is contained in:
weisd
2026-03-24 17:29:33 +08:00
committed by GitHub
parent 8aa59b12cb
commit 28f57b228c
60 changed files with 15509 additions and 412 deletions
+2 -2
View File
@@ -16,7 +16,7 @@
//! Verifies that anonymous access works correctly with bucket policies
//! when PublicAccessBlock configuration is missing or explicitly set.
use crate::common::{RustFSTestEnvironment, init_logging};
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use aws_sdk_s3::types::PublicAccessBlockConfiguration;
use serial_test::serial;
use tracing::info;
@@ -67,7 +67,7 @@ async fn anonymous_get_object(
key: &str,
) -> Result<reqwest::Response, reqwest::Error> {
let url = format!("{}/{}/{}", env.url, bucket_name, key);
reqwest::Client::new().get(&url).send().await
local_http_client().get(&url).send().await
}
/// Issue #2036: Anonymous GetObject should succeed when bucket policy allows it
+544
View File
@@ -0,0 +1,544 @@
// 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.
//! End-to-end tests for S3 dummy-compat bucket APIs.
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{
AccelerateConfiguration, BucketAccelerateStatus, BucketLoggingStatus, IndexDocument, LoggingEnabled, Payer,
RequestPaymentConfiguration, WebsiteConfiguration,
};
use serial_test::serial;
use std::path::PathBuf;
use std::process::Command;
use tracing::info;
fn awscurl_binary_path() -> PathBuf {
std::env::var_os("AWSCURL_PATH")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("awscurl"))
}
fn awscurl_available() -> bool {
Command::new(awscurl_binary_path()).arg("--version").output().is_ok()
}
fn execute_s3_awscurl(
method: &str,
url: &str,
access_key: &str,
secret_key: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let output = Command::new(awscurl_binary_path())
.args([
"--service",
"s3",
"--region",
"us-east-1",
"--access_key",
access_key,
"--secret_key",
secret_key,
"-i",
"-X",
method,
url,
])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
return Err(format!("awscurl failed: stderr='{stderr}', stdout='{stdout}'").into());
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn parse_status(raw: &str) -> Option<u16> {
raw.lines()
.filter_map(|line| {
if line.starts_with("HTTP/") {
line.split_whitespace().nth(1)?.parse::<u16>().ok()
} else {
None
}
})
.next_back()
}
fn parse_body(raw: &str) -> String {
if let Some(pos) = raw.rfind("\r\n\r\n") {
return raw[pos + 4..].to_string();
}
if let Some(pos) = raw.rfind("\n\n") {
return raw[pos + 2..].to_string();
}
String::new()
}
fn parse_headers(raw: &str) -> String {
let start = raw.rfind("HTTP/").unwrap_or(0);
let tail = &raw[start..];
if let Some(pos) = tail.find("\r\n\r\n") {
return tail[..pos].to_string();
}
if let Some(pos) = tail.find("\n\n") {
return tail[..pos].to_string();
}
tail.to_string()
}
#[tokio::test]
#[serial]
async fn test_dummy_bucket_compatibility_endpoints() {
init_logging();
info!("Starting test: dummy-compat bucket APIs should match S3-compatible behavior");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = env.create_s3_client();
let bucket = "test-get-bucket-logging";
client
.create_bucket()
.bucket(bucket)
.send()
.await
.expect("Failed to create bucket");
let result = client.get_bucket_logging().bucket(bucket).send().await;
assert!(
result.is_ok(),
"GetBucketLogging should return success for existing bucket, got: {:?}",
result.err()
);
let output = result.unwrap();
assert!(
output.logging_enabled().is_none(),
"Default GetBucketLogging should return empty logging configuration"
);
let put_logging = client
.put_bucket_logging()
.bucket(bucket)
.bucket_logging_status(
BucketLoggingStatus::builder()
.logging_enabled(
LoggingEnabled::builder()
.target_bucket(bucket)
.target_prefix("logs/")
.build()
.expect("failed to build LoggingEnabled"),
)
.build(),
)
.send()
.await;
assert!(
put_logging.is_ok(),
"PutBucketLogging should return success for existing bucket, got: {:?}",
put_logging.err()
);
let output_after_put = client
.get_bucket_logging()
.bucket(bucket)
.send()
.await
.expect("GetBucketLogging should succeed after PutBucketLogging");
let logging_after_put = output_after_put
.logging_enabled()
.expect("GetBucketLogging should return persisted logging_enabled");
assert_eq!(
logging_after_put.target_bucket(),
bucket,
"GetBucketLogging should preserve target bucket"
);
assert_eq!(
logging_after_put.target_prefix(),
"logs/",
"GetBucketLogging should preserve target prefix"
);
let accelerate = client
.get_bucket_accelerate_configuration()
.bucket(bucket)
.send()
.await
.expect("GetBucketAccelerateConfiguration should succeed");
assert!(
accelerate.status().is_none(),
"Default GetBucketAccelerateConfiguration should return empty status"
);
let payment = client
.get_bucket_request_payment()
.bucket(bucket)
.send()
.await
.expect("GetBucketRequestPayment should succeed");
assert_eq!(
payment.payer().map(|p| p.as_str()),
Some("BucketOwner"),
"GetBucketRequestPayment should return BucketOwner by default"
);
let put_accelerate = client
.put_bucket_accelerate_configuration()
.bucket(bucket)
.accelerate_configuration(
AccelerateConfiguration::builder()
.status(BucketAccelerateStatus::Suspended)
.build(),
)
.send()
.await;
assert!(
put_accelerate.is_ok(),
"PutBucketAccelerateConfiguration should return success for existing bucket, got: {:?}",
put_accelerate.err()
);
let put_request_payment = client
.put_bucket_request_payment()
.bucket(bucket)
.request_payment_configuration(
RequestPaymentConfiguration::builder()
.payer(Payer::Requester)
.build()
.expect("failed to build RequestPaymentConfiguration"),
)
.send()
.await;
assert!(
put_request_payment.is_ok(),
"PutBucketRequestPayment should return success for existing bucket, got: {:?}",
put_request_payment.err()
);
let accelerate_after_put = client
.get_bucket_accelerate_configuration()
.bucket(bucket)
.send()
.await
.expect("GetBucketAccelerateConfiguration should succeed after put");
assert_eq!(
accelerate_after_put.status().map(|s| s.as_str()),
Some("Suspended"),
"GetBucketAccelerateConfiguration should preserve put status"
);
let payment_after_put = client
.get_bucket_request_payment()
.bucket(bucket)
.send()
.await
.expect("GetBucketRequestPayment should succeed after put");
assert_eq!(
payment_after_put.payer().map(|p| p.as_str()),
Some("Requester"),
"GetBucketRequestPayment should preserve put payer"
);
let put_website = client
.put_bucket_website()
.bucket(bucket)
.website_configuration(
WebsiteConfiguration::builder()
.index_document(
IndexDocument::builder()
.suffix("index.html")
.build()
.expect("failed to build IndexDocument"),
)
.build(),
)
.send()
.await;
assert!(
put_website.is_ok(),
"PutBucketWebsite should return success for existing bucket, got: {:?}",
put_website.err()
);
let website = client.get_bucket_website().bucket(bucket).send().await;
assert!(website.is_ok(), "GetBucketWebsite should return persisted website configuration");
let website_output = website.unwrap();
assert_eq!(
website_output.index_document().map(|doc| doc.suffix()),
Some("index.html"),
"GetBucketWebsite should preserve index document suffix"
);
client
.delete_bucket_website()
.bucket(bucket)
.send()
.await
.expect("DeleteBucketWebsite should return success");
let website_after_delete = client.get_bucket_website().bucket(bucket).send().await;
assert!(
website_after_delete.is_err(),
"GetBucketWebsite should return NoSuchWebsiteConfiguration after deletion"
);
let website_err = website_after_delete.err().unwrap();
let website_code = website_err.as_service_error().and_then(|e| e.code());
assert!(
matches!(website_code, Some("NoSuchWebsiteConfiguration")),
"Unexpected GetBucketWebsite error code: {:?}, err: {:?}",
website_code,
website_err
);
env.stop_server();
}
#[tokio::test]
#[serial]
async fn test_dummy_bucket_compatibility_endpoints_no_such_bucket() {
init_logging();
info!("Starting test: dummy-compat bucket APIs should return NoSuchBucket for missing bucket");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = env.create_s3_client();
let missing_bucket = "test-dummy-bucket-missing";
let get_logging = client.get_bucket_logging().bucket(missing_bucket).send().await;
assert!(get_logging.is_err(), "GetBucketLogging should fail for missing bucket");
let get_logging_err = get_logging.err().unwrap();
let get_logging_code = get_logging_err.as_service_error().and_then(|e| e.code());
assert!(
matches!(get_logging_code, Some("NoSuchBucket")),
"Unexpected GetBucketLogging error code: {:?}, err: {:?}",
get_logging_code,
get_logging_err
);
let put_logging = client
.put_bucket_logging()
.bucket(missing_bucket)
.bucket_logging_status(BucketLoggingStatus::builder().build())
.send()
.await;
assert!(put_logging.is_err(), "PutBucketLogging should fail for missing bucket");
let put_logging_err = put_logging.err().unwrap();
let put_logging_code = put_logging_err.as_service_error().and_then(|e| e.code());
assert!(
matches!(put_logging_code, Some("NoSuchBucket")),
"Unexpected PutBucketLogging error code: {:?}, err: {:?}",
put_logging_code,
put_logging_err
);
let get_accelerate = client
.get_bucket_accelerate_configuration()
.bucket(missing_bucket)
.send()
.await;
assert!(get_accelerate.is_err(), "GetBucketAccelerateConfiguration should fail for missing bucket");
let get_accelerate_err = get_accelerate.err().unwrap();
let get_accelerate_code = get_accelerate_err.as_service_error().and_then(|e| e.code());
assert!(
matches!(get_accelerate_code, Some("NoSuchBucket")),
"Unexpected GetBucketAccelerateConfiguration error code: {:?}, err: {:?}",
get_accelerate_code,
get_accelerate_err
);
let get_request_payment = client.get_bucket_request_payment().bucket(missing_bucket).send().await;
assert!(get_request_payment.is_err(), "GetBucketRequestPayment should fail for missing bucket");
let get_request_payment_err = get_request_payment.err().unwrap();
let get_request_payment_code = get_request_payment_err.as_service_error().and_then(|e| e.code());
assert!(
matches!(get_request_payment_code, Some("NoSuchBucket")),
"Unexpected GetBucketRequestPayment error code: {:?}, err: {:?}",
get_request_payment_code,
get_request_payment_err
);
let put_accelerate = client
.put_bucket_accelerate_configuration()
.bucket(missing_bucket)
.accelerate_configuration(
AccelerateConfiguration::builder()
.status(BucketAccelerateStatus::Suspended)
.build(),
)
.send()
.await;
assert!(put_accelerate.is_err(), "PutBucketAccelerateConfiguration should fail for missing bucket");
let put_accelerate_err = put_accelerate.err().unwrap();
let put_accelerate_code = put_accelerate_err.as_service_error().and_then(|e| e.code());
assert!(
matches!(put_accelerate_code, Some("NoSuchBucket")),
"Unexpected PutBucketAccelerateConfiguration error code: {:?}, err: {:?}",
put_accelerate_code,
put_accelerate_err
);
let put_request_payment = client
.put_bucket_request_payment()
.bucket(missing_bucket)
.request_payment_configuration(
RequestPaymentConfiguration::builder()
.payer(Payer::BucketOwner)
.build()
.expect("failed to build RequestPaymentConfiguration"),
)
.send()
.await;
assert!(put_request_payment.is_err(), "PutBucketRequestPayment should fail for missing bucket");
let put_request_payment_err = put_request_payment.err().unwrap();
let put_request_payment_code = put_request_payment_err.as_service_error().and_then(|e| e.code());
assert!(
matches!(put_request_payment_code, Some("NoSuchBucket")),
"Unexpected PutBucketRequestPayment error code: {:?}, err: {:?}",
put_request_payment_code,
put_request_payment_err
);
let put_website = client
.put_bucket_website()
.bucket(missing_bucket)
.website_configuration(
WebsiteConfiguration::builder()
.index_document(
IndexDocument::builder()
.suffix("index.html")
.build()
.expect("failed to build IndexDocument"),
)
.build(),
)
.send()
.await;
assert!(put_website.is_err(), "PutBucketWebsite should fail for missing bucket");
let put_website_err = put_website.err().unwrap();
let put_website_code = put_website_err.as_service_error().and_then(|e| e.code());
assert!(
matches!(put_website_code, Some("NoSuchBucket")),
"Unexpected PutBucketWebsite error code: {:?}, err: {:?}",
put_website_code,
put_website_err
);
let get_website = client.get_bucket_website().bucket(missing_bucket).send().await;
assert!(get_website.is_err(), "GetBucketWebsite should fail for missing bucket");
let get_website_err = get_website.err().unwrap();
let get_website_code = get_website_err.as_service_error().and_then(|e| e.code());
assert!(
matches!(get_website_code, Some("NoSuchBucket")),
"Unexpected GetBucketWebsite error code: {:?}, err: {:?}",
get_website_code,
get_website_err
);
let delete_website = client.delete_bucket_website().bucket(missing_bucket).send().await;
assert!(delete_website.is_err(), "DeleteBucketWebsite should fail for missing bucket");
let delete_website_err = delete_website.err().unwrap();
let delete_website_code = delete_website_err.as_service_error().and_then(|e| e.code());
assert!(
matches!(delete_website_code, Some("NoSuchBucket")),
"Unexpected DeleteBucketWebsite error code: {:?}, err: {:?}",
delete_website_code,
delete_website_err
);
env.stop_server();
}
#[tokio::test]
#[serial]
async fn test_dummy_bucket_endpoints_http_contracts() {
init_logging();
info!("Starting test: dummy-compat bucket API HTTP contracts");
if !awscurl_available() {
info!("Skipping test_dummy_bucket_endpoints_http_contracts: awscurl binary not found");
return;
}
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = env.create_s3_client();
let bucket = "test-dummy-bucket-http-contracts";
client
.create_bucket()
.bucket(bucket)
.send()
.await
.expect("Failed to create bucket");
let logging_raw = execute_s3_awscurl("GET", &format!("{}/{bucket}?logging=", env.url), &env.access_key, &env.secret_key)
.expect("GetBucketLogging HTTP request failed");
assert_eq!(parse_status(&logging_raw), Some(200), "GetBucketLogging should return 200");
let logging_body = parse_body(&logging_raw);
assert!(
logging_body.contains("<BucketLoggingStatus"),
"GetBucketLogging response should contain BucketLoggingStatus XML, got: {logging_body}"
);
let accel_raw = execute_s3_awscurl("GET", &format!("{}/{bucket}?accelerate=", env.url), &env.access_key, &env.secret_key)
.expect("GetBucketAccelerateConfiguration HTTP request failed");
assert_eq!(parse_status(&accel_raw), Some(200), "GetBucketAccelerateConfiguration should return 200");
let accel_body = parse_body(&accel_raw);
assert!(
accel_body.contains("<AccelerateConfiguration"),
"GetBucketAccelerateConfiguration response should contain AccelerateConfiguration XML, got: {accel_body}"
);
let payment_raw =
execute_s3_awscurl("GET", &format!("{}/{bucket}?requestPayment=", env.url), &env.access_key, &env.secret_key)
.expect("GetBucketRequestPayment HTTP request failed");
assert_eq!(parse_status(&payment_raw), Some(200), "GetBucketRequestPayment should return 200");
let payment_body = parse_body(&payment_raw);
assert!(
payment_body.contains("<Payer>BucketOwner</Payer>"),
"GetBucketRequestPayment should return BucketOwner payer, got: {payment_body}"
);
let website_raw = execute_s3_awscurl("GET", &format!("{}/{bucket}?website=", env.url), &env.access_key, &env.secret_key)
.expect("GetBucketWebsite HTTP request failed");
assert_eq!(
parse_status(&website_raw),
Some(404),
"GetBucketWebsite should return 404 when website config is absent"
);
let website_content_type = parse_headers(&website_raw).to_ascii_lowercase();
assert!(
website_content_type.contains("content-type:") && website_content_type.contains("xml"),
"GetBucketWebsite error response should be XML, got content-type: {website_content_type}"
);
let website_body = parse_body(&website_raw);
assert!(
website_body.contains("<Code>NoSuchWebsiteConfiguration</Code>"),
"GetBucketWebsite should return NoSuchWebsiteConfiguration code, got: {website_body}"
);
let delete_raw =
execute_s3_awscurl("DELETE", &format!("{}/{bucket}?website=", env.url), &env.access_key, &env.secret_key)
.expect("DeleteBucketWebsite HTTP request failed");
assert_eq!(parse_status(&delete_raw), Some(204), "DeleteBucketWebsite should return 204");
env.stop_server();
}
}
@@ -54,6 +54,10 @@ fn create_user_client(env: &RustFSTestEnvironment, access_key: &str, secret_key:
#[serial]
async fn test_bucket_policy_authenticated_user() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if !crate::common::awscurl_available() {
info!("Skipping test_bucket_policy_authenticated_user because awscurl is not available");
return Ok(());
}
info!("Starting test_bucket_policy_authenticated_user...");
let mut env = RustFSTestEnvironment::new().await?;
+221 -61
View File
@@ -23,7 +23,11 @@
use aws_sdk_s3::config::{Credentials, Region};
use aws_sdk_s3::{Client, Config};
use std::path::PathBuf;
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use reqwest::Client as HttpClient;
use std::ffi::OsStr;
use std::fs as stdfs;
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::sync::Once;
use std::time::Duration;
@@ -32,11 +36,29 @@ use tokio::net::TcpStream;
use tokio::time::sleep;
use tracing::{error, info, warn};
use uuid::Uuid;
use walkdir::WalkDir;
// Common constants for all E2E tests
pub const DEFAULT_ACCESS_KEY: &str = "rustfsadmin";
pub const DEFAULT_SECRET_KEY: &str = "rustfsadmin";
pub const TEST_BUCKET: &str = "e2e-test-bucket";
fn build_test_s3_config(endpoint_url: &str, access_key: &str, secret_key: &str, provider_name: &'static str) -> Config {
let credentials = Credentials::new(access_key, secret_key, None, None, provider_name);
let mut config = Config::builder()
.credentials_provider(credentials)
.region(Region::new("us-east-1"))
.endpoint_url(endpoint_url)
.force_path_style(true)
.behavior_version_latest();
if endpoint_url.starts_with("http://") {
config = config.http_client(SmithyHttpClientBuilder::new().build_http());
}
config.build()
}
pub fn workspace_root() -> PathBuf {
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.pop(); // e2e_test
@@ -44,29 +66,125 @@ pub fn workspace_root() -> PathBuf {
path
}
pub fn local_http_client() -> HttpClient {
HttpClient::builder()
.no_proxy()
.build()
.expect("failed to build local reqwest client")
}
/// Resolve the RustFS binary relative to the workspace.
/// Always builds the binary to ensure it's up to date.
pub fn rustfs_binary_path() -> PathBuf {
rustfs_binary_path_with_features(requested_rustfs_build_features().as_deref())
}
/// Resolve the RustFS binary relative to the workspace, optionally requesting build features.
pub fn rustfs_binary_path_with_features(requested_features: Option<&str>) -> PathBuf {
if let Some(path) = std::env::var_os("CARGO_BIN_EXE_rustfs") {
return PathBuf::from(path);
}
// Always build the binary to ensure it's up to date
info!("Building RustFS binary to ensure it's up to date...");
build_rustfs_binary();
let mut binary_path = workspace_root();
binary_path.push("target");
let profile_dir = if cfg!(debug_assertions) { "debug" } else { "release" };
binary_path.push(profile_dir);
binary_path.push(format!("rustfs{}", std::env::consts::EXE_SUFFIX));
let features_match = binary_features_match(&binary_path, requested_features);
let source_is_newer = workspace_sources_newer_than_binary(&binary_path);
let can_reuse_inside_e2e = running_inside_e2e_test_binary() && requested_features.is_none() && features_match;
if binary_path.is_file() && features_match && (!source_is_newer || can_reuse_inside_e2e) {
if source_is_newer {
warn!(
"RustFS binary at {:?} appears older than workspace sources; reusing it inside cargo test to avoid nested builds",
binary_path
);
}
info!("Using existing RustFS binary at {:?}", binary_path);
return binary_path;
}
info!("Building RustFS binary to ensure it's up to date...");
build_rustfs_binary(requested_features);
info!("Using RustFS binary at {:?}", binary_path);
binary_path
}
fn workspace_sources_newer_than_binary(binary_path: &PathBuf) -> bool {
let Ok(binary_meta) = std::fs::metadata(binary_path) else {
return true;
};
let Ok(binary_modified) = binary_meta.modified() else {
return true;
};
let workspace = workspace_root();
let watch_roots = [
workspace.join("Cargo.toml"),
workspace.join("Cargo.lock"),
workspace.join("rustfs"),
workspace.join("crates"),
];
watch_roots.iter().any(|path| path_is_newer_than(binary_modified, path))
}
fn running_inside_e2e_test_binary() -> bool {
std::env::var("CARGO_PKG_NAME").is_ok_and(|value| value == "e2e_test")
}
fn requested_rustfs_build_features() -> Option<String> {
std::env::var("RUSTFS_BUILD_FEATURES")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn rustfs_binary_features_stamp_path(binary_path: &Path) -> PathBuf {
binary_path.with_extension("features")
}
fn binary_features_match(binary_path: &Path, requested_features: Option<&str>) -> bool {
let stamp_path = rustfs_binary_features_stamp_path(binary_path);
let recorded = stdfs::read_to_string(stamp_path).ok().map(|value| value.trim().to_string());
match requested_features {
Some(features) => recorded.as_deref() == Some(features),
None => recorded.as_deref().is_none_or(str::is_empty),
}
}
fn path_is_newer_than(binary_modified: std::time::SystemTime, path: &Path) -> bool {
if path.is_file() {
return std::fs::metadata(path)
.and_then(|meta| meta.modified())
.map(|modified| modified > binary_modified)
.unwrap_or(false);
}
if !path.is_dir() {
return false;
}
WalkDir::new(path)
.into_iter()
.filter_entry(|entry| {
let name = entry.file_name();
name != OsStr::new("target") && name != OsStr::new(".git")
})
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_file())
.any(|entry| {
std::fs::metadata(entry.path())
.and_then(|meta| meta.modified())
.map(|modified| modified > binary_modified)
.unwrap_or(false)
})
}
/// Build the RustFS binary using cargo
fn build_rustfs_binary() {
fn build_rustfs_binary(requested_features: Option<&str>) {
let workspace = workspace_root();
info!("Building RustFS binary from workspace: {:?}", workspace);
@@ -81,11 +199,8 @@ fn build_rustfs_binary() {
let mut cmd = Command::new("cargo");
cmd.current_dir(&workspace).args(["build", "--bin", "rustfs"]);
// Read features from environment variable for e2e tests
if let Ok(features) = std::env::var("RUSTFS_BUILD_FEATURES")
&& !features.is_empty()
{
cmd.arg("--features").arg(&features);
if let Some(features) = requested_features {
cmd.arg("--features").arg(features);
info!("Building with features: {}", features);
}
@@ -105,6 +220,15 @@ fn build_rustfs_binary() {
panic!("Failed to build RustFS binary. Error: {stderr}");
}
let mut binary_path = workspace;
binary_path.push("target");
binary_path.push(if cfg!(debug_assertions) { "debug" } else { "release" });
binary_path.push(format!("rustfs{}", std::env::consts::EXE_SUFFIX));
let stamp_path = rustfs_binary_features_stamp_path(&binary_path);
if let Err(err) = stdfs::write(&stamp_path, requested_features.unwrap_or_default()) {
warn!("Failed to write RustFS feature stamp {:?}: {}", stamp_path, err);
}
info!("✅ RustFS binary built successfully");
}
@@ -114,6 +238,17 @@ fn awscurl_binary_path() -> PathBuf {
.unwrap_or_else(|| PathBuf::from("awscurl"))
}
pub fn awscurl_available() -> bool {
let path = awscurl_binary_path();
if path.components().count() > 1 || path.is_absolute() {
return path.is_file();
}
std::env::var_os("PATH")
.map(|paths| std::env::split_paths(&paths).any(|dir| dir.join(&path).is_file()))
.unwrap_or(false)
}
// Global initialization
static INIT: Once = Once::new();
@@ -183,24 +318,22 @@ impl RustFSTestEnvironment {
/// Kill any existing RustFS processes
pub async fn cleanup_existing_processes(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Cleaning up any existing RustFS processes");
let binary_path = rustfs_binary_path();
let binary_name = binary_path.to_string_lossy();
let output = Command::new("pkill").args(["-f", &binary_name]).output();
info!("Cleaning up any existing RustFS processes for {}", self.address);
if let Ok(output) = output
&& output.status.success()
{
info!("Killed existing RustFS processes: {}", binary_name);
sleep(Duration::from_millis(1000)).await;
for pattern in [&self.address, &self.temp_dir] {
let output = Command::new("pkill").args(["-f", pattern]).output();
if let Ok(output) = output
&& output.status.success()
{
info!("Killed existing RustFS processes matching: {}", pattern);
sleep(Duration::from_millis(250)).await;
}
}
Ok(())
}
/// Start RustFS server with basic configuration
pub async fn start_rustfs_server(&mut self, extra_args: Vec<&str>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.cleanup_existing_processes().await?;
fn build_start_args<'a>(&'a self, extra_args: Vec<&'a str>) -> Vec<&'a str> {
let mut args = vec![
"--address",
&self.address,
@@ -210,16 +343,29 @@ impl RustFSTestEnvironment {
&self.secret_key,
];
// Add extra arguments
args.extend(extra_args);
// Add temp directory as the last argument
args.push(&self.temp_dir);
args
}
async fn start_rustfs_server_inner(
&mut self,
extra_args: Vec<&str>,
cleanup_existing: bool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if cleanup_existing {
self.cleanup_existing_processes().await?;
}
let args = self.build_start_args(extra_args);
info!("Starting RustFS server with args: {:?}", args);
let binary_path = rustfs_binary_path();
let process = Command::new(&binary_path).args(&args).spawn()?;
let process = Command::new(&binary_path)
.env("RUST_LOG", "rustfs=info,rustfs_notify=debug")
.args(&args)
.spawn()?;
self.process = Some(process);
@@ -229,18 +375,40 @@ impl RustFSTestEnvironment {
Ok(())
}
/// Wait for RustFS server to be ready by checking TCP connectivity
/// Start RustFS server with basic configuration
pub async fn start_rustfs_server(&mut self, extra_args: Vec<&str>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.start_rustfs_server_inner(extra_args, true).await
}
/// Start RustFS server without cleaning up other running RustFS processes.
///
/// This is useful for tests that need multiple independent RustFS instances
/// alive at the same time.
pub async fn start_rustfs_server_without_cleanup(
&mut self,
extra_args: Vec<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.start_rustfs_server_inner(extra_args, false).await
}
/// Wait for RustFS server to be ready.
///
/// A listening TCP port is not sufficient here: the process may accept
/// connections before the S3 stack is fully initialized, which causes
/// early requests to fail intermittently. Treat readiness as "S3 API
/// responds successfully" instead.
pub async fn wait_for_server_ready(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Waiting for RustFS server to be ready on {}", self.address);
let client = self.create_s3_client();
for i in 0..30 {
if TcpStream::connect(&self.address).await.is_ok() {
for i in 0..60 {
if TcpStream::connect(&self.address).await.is_ok() && client.list_buckets().send().await.is_ok() {
info!("✅ RustFS server is ready after {} attempts", i + 1);
return Ok(());
}
if i == 29 {
return Err("RustFS server failed to become ready within 30 seconds".into());
if i == 59 {
return Err("RustFS server failed to become ready within 60 seconds".into());
}
sleep(Duration::from_secs(1)).await;
@@ -251,16 +419,7 @@ impl RustFSTestEnvironment {
/// Create an AWS S3 client configured for this RustFS instance
pub fn create_s3_client(&self) -> Client {
let credentials = Credentials::new(&self.access_key, &self.secret_key, None, None, "e2e-test");
let config = Config::builder()
.credentials_provider(credentials)
.region(Region::new("us-east-1"))
.endpoint_url(&self.url)
.force_path_style(true)
.behavior_version_latest()
.build();
Client::from_conf(config)
Client::from_conf(build_test_s3_config(&self.url, &self.access_key, &self.secret_key, "e2e-test"))
}
/// Create test bucket
@@ -493,6 +652,7 @@ impl RustFSTestClusterEnvironment {
.env("RUSTFS_ACCESS_KEY", &self.access_key)
.env("RUSTFS_SECRET_KEY", &self.secret_key)
.env("RUSTFS_CONSOLE_ENABLE", "false")
.env("RUST_LOG", "rustfs=info,rustfs_notify=debug")
.current_dir(&node.data_dir)
.spawn()?;
@@ -503,7 +663,9 @@ impl RustFSTestClusterEnvironment {
self.wait_for_node_ready(&node.address, i).await?;
}
self.wait_for_service_ready().await?;
for node_idx in 0..self.nodes.len() {
self.wait_for_node_service_ready(node_idx).await?;
}
Ok(())
}
@@ -523,17 +685,17 @@ impl RustFSTestClusterEnvironment {
Err(format!("Node {} failed to become ready", idx).into())
}
/// Wait for the entire cluster's S3-compatible service to be ready (internal helper method).
/// Wait for a specific node's S3-compatible service to be ready (internal helper method).
///
/// Verifies service availability by calling the S3 `list_buckets` API, retries up to 120 times
/// with a 1-second interval between attempts. Fails if the API call remains unsuccessful after all retries.
async fn wait_for_service_ready(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = self.create_s3_client(0)?;
/// Verifies service availability by calling the S3 `list_buckets` API against the requested node,
/// retries up to 120 times with a 1-second interval between attempts.
async fn wait_for_node_service_ready(&self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = self.create_s3_client(node_idx)?;
for attempt in 0..120 {
match client.list_buckets().send().await {
Ok(_) => {
info!("Cluster service ready after {} attempts", attempt + 1);
info!("Cluster node {} service ready after {} attempts", node_idx, attempt + 1);
return Ok(());
}
Err(_) => {
@@ -541,7 +703,8 @@ impl RustFSTestClusterEnvironment {
}
}
}
Err("Cluster service failed to become ready".into())
Err(format!("Cluster node {} service failed to become ready", node_idx).into())
}
/// Create an S3 client configured to communicate with a specific cluster node.
@@ -562,15 +725,12 @@ impl RustFSTestClusterEnvironment {
if node_idx >= self.nodes.len() {
return Err("node_idx is invalid".into());
}
let credentials = Credentials::new(&self.access_key, &self.secret_key, None, None, "cluster-test");
let config = Config::builder()
.credentials_provider(credentials)
.region(Region::new("us-east-1"))
.endpoint_url(&self.nodes[node_idx].url)
.force_path_style(true)
.behavior_version_latest()
.build();
Ok(Client::from_conf(config))
Ok(Client::from_conf(build_test_s3_config(
&self.nodes[node_idx].url,
&self.access_key,
&self.secret_key,
"cluster-test",
)))
}
/// Create S3 clients for all nodes in the RustFS cluster and collect them into a vector.
+29 -10
View File
@@ -22,11 +22,13 @@
//! - KMS backend configuration (Local and Vault)
//! - SSE encryption testing utilities
use crate::common::{RustFSTestEnvironment, awscurl_get, awscurl_post, init_logging as common_init_logging};
use crate::common::{
RustFSTestEnvironment, awscurl_available, awscurl_get, awscurl_post, init_logging as common_init_logging, local_http_client,
};
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::ServerSideEncryption;
use base64::Engine;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use serde_json;
use std::process::{Child, Command};
use std::time::Duration;
@@ -51,6 +53,19 @@ pub fn init_logging() {
// Additional KMS-specific logging configuration can be added here if needed
}
pub fn skip_if_kms_admin_tool_unavailable(test_name: &str) -> bool {
if awscurl_available() {
return false;
}
info!("Skipping {} because awscurl is not available in PATH", test_name);
true
}
pub fn sse_customer_key_md5_base64(key: &str) -> String {
BASE64.encode(md5::compute(key).0)
}
// KMS-specific helper functions
/// Configure KMS backend via admin API
pub async fn configure_kms(
@@ -133,10 +148,10 @@ pub async fn create_key_with_specific_id(key_dir: &str, key_id: &str) -> Result<
"usage": "EncryptDecrypt",
"status": "Active",
"metadata": HashMap::<String, String>::new(),
"created_at": chrono::Utc::now().to_rfc3339(),
"created_at": format!("{}[UTC]", chrono::Utc::now().to_rfc3339()),
"rotated_at": serde_json::Value::Null,
"created_by": "e2e-test",
"encrypted_key_material": key_data.to_vec(),
"encrypted_key_material": BASE64.encode(key_data),
"nonce": Vec::<u8>::new()
});
@@ -155,7 +170,7 @@ pub async fn test_sse_c_encryption(s3_client: &Client, bucket: &str) -> Result<(
let test_key = "01234567890123456789012345678901"; // 32-byte key
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
let test_key_md5 = format!("{:x}", md5::compute(test_key));
let test_key_md5 = sse_customer_key_md5_base64(test_key);
let test_data = b"Hello, KMS SSE-C World!";
let object_key = "test-sse-c-object";
@@ -272,6 +287,10 @@ pub async fn test_kms_key_management(
access_key: &str,
secret_key: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if skip_if_kms_admin_tool_unavailable("test_kms_key_management") {
return Ok(());
}
info!("Testing KMS key management APIs");
// Test CreateKey
@@ -324,8 +343,8 @@ pub async fn test_error_scenarios(s3_client: &Client, bucket: &str) -> Result<()
let wrong_key = "98765432109876543210987654321098";
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
let wrong_key_b64 = base64::engine::general_purpose::STANDARD.encode(wrong_key);
let test_key_md5 = format!("{:x}", md5::compute(test_key));
let wrong_key_md5 = format!("{:x}", md5::compute(wrong_key));
let test_key_md5 = sse_customer_key_md5_base64(test_key);
let wrong_key_md5 = sse_customer_key_md5_base64(wrong_key);
let test_data = b"Test data for error scenarios";
let object_key = "test-error-object";
@@ -406,7 +425,7 @@ impl VaultTestEnvironment {
let port_check = TcpStream::connect(VAULT_ADDRESS).await.is_ok();
if port_check {
// Additional check by making a health request
if let Ok(response) = reqwest::get(&format!("{VAULT_URL}/v1/sys/health")).await
if let Ok(response) = local_http_client().get(format!("{VAULT_URL}/v1/sys/health")).send().await
&& response.status().is_success()
{
info!("Vault server is ready after {} seconds", i);
@@ -426,7 +445,7 @@ impl VaultTestEnvironment {
/// Setup Vault transit secrets engine
pub async fn setup_vault_transit(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = reqwest::Client::new();
let client = local_http_client();
info!("Enabling Vault transit secrets engine");
@@ -687,7 +706,7 @@ pub async fn test_multipart_upload_with_config(
/// Create a standard SSE-C encryption configuration for testing
pub fn create_sse_c_config() -> EncryptionType {
let key = "01234567890123456789012345678901"; // 32-byte key
let key_md5 = format!("{:x}", md5::compute(key));
let key_md5 = sse_customer_key_md5_base64(key);
EncryptionType::SSEC {
key: key.to_string(),
key_md5,
@@ -26,27 +26,20 @@ use serial_test::serial;
use std::collections::{HashMap, VecDeque};
use tracing::info;
fn assert_encryption_metadata(metadata: &HashMap<String, String>, expected_size: usize) {
fn assert_managed_encryption_metadata_hidden(metadata: Option<&HashMap<String, String>>) {
let Some(metadata) = metadata else { return };
for key in [
"x-rustfs-encryption-key",
"x-rustfs-encryption-iv",
"x-rustfs-encryption-context",
"x-rustfs-encryption-original-size",
] {
assert!(metadata.contains_key(key), "expected managed encryption metadata '{key}' to be present");
assert!(
!metadata.get(key).unwrap().is_empty(),
"managed encryption metadata '{key}' should not be empty"
!metadata.contains_key(key),
"managed encryption metadata '{key}' should not be exposed to clients"
);
}
let size_value = metadata
.get("x-rustfs-encryption-original-size")
.expect("managed encryption metadata should include original size");
let parsed_size: usize = size_value
.parse()
.expect("x-rustfs-encryption-original-size should be numeric");
assert_eq!(parsed_size, expected_size, "recorded original size should match uploaded payload length");
}
fn assert_storage_encrypted(storage_root: &std::path::Path, bucket: &str, key: &str, plaintext: &[u8]) {
@@ -142,10 +135,7 @@ async fn test_head_reports_managed_metadata_for_sse_s3() -> Result<(), Box<dyn s
"head_object should advertise SSE-S3"
);
let metadata = head
.metadata()
.expect("head_object should return managed encryption metadata");
assert_encryption_metadata(metadata, payload.len());
assert_managed_encryption_metadata_hidden(head.metadata());
assert_storage_encrypted(std::path::Path::new(&kms_env.base_env.temp_dir), TEST_BUCKET, key, payload);
@@ -210,10 +200,7 @@ async fn test_head_reports_managed_metadata_for_sse_kms_and_copy() -> Result<(),
&default_key_id,
"source object should maintain the configured KMS key id"
);
let source_metadata = head_source
.metadata()
.expect("source object should include managed encryption metadata");
assert_encryption_metadata(source_metadata, payload.len());
assert_managed_encryption_metadata_hidden(head_source.metadata());
let dest_key = "metadata-sse-kms-object-copy";
let copy_source = format!("{TEST_BUCKET}/{source_key}");
@@ -238,10 +225,7 @@ async fn test_head_reports_managed_metadata_for_sse_kms_and_copy() -> Result<(),
&default_key_id,
"copied object should keep the default KMS key id"
);
let dest_metadata = head_dest
.metadata()
.expect("copied object should include managed encryption metadata");
assert_encryption_metadata(dest_metadata, payload.len());
assert_managed_encryption_metadata_hidden(head_dest.metadata());
let copied_body = s3_client
.get_object()
@@ -358,10 +342,7 @@ async fn test_multipart_upload_writes_encrypted_data() -> Result<(), Box<dyn std
"multipart object should retain bucket default KMS key"
);
assert_encryption_metadata(
head.metadata().expect("multipart head_object should expose managed metadata"),
combined.len(),
);
assert_managed_encryption_metadata_hidden(head.metadata());
// Data returned to clients should decrypt back to original payload
let fetched = s3_client
@@ -19,9 +19,9 @@
//! complex workflows.
use super::common::{
EncryptionType, LocalKMSTestEnvironment, MultipartTestConfig, create_sse_c_config, test_all_multipart_encryption_types,
test_kms_key_management, test_multipart_upload_with_config, test_sse_c_encryption, test_sse_kms_encryption,
test_sse_s3_encryption,
EncryptionType, LocalKMSTestEnvironment, MultipartTestConfig, create_sse_c_config, sse_customer_key_md5_base64,
test_all_multipart_encryption_types, test_kms_key_management, test_multipart_upload_with_config, test_sse_c_encryption,
test_sse_kms_encryption, test_sse_s3_encryption,
};
use crate::common::{TEST_BUCKET, init_logging};
use serial_test::serial;
@@ -149,8 +149,8 @@ async fn test_comprehensive_key_isolation() -> Result<(), Box<dyn std::error::Er
// Test different SSE-C keys to ensure isolation
let key1 = "01234567890123456789012345678901";
let key2 = "98765432109876543210987654321098";
let key1_md5 = format!("{:x}", md5::compute(key1));
let key2_md5 = format!("{:x}", md5::compute(key2));
let key1_md5 = sse_customer_key_md5_base64(key1);
let key2_md5 = sse_customer_key_md5_base64(key2);
let config1 = MultipartTestConfig::new(
"isolation-test-key1",
@@ -183,7 +183,7 @@ async fn test_comprehensive_key_isolation() -> Result<(), Box<dyn std::error::Er
info!("🔒 Verify key isolation");
let wrong_key = "11111111111111111111111111111111";
let wrong_key_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, wrong_key);
let wrong_key_md5 = format!("{:x}", md5::compute(wrong_key));
let wrong_key_md5 = sse_customer_key_md5_base64(wrong_key);
// Try to read file encrypted with key1 using wrong key
let wrong_read_result = s3_client
@@ -21,7 +21,7 @@
//! - Concurrent encryption operations
//! - Security validation tests
use super::common::LocalKMSTestEnvironment;
use super::common::{LocalKMSTestEnvironment, sse_customer_key_md5_base64};
use crate::common::{TEST_BUCKET, init_logging};
use aws_sdk_s3::types::ServerSideEncryption;
use base64::Engine;
@@ -72,7 +72,7 @@ async fn test_kms_zero_byte_file_encryption() -> Result<(), Box<dyn std::error::
info!("📤 Testing SSE-C with zero-byte file");
let test_key = "01234567890123456789012345678901";
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
let test_key_md5 = format!("{:x}", compute(test_key));
let test_key_md5 = sse_customer_key_md5_base64(test_key);
let object_key_c = "zero-byte-sse-c";
let _put_response_c = s3_client
@@ -166,7 +166,7 @@ async fn test_kms_single_byte_file_encryption() -> Result<(), Box<dyn std::error
info!("📤 Testing SSE-C with single-byte file");
let test_key = "01234567890123456789012345678901";
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
let test_key_md5 = format!("{:x}", compute(test_key));
let test_key_md5 = sse_customer_key_md5_base64(test_key);
let object_key_c = "single-byte-sse-c";
s3_client
@@ -334,7 +334,7 @@ async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Erro
info!("🔍 Testing access to SSE-C object without key");
// First upload a valid SSE-C object
let valid_key_md5 = format!("{:x}", compute(valid_key));
let valid_key_md5 = sse_customer_key_md5_base64(valid_key);
s3_client
.put_object()
.bucket(TEST_BUCKET)
@@ -421,7 +421,7 @@ async fn test_kms_concurrent_encryption() -> Result<(), Box<dyn std::error::Erro
// SSE-C
let key = format!("testkey{i:026}"); // 32-byte key
let key_b64 = base64::engine::general_purpose::STANDARD.encode(&key);
let key_md5 = format!("{:x}", compute(&key));
let key_md5 = sse_customer_key_md5_base64(&key);
client
.put_object()
@@ -493,8 +493,8 @@ async fn test_kms_key_validation_security() -> Result<(), Box<dyn std::error::Er
let key1_b64 = base64::engine::general_purpose::STANDARD.encode(key1);
let key2_b64 = base64::engine::general_purpose::STANDARD.encode(key2);
let key1_md5 = format!("{:x}", compute(key1));
let key2_md5 = format!("{:x}", compute(key2));
let key1_md5 = sse_customer_key_md5_base64(key1);
let key2_md5 = sse_customer_key_md5_base64(key2);
// Upload same data with different keys
s3_client
+10 -4
View File
@@ -19,7 +19,10 @@
//! - S3 object upload/download with SSE-S3, SSE-KMS, SSE-C encryption
//! - Complete encryption/decryption lifecycle
use super::common::{LocalKMSTestEnvironment, get_kms_status, test_kms_key_management, test_sse_c_encryption};
use super::common::{
LocalKMSTestEnvironment, get_kms_status, skip_if_kms_admin_tool_unavailable, sse_customer_key_md5_base64,
test_kms_key_management, test_sse_c_encryption,
};
use crate::common::{TEST_BUCKET, init_logging};
use serial_test::serial;
use tracing::{error, info};
@@ -28,6 +31,9 @@ use tracing::{error, info};
#[serial]
async fn test_local_kms_end_to_end() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_if_kms_admin_tool_unavailable("test_local_kms_end_to_end") {
return Ok(());
}
info!("Starting Local KMS End-to-End Test");
// Create LocalKMS test environment
@@ -140,8 +146,8 @@ async fn test_local_kms_key_isolation() {
let key2 = "98765432109876543210987654321098";
let key1_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key1);
let key2_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key2);
let key1_md5 = format!("{:x}", md5::compute(key1));
let key2_md5 = format!("{:x}", md5::compute(key2));
let key1_md5 = sse_customer_key_md5_base64(key1);
let key2_md5 = sse_customer_key_md5_base64(key2);
let data1 = b"Data encrypted with key 1";
let data2 = b"Data encrypted with key 2";
@@ -562,7 +568,7 @@ async fn test_multipart_upload_with_sse_c(
// SSE-C encryption key
let encryption_key = "01234567890123456789012345678901";
let key_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, encryption_key);
let key_md5 = format!("{:x}", md5::compute(encryption_key));
let key_md5 = sse_customer_key_md5_base64(encryption_key);
// Generate test data
let test_data: Vec<u8> = (0..total_size).map(|i| ((i * 3) % 256) as u8).collect();
+20 -5
View File
@@ -19,14 +19,14 @@
//! multipart upload behaviour.
use crate::common::{TEST_BUCKET, init_logging};
use md5::compute;
use serial_test::serial;
use tokio::time::{Duration, sleep};
use tracing::{error, info};
use super::common::{
VAULT_KEY_NAME, VaultTestEnvironment, get_kms_status, start_kms, test_all_multipart_encryption_types, test_error_scenarios,
test_kms_key_management, test_sse_c_encryption, test_sse_kms_encryption, test_sse_s3_encryption,
VAULT_KEY_NAME, VaultTestEnvironment, get_kms_status, skip_if_kms_admin_tool_unavailable, sse_customer_key_md5_base64,
start_kms, test_all_multipart_encryption_types, test_error_scenarios, test_kms_key_management, test_sse_c_encryption,
test_sse_kms_encryption, test_sse_s3_encryption,
};
/// Helper that brings up Vault, configures RustFS, and starts the KMS service.
@@ -65,6 +65,9 @@ impl VaultKmsTestContext {
#[serial]
async fn test_vault_kms_end_to_end() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_if_kms_admin_tool_unavailable("test_vault_kms_end_to_end") {
return Ok(());
}
info!("Starting Vault KMS End-to-End Test with default key {}", VAULT_KEY_NAME);
let context = VaultKmsTestContext::new().await?;
@@ -118,6 +121,9 @@ async fn test_vault_kms_end_to_end() -> Result<(), Box<dyn std::error::Error + S
#[serial]
async fn test_vault_kms_key_isolation() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_if_kms_admin_tool_unavailable("test_vault_kms_key_isolation") {
return Ok(());
}
info!("Starting Vault KMS SSE-C key isolation test");
let context = VaultKmsTestContext::new().await?;
@@ -133,8 +139,8 @@ async fn test_vault_kms_key_isolation() -> Result<(), Box<dyn std::error::Error
let key2 = "98765432109876543210987654321098";
let key1_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key1);
let key2_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key2);
let key1_md5 = format!("{:x}", compute(key1));
let key2_md5 = format!("{:x}", compute(key2));
let key1_md5 = sse_customer_key_md5_base64(key1);
let key2_md5 = sse_customer_key_md5_base64(key2);
let data1 = b"Vault data encrypted with key 1";
let data2 = b"Vault data encrypted with key 2";
@@ -202,6 +208,9 @@ async fn test_vault_kms_key_isolation() -> Result<(), Box<dyn std::error::Error
#[serial]
async fn test_vault_kms_large_file() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_if_kms_admin_tool_unavailable("test_vault_kms_large_file") {
return Ok(());
}
info!("Starting Vault KMS large file SSE-S3 test");
let context = VaultKmsTestContext::new().await?;
@@ -264,6 +273,9 @@ async fn test_vault_kms_large_file() -> Result<(), Box<dyn std::error::Error + S
#[serial]
async fn test_vault_kms_multipart_upload() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_if_kms_admin_tool_unavailable("test_vault_kms_multipart_upload") {
return Ok(());
}
info!("Starting Vault KMS multipart upload encryption suite");
let context = VaultKmsTestContext::new().await?;
@@ -292,6 +304,9 @@ async fn test_vault_kms_multipart_upload() -> Result<(), Box<dyn std::error::Err
#[serial]
async fn test_vault_kms_key_operations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_if_kms_admin_tool_unavailable("test_vault_kms_key_operations") {
return Ok(());
}
info!("Starting Vault KMS key operations test (CRUD)");
let context = VaultKmsTestContext::new().await?;
@@ -21,7 +21,7 @@
//! 3. Test the saving and reading of encrypted metadata
//! 4. Test the complete sharded upload encryption process
use super::common::LocalKMSTestEnvironment;
use super::common::{LocalKMSTestEnvironment, sse_customer_key_md5_base64};
use crate::common::{TEST_BUCKET, init_logging};
use serial_test::serial;
use tracing::{debug, info};
@@ -504,7 +504,7 @@ async fn test_multipart_encryption_type(
let (sse_c_key, sse_c_md5) = if matches!(encryption_type, EncryptionType::SSEC) {
let key = "01234567890123456789012345678901";
let key_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key);
let key_md5 = format!("{:x}", md5::compute(key));
let key_md5 = sse_customer_key_md5_base64(key);
(Some(key_b64), Some(key_md5))
} else {
(None, None)
+16
View File
@@ -88,3 +88,19 @@ mod checksum_upload_test;
// Group deletion tests
#[cfg(test)]
mod group_delete_test;
// S3 dummy-compat bucket API tests
#[cfg(test)]
mod bucket_logging_test;
// Multipart control API auth regression tests
#[cfg(test)]
mod multipart_auth_test;
// Object lambda end-to-end regression tests
#[cfg(test)]
mod object_lambda_test;
// Replication extension end-to-end regression tests
#[cfg(test)]
mod replication_extension_test;
File diff suppressed because it is too large Load Diff
+985
View File
@@ -0,0 +1,985 @@
// 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.
use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging, local_http_client};
use aws_sdk_s3::primitives::ByteStream;
use http::header::{CONTENT_TYPE, HOST};
use reqwest::StatusCode;
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::{pre_sign_v4, sign_v4};
use s3s::Body;
use serial_test::serial;
use std::collections::HashMap;
use std::error::Error;
use time::OffsetDateTime;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio::time::{Duration, timeout};
#[derive(Debug)]
struct CapturedWebhookRequest {
headers: HashMap<String, String>,
payload: serde_json::Value,
}
struct WebhookResponseSpec {
status_line: String,
body: Vec<u8>,
headers: Vec<(String, String)>,
include_auth_headers: bool,
auth_route_override: Option<String>,
auth_token_override: Option<String>,
}
fn find_header_terminator(buf: &[u8]) -> Option<usize> {
buf.windows(4).position(|window| window == b"\r\n\r\n")
}
async fn read_http_request(
stream: &mut tokio::net::TcpStream,
) -> Result<(HashMap<String, String>, Vec<u8>), Box<dyn Error + Send + Sync>> {
let mut buffer = Vec::new();
let mut chunk = [0_u8; 4096];
let header_end = loop {
let read = stream.read(&mut chunk).await?;
if read == 0 {
return Err("webhook request ended before headers were fully received".into());
}
buffer.extend_from_slice(&chunk[..read]);
if let Some(pos) = find_header_terminator(&buffer) {
break pos;
}
};
let header_bytes = &buffer[..header_end];
let header_text = std::str::from_utf8(header_bytes)?;
let mut lines = header_text.split("\r\n");
let _request_line = lines.next().ok_or("missing request line")?;
let mut headers = HashMap::new();
for line in lines {
if line.is_empty() {
continue;
}
let (name, value) = line.split_once(':').ok_or("invalid header line")?;
headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
}
let content_length = headers
.get("content-length")
.ok_or("missing content-length header")?
.parse::<usize>()?;
let body_offset = header_end + 4;
while buffer.len().saturating_sub(body_offset) < content_length {
let read = stream.read(&mut chunk).await?;
if read == 0 {
return Err("webhook request ended before body was fully received".into());
}
buffer.extend_from_slice(&chunk[..read]);
}
Ok((headers, buffer[body_offset..body_offset + content_length].to_vec()))
}
async fn spawn_object_lambda_webhook_server() -> Result<
(
String,
oneshot::Receiver<CapturedWebhookRequest>,
tokio::task::JoinHandle<Result<(), Box<dyn Error + Send + Sync>>>,
),
Box<dyn Error + Send + Sync>,
> {
spawn_object_lambda_webhook_server_with_response(WebhookResponseSpec {
status_line: "200 OK".to_string(),
body: b"transformed through object lambda".to_vec(),
headers: vec![("content-type".to_string(), "text/plain".to_string())],
include_auth_headers: true,
auth_route_override: None,
auth_token_override: None,
})
.await
}
async fn spawn_object_lambda_webhook_server_with_response(
response_spec: WebhookResponseSpec,
) -> Result<
(
String,
oneshot::Receiver<CapturedWebhookRequest>,
tokio::task::JoinHandle<Result<(), Box<dyn Error + Send + Sync>>>,
),
Box<dyn Error + Send + Sync>,
> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let address = listener.local_addr()?;
let webhook_url = format!("http://{address}/transform");
let (request_tx, request_rx) = oneshot::channel();
let handle = tokio::spawn(async move {
loop {
let (mut stream, _) = listener.accept().await?;
let Ok(Ok((headers, body))) = timeout(Duration::from_secs(2), read_http_request(&mut stream)).await else {
continue;
};
let payload: serde_json::Value = serde_json::from_slice(&body)?;
let output_route = payload["getObjectContext"]["outputRoute"]
.as_str()
.ok_or("missing outputRoute in webhook payload")?
.to_string();
let output_token = payload["getObjectContext"]["outputToken"]
.as_str()
.ok_or("missing outputToken in webhook payload")?
.to_string();
let _ = request_tx.send(CapturedWebhookRequest { headers, payload });
let mut response_head = format!(
"HTTP/1.1 {}\r\ncontent-length: {}\r\nconnection: close\r\n",
response_spec.status_line,
response_spec.body.len()
);
for (name, value) in &response_spec.headers {
response_head.push_str(&format!("{name}: {value}\r\n"));
}
if response_spec.include_auth_headers {
let auth_route = response_spec.auth_route_override.as_deref().unwrap_or(&output_route);
let auth_token = response_spec.auth_token_override.as_deref().unwrap_or(&output_token);
response_head.push_str(&format!("x-amz-request-route: {auth_route}\r\n"));
response_head.push_str(&format!("x-amz-request-token: {auth_token}\r\n"));
}
response_head.push_str("\r\n");
stream.write_all(response_head.as_bytes()).await?;
stream.write_all(&response_spec.body).await?;
stream.shutdown().await?;
return Ok(());
}
});
Ok((webhook_url, request_rx, handle))
}
async fn presigned_get_request(
url: &str,
access_key: &str,
secret_key: &str,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let uri = url.parse::<http::Uri>()?;
let authority = uri.authority().ok_or("request URL missing authority")?.to_string();
let signed = pre_sign_v4(
http::Request::builder()
.method(http::Method::GET)
.uri(uri)
.header(HOST, authority)
.body(Body::empty())?,
access_key,
secret_key,
"",
"us-east-1",
600,
OffsetDateTime::now_utc(),
);
Ok(local_http_client().get(signed.uri().to_string()).send().await?)
}
async fn signed_request(
method: http::Method,
url: &str,
access_key: &str,
secret_key: &str,
body: Option<Vec<u8>>,
content_type: Option<&str>,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let uri = url.parse::<http::Uri>()?;
let authority = uri.authority().ok_or("request URL missing authority")?.to_string();
let mut request = http::Request::builder().method(method.clone()).uri(uri);
request = request.header(HOST, authority);
request = request.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
if let Some(content_type) = content_type {
request = request.header(CONTENT_TYPE, content_type);
}
let content_len = body.as_ref().map(|body| body.len() as i64).unwrap_or_default();
let signed = sign_v4(request.body(Body::empty())?, content_len, access_key, secret_key, "", "us-east-1");
let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())?;
let client = local_http_client();
let mut request_builder = client.request(reqwest_method, url);
for (name, value) in signed.headers() {
request_builder = request_builder.header(name, value);
}
if let Some(body) = body {
request_builder = request_builder.body(body);
}
Ok(request_builder.send().await?)
}
async fn configure_webhook_target(
env: &RustFSTestEnvironment,
target_name: &str,
endpoint: &str,
auth_token: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
configure_webhook_target_with_key_values(
env,
target_name,
vec![
("endpoint", endpoint.to_string()),
("auth_token", auth_token.to_string()),
("queue_dir", format!("{}/notify-queue", env.temp_dir)),
],
)
.await
}
async fn configure_webhook_target_with_key_values(
env: &RustFSTestEnvironment,
target_name: &str,
key_values: Vec<(&str, String)>,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let queue_dir = format!("{}/notify-queue", env.temp_dir);
tokio::fs::create_dir_all(&queue_dir).await?;
let mut key_values = key_values
.into_iter()
.map(|(key, value)| serde_json::json!({ "key": key, "value": value }))
.collect::<Vec<_>>();
if !key_values.iter().any(|entry| entry["key"].as_str() == Some("queue_dir")) {
key_values.push(serde_json::json!({ "key": "queue_dir", "value": queue_dir }));
}
let response = send_configure_webhook_target_request(env, target_name, key_values).await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("failed to configure object lambda webhook target: {status} {body}").into());
}
Ok(())
}
async fn send_configure_webhook_target_request(
env: &RustFSTestEnvironment,
target_name: &str,
key_values: Vec<serde_json::Value>,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let payload = serde_json::json!({ "key_values": key_values });
let url = format!("{}/rustfs/admin/v3/target/notify_webhook/{}", env.url, target_name);
signed_request(
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(payload.to_string().into_bytes()),
Some("application/json"),
)
.await
}
async fn list_notification_targets(env: &RustFSTestEnvironment) -> Result<serde_json::Value, Box<dyn Error + Send + Sync>> {
let url = format!("{}/rustfs/admin/v3/target/list", env.url);
let response = signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await?;
let status = response.status();
let body = response.bytes().await?;
if status != StatusCode::OK {
return Err(format!("failed to list notification targets: {status} {}", String::from_utf8_lossy(body.as_ref())).into());
}
Ok(serde_json::from_slice(&body)?)
}
async fn list_target_arns(env: &RustFSTestEnvironment) -> Result<Vec<String>, Box<dyn Error + Send + Sync>> {
let url = format!("{}/rustfs/admin/v3/target/arns", env.url);
let response = signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await?;
let status = response.status();
let body = response.bytes().await?;
if status != StatusCode::OK {
return Err(format!("failed to list target arns: {status} {}", String::from_utf8_lossy(body.as_ref())).into());
}
Ok(serde_json::from_slice(&body)?)
}
async fn wait_for_target_visibility(
env: &RustFSTestEnvironment,
target_name: &str,
) -> Result<(serde_json::Value, Vec<String>), Box<dyn Error + Send + Sync>> {
let mut last_targets = serde_json::Value::Null;
let mut last_arns = Vec::new();
for _ in 0..20 {
last_targets = list_notification_targets(env).await?;
last_arns = list_target_arns(env).await?;
let listed = last_targets["notification_endpoints"]
.as_array()
.into_iter()
.flatten()
.any(|entry| {
entry["account_id"].as_str() == Some(target_name)
&& entry["service"]
.as_str()
.is_some_and(|service| service == "webhook" || service.starts_with("webhook-"))
});
if listed {
return Ok((last_targets, last_arns));
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
Err(format!("target {target_name} did not become visible in admin APIs; targets={last_targets}, arns={last_arns:?}").into())
}
async fn read_persisted_server_config(env: &RustFSTestEnvironment) -> String {
let path = format!("{}/.rustfs.sys/config/config.json", env.temp_dir);
match tokio::fs::read_to_string(&path).await {
Ok(content) => content,
Err(err) => format!("failed to read persisted config at {path}: {err}"),
}
}
async fn read_listen_notification_event(
response: reqwest::Response,
expected_key: &str,
) -> Result<String, Box<dyn Error + Send + Sync>> {
let mut response = response;
let mut pending = String::new();
loop {
let chunk = timeout(Duration::from_secs(12), response.chunk()).await??;
let Some(chunk) = chunk else {
return Err("listen_notification stream ended before payload".into());
};
if chunk.is_empty() {
continue;
}
pending.push_str(&String::from_utf8(chunk.to_vec())?);
while let Some(newline) = pending.find('\n') {
let line = pending.drain(..=newline).collect::<String>();
let payload = line.trim();
if payload.is_empty() {
continue;
}
let json: serde_json::Value = serde_json::from_str(payload)?;
let Some(records) = json["Records"].as_array() else {
continue;
};
if records.is_empty() {
continue;
}
let has_expected_key = records.iter().any(|record| {
let Some(object_key) = record["s3"]["object"]["key"].as_str() else {
return false;
};
let decoded = urlencoding::decode(object_key)
.map(|decoded| decoded.into_owned())
.unwrap_or_else(|_| object_key.to_string());
decoded == expected_key
});
if has_expected_key {
return Ok(payload.to_string());
}
}
}
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_accepts_presigned_requests() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let (webhook_url, request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "object-lambda-e2e-presigned";
let key = "input.txt";
let object_body = b"hello presigned object lambda";
let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(object_body))
.send()
.await?;
configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?;
wait_for_target_visibility(&env, "transformer").await?;
let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
let response = presigned_get_request(&lambda_url, &env.access_key, &env.secret_key).await?;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.text().await?, "transformed through object lambda");
let captured = timeout(Duration::from_secs(10), request_rx).await??;
assert_eq!(captured.payload["configuration"]["accessPointArn"].as_str(), Some(lambda_arn));
webhook_handle.await??;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_accepts_named_webhook_target_arn() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let (webhook_url, request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "object-lambda-e2e-named-target";
let key = "input.txt";
let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook-preview";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"hello object lambda"))
.send()
.await?;
configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?;
wait_for_target_visibility(&env, "transformer").await?;
let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.text().await?, "transformed through object lambda");
let captured = timeout(Duration::from_secs(10), request_rx).await??;
assert_eq!(captured.payload["configuration"]["accessPointArn"].as_str(), Some(lambda_arn));
webhook_handle.await??;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_invokes_runtime_webhook_target() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let (webhook_url, request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "object-lambda-e2e";
let key = "input.txt";
let object_body = b"hello object lambda";
let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(object_body))
.send()
.await?;
configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?;
let (visible_targets, visible_arns) = wait_for_target_visibility(&env, "transformer").await?;
let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let persisted_config = read_persisted_server_config(&env).await;
return Err(format!(
"object lambda request failed: {status} {body}; visible_targets={visible_targets}; visible_arns={visible_arns:?}; persisted_config={persisted_config}"
)
.into());
}
assert_eq!(
response.headers().get(CONTENT_TYPE).and_then(|value| value.to_str().ok()),
Some("text/plain")
);
assert_eq!(response.text().await?, "transformed through object lambda");
let captured = timeout(Duration::from_secs(10), request_rx).await??;
assert_eq!(captured.headers.get("authorization").map(String::as_str), Some("Bearer secret-token"));
assert_eq!(captured.headers.get("x-rustfs-object-lambda-bucket").map(String::as_str), Some(bucket));
assert_eq!(captured.headers.get("x-rustfs-object-lambda-key").map(String::as_str), Some(key));
assert_eq!(captured.payload["configuration"]["accessPointArn"].as_str(), Some(lambda_arn));
let expected_request_url = format!("/{bucket}/{key}?lambdaArn={}", urlencoding::encode(lambda_arn));
assert_eq!(captured.payload["userRequest"]["url"].as_str(), Some(expected_request_url.as_str()));
let input_s3_url = captured.payload["getObjectContext"]["inputS3Url"]
.as_str()
.ok_or("missing inputS3Url in object lambda payload")?;
assert!(!input_s3_url.contains("lambdaArn="));
let source_response = local_http_client().get(input_s3_url).send().await?;
assert_eq!(source_response.status(), StatusCode::OK);
assert_eq!(source_response.bytes().await?.as_ref(), object_body);
webhook_handle.await??;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_passthroughs_non_success_webhook_response() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let (webhook_url, _request_rx, webhook_handle) = spawn_object_lambda_webhook_server_with_response(WebhookResponseSpec {
status_line: "418 I'm a teapot".to_string(),
body: b"lambda upstream rejected".to_vec(),
headers: vec![
("content-type".to_string(), "text/plain".to_string()),
("x-rustfs-debug".to_string(), "passthrough".to_string()),
("x-amz-request-route".to_string(), "should-not-leak".to_string()),
("x-amz-request-token".to_string(), "should-not-leak".to_string()),
],
include_auth_headers: false,
auth_route_override: None,
auth_token_override: None,
})
.await?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "object-lambda-e2e-failure";
let key = "input.txt";
let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"hello object lambda"))
.send()
.await?;
configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?;
wait_for_target_visibility(&env, "transformer").await?;
let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
assert_eq!(response.status(), StatusCode::IM_A_TEAPOT);
assert_eq!(
response.headers().get("content-type").and_then(|value| value.to_str().ok()),
Some("text/plain")
);
assert_eq!(
response.headers().get("x-rustfs-debug").and_then(|value| value.to_str().ok()),
Some("passthrough")
);
assert!(response.headers().get("x-amz-request-route").is_none());
assert!(response.headers().get("x-amz-request-token").is_none());
assert_eq!(response.text().await?, "lambda upstream rejected");
webhook_handle.await??;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_rejects_success_response_without_auth_headers() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let (webhook_url, _request_rx, webhook_handle) = spawn_object_lambda_webhook_server_with_response(WebhookResponseSpec {
status_line: "200 OK".to_string(),
body: b"missing auth headers".to_vec(),
headers: vec![("content-type".to_string(), "text/plain".to_string())],
include_auth_headers: false,
auth_route_override: None,
auth_token_override: None,
})
.await?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "object-lambda-e2e-missing-auth";
let key = "input.txt";
let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"hello object lambda"))
.send()
.await?;
configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?;
wait_for_target_visibility(&env, "transformer").await?;
let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response.text().await?;
assert!(body.contains("authorization headers"), "unexpected error body: {body}");
webhook_handle.await??;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_rejects_success_response_with_mismatched_auth_headers() -> Result<(), Box<dyn Error + Send + Sync>>
{
init_logging();
let (webhook_url, _request_rx, webhook_handle) = spawn_object_lambda_webhook_server_with_response(WebhookResponseSpec {
status_line: "200 OK".to_string(),
body: b"mismatched auth headers".to_vec(),
headers: vec![("content-type".to_string(), "text/plain".to_string())],
include_auth_headers: true,
auth_route_override: Some("wrong-route".to_string()),
auth_token_override: Some("wrong-token".to_string()),
})
.await?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "object-lambda-e2e-mismatched-auth";
let key = "input.txt";
let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"hello object lambda"))
.send()
.await?;
configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?;
wait_for_target_visibility(&env, "transformer").await?;
let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response.text().await?;
assert!(body.contains("authorization headers"), "unexpected error body: {body}");
webhook_handle.await??;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_rejects_unsupported_target_type() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "object-lambda-e2e-unsupported-target";
let key = "input.txt";
let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:mqtt";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"hello object lambda"))
.send()
.await?;
let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::NOT_IMPLEMENTED);
assert!(body.contains("NotImplemented"), "unexpected error body: {body}");
assert!(
body.to_ascii_lowercase().contains("target type is not supported"),
"unexpected error body: {body}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_rejects_unconfigured_target() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "object-lambda-e2e-missing-target";
let key = "input.txt";
let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"hello object lambda"))
.send()
.await?;
let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected error body: {body}");
assert!(
body.to_ascii_lowercase().contains("target is not configured"),
"unexpected error body: {body}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_rejects_disabled_target() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "object-lambda-e2e-disabled-target";
let key = "input.txt";
let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"hello object lambda"))
.send()
.await?;
configure_webhook_target_with_key_values(
&env,
"transformer",
vec![
("endpoint", "http://127.0.0.1:9/transform".to_string()),
("auth_token", "secret-token".to_string()),
("enable", "off".to_string()),
],
)
.await?;
wait_for_target_visibility(&env, "transformer").await?;
let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected error body: {body}");
assert!(body.to_ascii_lowercase().contains("target is disabled"), "unexpected error body: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_configure_object_lambda_target_rejects_invalid_endpoint() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "object-lambda-e2e-invalid-endpoint";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key("input.txt")
.body(ByteStream::from_static(b"hello object lambda"))
.send()
.await?;
let response = send_configure_webhook_target_request(
&env,
"transformer",
vec![
serde_json::json!({ "key": "endpoint", "value": "://invalid-endpoint" }),
serde_json::json!({ "key": "auth_token", "value": "secret-token" }),
serde_json::json!({ "key": "queue_dir", "value": format!("{}/notify-queue", env.temp_dir) }),
],
)
.await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidArgument"), "unexpected error body: {body}");
assert!(
body.to_ascii_lowercase().contains("invalid endpoint url"),
"unexpected error body: {body}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_configure_object_lambda_notify_webhook_rejects_response_header_timeout_key()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let response = send_configure_webhook_target_request(
&env,
"transformer",
vec![
serde_json::json!({ "key": "endpoint", "value": "http://127.0.0.1:9/transform" }),
serde_json::json!({ "key": "auth_token", "value": "secret-token" }),
serde_json::json!({ "key": "response_header_timeout", "value": "not-a-duration" }),
serde_json::json!({ "key": "queue_dir", "value": format!("{}/notify-queue", env.temp_dir) }),
],
)
.await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidArgument"), "unexpected error body: {body}");
assert!(
body.to_ascii_lowercase().contains("response_header_timeout"),
"unexpected error body: {body}"
);
assert!(body.to_ascii_lowercase().contains("not allowed"), "unexpected error body: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_listen_notification_emits_after_put_object() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "listen-notification-e2e";
let key = "logs/app.json";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
let listen_url = format!(
"{}/{bucket}?events={}&prefix={}&suffix={}&ping=1",
env.url,
urlencoding::encode("s3:ObjectCreated:Put"),
urlencoding::encode("logs/"),
urlencoding::encode(".json"),
);
let response = signed_request(http::Method::GET, &listen_url, &env.access_key, &env.secret_key, None, None).await?;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("content-type").and_then(|value| value.to_str().ok()),
Some("text/event-stream")
);
let read_task = tokio::spawn(read_listen_notification_event(response, key));
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"listen notification body"))
.send()
.await?;
let payload = timeout(Duration::from_secs(12), read_task).await???;
assert!(!payload.is_empty(), "listen_notification payload should not be empty");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_listen_notification_fans_in_remote_node_events() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut cluster = RustFSTestClusterEnvironment::new(2).await?;
cluster.start().await?;
let bucket = "listen-notification-cluster";
let key = "logs/cluster.json";
let node0_client = cluster.create_s3_client(0)?;
let node1_client = cluster.create_s3_client(1)?;
node0_client.create_bucket().bucket(bucket).send().await?;
let listen_url = format!(
"{}/{bucket}?events={}&prefix={}&suffix={}&ping=1",
cluster.nodes[0].url,
urlencoding::encode("s3:ObjectCreated:Put"),
urlencoding::encode("logs/"),
urlencoding::encode(".json"),
);
let response = signed_request(http::Method::GET, &listen_url, &cluster.access_key, &cluster.secret_key, None, None).await?;
assert_eq!(response.status(), StatusCode::OK);
let read_task = tokio::spawn(read_listen_notification_event(response, key));
node1_client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"cluster listen notification body"))
.send()
.await?;
let payload = timeout(Duration::from_secs(12), read_task).await???;
assert!(!payload.is_empty(), "listen_notification cluster payload should not be empty");
Ok(())
}
+47 -15
View File
@@ -14,11 +14,13 @@
//! Core FTPS tests
use crate::common::rustfs_binary_path;
use crate::common::rustfs_binary_path_with_features;
use crate::protocols::test_env::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, ProtocolTestEnvironment};
use anyhow::Result;
use rcgen::generate_simple_self_signed;
use rustls::{ClientConfig, RootCertStore, pki_types::CertificateDer, pki_types::pem::PemObject};
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{ClientConfig, DigitallySignedStruct, Error as RustlsError, SignatureScheme};
use std::io::Cursor;
use std::path::PathBuf;
use std::sync::Arc;
@@ -31,6 +33,46 @@ use tracing::info;
const FTPS_PORT: u16 = 9021;
const FTPS_ADDRESS: &str = "127.0.0.1:9021";
#[derive(Debug)]
struct AcceptAnyServerCertVerifier;
impl ServerCertVerifier for AcceptAnyServerCertVerifier {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, RustlsError> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, RustlsError> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, RustlsError> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
rustls::crypto::aws_lc_rs::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
/// Test FTPS: put, ls, mkdir, rmdir, delete operations
pub async fn test_ftps_core_operations() -> Result<()> {
let env = ProtocolTestEnvironment::new().map_err(|e| anyhow::anyhow!("{}", e))?;
@@ -58,7 +100,7 @@ pub async fn test_ftps_core_operations() -> Result<()> {
// Start server manually
info!("Starting FTPS server on {}", FTPS_ADDRESS);
let binary_path = rustfs_binary_path();
let binary_path = rustfs_binary_path_with_features(Some("ftps,webdav"));
let mut server_process = Command::new(&binary_path)
.env("RUSTFS_FTPS_ENABLE", "true")
.env("RUSTFS_FTPS_ADDRESS", FTPS_ADDRESS)
@@ -78,19 +120,9 @@ pub async fn test_ftps_core_operations() -> Result<()> {
.install_default()
.map_err(|e| anyhow::anyhow!("Failed to install crypto provider: {:?}", e))?;
// Create a simple rustls config that accepts any certificate for testing
let mut root_store = RootCertStore::empty();
// Add the self-signed certificate to the trust store for e2e
// Note: In a real environment, you'd use proper root certificates
let cert_pem = default_cert.cert.pem();
let cert_der = CertificateDer::pem_reader_iter(&mut Cursor::new(cert_pem))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| anyhow::anyhow!("Failed to parse cert: {}", e))?;
root_store.add_parsable_certificates(cert_der);
let config = ClientConfig::builder()
.with_root_certificates(root_store)
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAnyServerCertVerifier))
.with_no_client_auth();
// Wrap in suppaftp's RustlsConnector
+2 -2
View File
@@ -14,7 +14,7 @@
//! Core WebDAV tests
use crate::common::rustfs_binary_path;
use crate::common::rustfs_binary_path_with_features;
use crate::protocols::test_env::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, ProtocolTestEnvironment};
use anyhow::Result;
use base64::Engine;
@@ -47,7 +47,7 @@ pub async fn test_webdav_core_operations() -> Result<()> {
// Start server manually
info!("Starting WebDAV server on {}", WEBDAV_ADDRESS);
let binary_path = rustfs_binary_path();
let binary_path = rustfs_binary_path_with_features(Some("ftps,webdav"));
let mut server_process = Command::new(&binary_path)
.env("RUSTFS_WEBDAV_ENABLE", "true")
.env("RUSTFS_WEBDAV_ADDRESS", WEBDAV_ADDRESS)
+48
View File
@@ -17,6 +17,15 @@ use aws_sdk_s3::Client;
use serial_test::serial;
use tracing::{debug, info};
fn skip_without_awscurl() -> bool {
if crate::common::awscurl_available() {
return false;
}
info!("Skipping quota test because awscurl is not available");
true
}
/// Test environment setup for quota tests
pub struct QuotaTestEnv {
pub env: RustFSTestEnvironment,
@@ -233,6 +242,9 @@ mod integration_tests {
#[serial]
async fn test_quota_basic_operations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
// Create test bucket
@@ -269,6 +281,9 @@ mod integration_tests {
#[serial]
async fn test_quota_update_and_clear() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
@@ -302,6 +317,9 @@ mod integration_tests {
#[serial]
async fn test_quota_delete_operations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
@@ -336,6 +354,9 @@ mod integration_tests {
#[serial]
async fn test_quota_usage_tracking() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
@@ -372,6 +393,9 @@ mod integration_tests {
#[serial]
async fn test_quota_statistics() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
@@ -403,6 +427,9 @@ mod integration_tests {
#[serial]
async fn test_quota_check_api() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
@@ -441,6 +468,9 @@ mod integration_tests {
#[serial]
async fn test_quota_multiple_buckets() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
// Create two buckets in the same environment
@@ -479,6 +509,9 @@ mod integration_tests {
#[serial]
async fn test_quota_error_handling() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
@@ -512,6 +545,9 @@ mod integration_tests {
#[serial]
async fn test_quota_http_endpoints() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
@@ -575,6 +611,9 @@ mod integration_tests {
#[serial]
async fn test_quota_normal_user_permissions() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
@@ -628,6 +667,9 @@ mod integration_tests {
#[serial]
async fn test_quota_copy_operations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
@@ -671,6 +713,9 @@ mod integration_tests {
#[serial]
async fn test_quota_batch_delete() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
@@ -727,6 +772,9 @@ mod integration_tests {
#[serial]
async fn test_quota_multipart_upload() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
@@ -551,6 +551,13 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("lock-only test server"))
}
async fn get_live_events(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::GetLiveEventsRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::GetLiveEventsResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn start_profiling(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::StartProfilingRequest>,
@@ -0,0 +1,802 @@
// 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.
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use http::header::{CONTENT_TYPE, HOST};
use reqwest::StatusCode;
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use s3s::Body;
use serial_test::serial;
use std::error::Error;
async fn signed_request(
method: http::Method,
url: &str,
access_key: &str,
secret_key: &str,
body: Option<Vec<u8>>,
content_type: Option<&str>,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let uri = url.parse::<http::Uri>()?;
let authority = uri.authority().ok_or("request URL missing authority")?.to_string();
let mut request = http::Request::builder().method(method.clone()).uri(uri);
request = request.header(HOST, authority);
request = request.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
if let Some(content_type) = content_type {
request = request.header(CONTENT_TYPE, content_type);
}
let content_len = body.as_ref().map(|body| body.len() as i64).unwrap_or_default();
let signed = sign_v4(request.body(Body::empty())?, content_len, access_key, secret_key, "", "us-east-1");
let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())?;
let client = local_http_client();
let mut request_builder = client.request(reqwest_method, url);
for (name, value) in signed.headers() {
request_builder = request_builder.header(name, value);
}
if let Some(body) = body {
request_builder = request_builder.body(body);
}
Ok(request_builder.send().await?)
}
async fn set_replication_target(
source_env: &RustFSTestEnvironment,
source_bucket: &str,
target_env: &RustFSTestEnvironment,
target_bucket: &str,
) -> Result<String, Box<dyn Error + Send + Sync>> {
let body = serde_json::json!({
"endpoint": target_env.address,
"credentials": {
"accessKey": target_env.access_key,
"secretKey": target_env.secret_key
},
"targetbucket": target_bucket,
"secure": false,
"type": "replication"
});
let url = format!(
"{}/rustfs/admin/v3/set-remote-target?bucket={}",
source_env.url,
urlencoding::encode(source_bucket)
);
let response = signed_request(
http::Method::PUT,
&url,
&source_env.access_key,
&source_env.secret_key,
Some(body.to_string().into_bytes()),
Some("application/json"),
)
.await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("set remote target failed: {status} {body}").into());
}
let body = response.bytes().await?;
let arn: String = serde_json::from_slice(&body)?;
Ok(arn)
}
async fn send_set_replication_target_request(
source_env: &RustFSTestEnvironment,
source_bucket: &str,
update: bool,
body: serde_json::Value,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let mut url = format!(
"{}/rustfs/admin/v3/set-remote-target?bucket={}",
source_env.url,
urlencoding::encode(source_bucket)
);
if update {
url.push_str("&update=true");
}
signed_request(
http::Method::PUT,
&url,
&source_env.access_key,
&source_env.secret_key,
Some(body.to_string().into_bytes()),
Some("application/json"),
)
.await
}
async fn put_bucket_replication(
env: &RustFSTestEnvironment,
bucket: &str,
target_arn: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let body = format!(
r#"<ReplicationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Role></Role>
<Rule>
<ID>rule-1</ID>
<Priority>1</Priority>
<Status>Enabled</Status>
<DeleteMarkerReplication>
<Status>Enabled</Status>
</DeleteMarkerReplication>
<ExistingObjectReplication>
<Status>Enabled</Status>
</ExistingObjectReplication>
<Destination>
<Bucket>{target_arn}</Bucket>
</Destination>
</Rule>
</ReplicationConfiguration>"#
);
let url = format!("{}/{bucket}?replication", env.url);
let response = signed_request(
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(body.into_bytes()),
Some("application/xml"),
)
.await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("put bucket replication failed: {status} {body}").into());
}
Ok(())
}
async fn enable_bucket_versioning(env: &RustFSTestEnvironment, bucket: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
let client = env.create_s3_client();
client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
Ok(())
}
async fn run_replication_check(
env: &RustFSTestEnvironment,
bucket: &str,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let url = format!("{}/{bucket}?replication-check", env.url);
signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await
}
async fn remove_replication_target(
env: &RustFSTestEnvironment,
bucket: &str,
arn: &str,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let url = format!(
"{}/rustfs/admin/v3/remove-remote-target?bucket={}&arn={}",
env.url,
urlencoding::encode(bucket),
urlencoding::encode(arn)
);
signed_request(http::Method::DELETE, &url, &env.access_key, &env.secret_key, None, None).await
}
async fn remove_replication_target_request(
env: &RustFSTestEnvironment,
bucket: Option<&str>,
arn: Option<&str>,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let mut url = format!("{}/rustfs/admin/v3/remove-remote-target", env.url);
let mut separator = '?';
if let Some(bucket) = bucket {
url.push(separator);
separator = '&';
url.push_str("bucket=");
url.push_str(&urlencoding::encode(bucket));
}
if let Some(arn) = arn {
url.push(separator);
url.push_str("arn=");
url.push_str(&urlencoding::encode(arn));
}
signed_request(http::Method::DELETE, &url, &env.access_key, &env.secret_key, None, None).await
}
async fn list_replication_targets_request(
env: &RustFSTestEnvironment,
bucket: Option<&str>,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let mut url = format!("{}/rustfs/admin/v3/list-remote-targets", env.url);
if let Some(bucket) = bucket {
url.push_str("?bucket=");
url.push_str(&urlencoding::encode(bucket));
}
signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await
}
async fn build_replication_pair(
enable_target_versioning: bool,
) -> Result<(RustFSTestEnvironment, RustFSTestEnvironment, String), Box<dyn Error + Send + Sync>> {
let mut source_env = RustFSTestEnvironment::new().await?;
source_env.start_rustfs_server(vec![]).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-check-src";
let target_bucket = "replication-check-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
if enable_target_versioning {
enable_bucket_versioning(&target_env, target_bucket).await?;
}
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
Ok((source_env, target_env, source_bucket.to_string()))
}
#[tokio::test]
#[serial]
async fn test_replication_check_succeeds_with_remote_target() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let (_source_env, _target_env, source_bucket) = build_replication_pair(true).await?;
let response = run_replication_check(&_source_env, &source_bucket).await?;
assert_eq!(response.status(), StatusCode::OK);
assert!(response.text().await?.is_empty());
Ok(())
}
#[tokio::test]
#[serial]
async fn test_replication_check_rejects_target_without_object_lock() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env.start_rustfs_server(vec![]).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-check-lock-src";
let target_bucket = "replication-check-lock-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client
.create_bucket()
.bucket(source_bucket)
.object_lock_enabled_for_bucket(true)
.send()
.await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
let response = run_replication_check(&source_env, source_bucket).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("object lock"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_set_remote_target_rejects_unversioned_source_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env.start_rustfs_server(vec![]).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-check-unversioned-src";
let target_bucket = "replication-check-unversioned-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let err = set_replication_target(&source_env, source_bucket, &target_env, target_bucket)
.await
.expect_err("unversioned source bucket should be rejected during remote target setup");
let err = err.to_string();
assert!(err.contains("400 Bad Request"), "unexpected set remote target error: {err}");
assert!(err.contains("InvalidRequest"), "unexpected set remote target error: {err}");
assert!(
err.to_ascii_lowercase().contains("not versioned"),
"unexpected set remote target error: {err}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_replication_check_rejects_unversioned_source_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "replication-check-source-unversioned";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
let response = run_replication_check(&env, bucket).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("versioning"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_replication_check_rejects_missing_replication_config() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "replication-check-missing-config";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
enable_bucket_versioning(&env, bucket).await?;
let response = run_replication_check(&env, bucket).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body.contains("ReplicationConfigurationNotFoundError"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_replication_check_rejects_invalid_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let response = run_replication_check(&env, "replication-check-no-such-bucket").await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body.contains("NoSuchBucket"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_set_remote_target_rejects_same_bucket_on_same_deployment() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "replication-check-same-target";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
enable_bucket_versioning(&env, bucket).await?;
let body = serde_json::json!({
"endpoint": env.address,
"credentials": {
"accessKey": env.access_key,
"secretKey": env.secret_key
},
"targetbucket": bucket,
"secure": false,
"type": "replication"
});
let url = format!("{}/rustfs/admin/v3/set-remote-target?bucket={}", env.url, urlencoding::encode(bucket));
let response = signed_request(
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(body.to_string().into_bytes()),
Some("application/json"),
)
.await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("IncorrectEndpoint"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_set_remote_target_rejects_unversioned_target_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env.start_rustfs_server(vec![]).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-check-src";
let target_bucket = "replication-check-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
let err = set_replication_target(&source_env, source_bucket, &target_env, target_bucket)
.await
.expect_err("unversioned target bucket should be rejected during remote target setup");
assert!(err.to_string().contains("not versioned"), "unexpected set remote target error: {err}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_set_remote_target_update_requires_arn() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env.start_rustfs_server(vec![]).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-update-needs-arn-src";
let target_bucket = "replication-update-needs-arn-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let response = send_set_replication_target_request(
&source_env,
source_bucket,
true,
serde_json::json!({
"endpoint": target_env.address,
"credentials": {
"accessKey": target_env.access_key,
"secretKey": target_env.secret_key
},
"targetbucket": target_bucket,
"secure": false,
"type": "replication"
}),
)
.await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("arn is empty"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_set_remote_target_update_rejects_missing_target() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env.start_rustfs_server(vec![]).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-update-missing-target-src";
let target_bucket = "replication-update-missing-target-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let response = send_set_replication_target_request(
&source_env,
source_bucket,
true,
serde_json::json!({
"endpoint": target_env.address,
"credentials": {
"accessKey": target_env.access_key,
"secretKey": target_env.secret_key
},
"targetbucket": target_bucket,
"secure": false,
"type": "replication",
"arn": "arn:aws:s3:us-east-1:123456789012:replication::missing-target"
}),
)
.await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("target not found"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_set_remote_target_rejects_invalid_target_url() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env.start_rustfs_server(vec![]).await?;
let bucket = "replication-invalid-target-url-src";
let source_client = source_env.create_s3_client();
source_client.create_bucket().bucket(bucket).send().await?;
enable_bucket_versioning(&source_env, bucket).await?;
let response = send_set_replication_target_request(
&source_env,
bucket,
false,
serde_json::json!({
"endpoint": "://invalid-target-url",
"credentials": {
"accessKey": "replication",
"secretKey": "replication"
},
"targetbucket": "target-bucket",
"secure": false,
"type": "replication"
}),
)
.await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("invalid target url"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_list_remote_targets_rejects_empty_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let response = list_replication_targets_request(&env, Some("")).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("bucket is required"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_list_remote_targets_rejects_invalid_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let response = list_replication_targets_request(&env, Some("missing-replication-target-bucket")).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body.contains("NoSuchBucket"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_remove_remote_target_rejects_missing_target() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env.start_rustfs_server(vec![]).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let bucket = "replication-remove-missing-target";
let target_bucket = "replication-remove-missing-target-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let arn = set_replication_target(&source_env, bucket, &target_env, target_bucket).await?;
let first_remove = remove_replication_target(&source_env, bucket, &arn).await?;
assert_eq!(first_remove.status(), StatusCode::NO_CONTENT);
let response = remove_replication_target(&source_env, bucket, &arn).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("not found"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_remove_remote_target_rejects_missing_arn() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "replication-remove-missing-arn";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
enable_bucket_versioning(&env, bucket).await?;
let response = remove_replication_target_request(&env, Some(bucket), None).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("arn is required"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_remove_remote_target_rejects_invalid_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let response = remove_replication_target_request(
&env,
Some("missing-replication-remove-bucket"),
Some("arn:aws:s3:us-east-1:123456789012:replication::missing"),
)
.await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body.contains("NoSuchBucket"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_remove_remote_target_rejects_target_used_by_replication() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let (source_env, _target_env, source_bucket) = build_replication_pair(true).await?;
let targets_url = format!(
"{}/rustfs/admin/v3/list-remote-targets?bucket={}",
source_env.url,
urlencoding::encode(&source_bucket)
);
let targets_response = signed_request(
http::Method::GET,
&targets_url,
&source_env.access_key,
&source_env.secret_key,
None,
None,
)
.await?;
assert_eq!(targets_response.status(), StatusCode::OK);
let targets: Vec<serde_json::Value> = targets_response.json().await?;
let arn = targets
.first()
.and_then(|target| target.get("arn"))
.and_then(|arn| arn.as_str())
.ok_or("replication target arn missing")?
.to_string();
let response = remove_replication_target(&source_env, &source_bucket, &arn).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("removal disallowed"), "unexpected response: {body}");
Ok(())
}
@@ -454,12 +454,12 @@ mod tests {
Ok(())
}
/// Test 7: PutObject should return "null" version_id when versioning is Suspended
/// Test 7: PutObject should omit version_id when versioning is Suspended
#[tokio::test]
#[serial]
async fn test_put_object_returns_null_version_id_with_suspended_versioning() {
async fn test_put_object_omits_version_id_with_suspended_versioning() {
init_logging();
info!("🧪 TEST: PutObject returns null version_id with versioning suspended");
info!("🧪 TEST: PutObject omits version_id with versioning suspended");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
@@ -489,13 +489,130 @@ mod tests {
info!("📥 PutObject response - version_id: {:?}", output.version_id);
// When suspended, version_id must be "null"
// When suspended, version_id must be omitted
assert_eq!(
output.version_id.as_deref(),
Some("null"),
"❌ FAILED: version_id should be 'null' when versioning is suspended"
output.version_id, None,
"❌ FAILED: version_id should be omitted when versioning is suspended"
);
info!("✅ PASSED: PutObject correctly returns 'null' version_id");
info!("✅ PASSED: PutObject correctly omits version_id");
}
/// Test 8: CopyObject should omit version_id when versioning is Suspended
#[tokio::test]
#[serial]
async fn test_copy_object_omits_version_id_with_suspended_versioning() {
init_logging();
info!("🧪 TEST: CopyObject omits version_id with versioning suspended");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = create_s3_client(&env);
let bucket = "test-copy-suspended-version-id";
create_bucket(&client, bucket).await.expect("Failed to create bucket");
suspend_versioning(&client, bucket)
.await
.expect("Failed to suspend versioning");
let source_key = "source-file.txt";
let dest_key = "dest-file.txt";
let content = b"Content to copy into suspended bucket";
client
.put_object()
.bucket(bucket)
.key(source_key)
.body(ByteStream::from_static(content))
.send()
.await
.expect("Failed to create source object");
let result = client
.copy_object()
.bucket(bucket)
.key(dest_key)
.copy_source(format!("{}/{}", bucket, source_key))
.send()
.await;
assert!(result.is_ok(), "CopyObject failed: {:?}", result.err());
let output = result.unwrap();
info!("📥 CopyObject response - version_id: {:?}", output.version_id);
assert_eq!(
output.version_id, None,
"❌ FAILED: version_id should be omitted when versioning is suspended"
);
info!("✅ PASSED: CopyObject correctly omits version_id");
}
/// Test 9: CompleteMultipartUpload should omit version_id when versioning is Suspended
#[tokio::test]
#[serial]
async fn test_multipart_upload_omits_version_id_with_suspended_versioning() {
init_logging();
info!("🧪 TEST: CompleteMultipartUpload omits version_id with versioning suspended");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = create_s3_client(&env);
let bucket = "test-multipart-suspended-version-id";
create_bucket(&client, bucket).await.expect("Failed to create bucket");
suspend_versioning(&client, bucket)
.await
.expect("Failed to suspend versioning");
let key = "multipart-file.txt";
let content = b"Part 1 content for suspended multipart upload test";
let create_result = client
.create_multipart_upload()
.bucket(bucket)
.key(key)
.send()
.await
.expect("Failed to create multipart upload");
let upload_id = create_result.upload_id().expect("No upload_id returned");
let upload_part_result = client
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from_static(content))
.send()
.await
.expect("Failed to upload part");
let etag = upload_part_result.e_tag().expect("No etag returned").to_string();
let completed_part = CompletedPart::builder().part_number(1).e_tag(etag).build();
let completed_upload = CompletedMultipartUpload::builder().parts(completed_part).build();
let result = client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.multipart_upload(completed_upload)
.send()
.await;
assert!(result.is_ok(), "CompleteMultipartUpload failed: {:?}", result.err());
let output = result.unwrap();
info!("📥 CompleteMultipartUpload response - version_id: {:?}", output.version_id);
assert_eq!(
output.version_id, None,
"❌ FAILED: version_id should be omitted when versioning is suspended"
);
info!("✅ PASSED: CompleteMultipartUpload correctly omits version_id");
}
}