#![cfg(e2e_test)]
mod common;
use common::{CollectingEventCallback, TestHelper};
use s3rm_rs::EventType;
use std::sync::{Arc, Mutex};
#[tokio::test]
async fn e2e_access_denied_invalid_credentials() {
e2e_timeout!(async {
let helper = TestHelper::new().await;
let bucket = helper.generate_bucket_name();
helper.create_bucket(&bucket).await;
let guard = helper.bucket_guard(&bucket);
for i in 0..5 {
helper
.put_object(&bucket, &format!("access/file{i}.dat"), vec![b'a'; 100])
.await;
}
let config = TestHelper::build_config(vec![
&format!("s3://{bucket}/access/"),
"--target-access-key",
"INVALIDACCESSKEY123456",
"--target-secret-access-key",
"INVALIDSECRETKEY1234567890abcdefghijklmn",
"--target-region",
helper.region(),
"--force",
]);
let result = TestHelper::run_pipeline(config).await;
assert!(
result.has_error,
"Pipeline should report error for invalid credentials"
);
let remaining = helper.count_objects(&bucket, "access/").await;
assert_eq!(
remaining, 5,
"All objects should remain after access denial"
);
guard.cleanup().await;
});
}
#[tokio::test]
async fn e2e_nonexistent_bucket_error() {
e2e_timeout!(async {
let helper = TestHelper::new().await;
let fake_bucket = helper.generate_bucket_name();
let config = TestHelper::build_config(vec![&format!("s3://{fake_bucket}/"), "--force"]);
let result = TestHelper::run_pipeline(config).await;
assert!(
result.has_error,
"Pipeline should report error for nonexistent bucket"
);
});
}
#[tokio::test]
async fn e2e_batch_partial_failure_access_denied() {
e2e_timeout!(async {
let helper = TestHelper::new().await;
let bucket = helper.generate_bucket_name();
helper.create_bucket(&bucket).await;
let guard = helper.bucket_guard(&bucket);
for i in 0..10 {
helper
.put_object(&bucket, &format!("deletable/file{i}.dat"), vec![b'd'; 100])
.await;
}
for i in 0..10 {
helper
.put_object(&bucket, &format!("protected/file{i}.dat"), vec![b'p'; 100])
.await;
}
helper.deny_delete_on_prefix(&bucket, "protected/").await;
let config = TestHelper::build_config(vec![&format!("s3://{bucket}/"), "--force"]);
let result = TestHelper::run_pipeline(config).await;
helper.delete_bucket_policy(&bucket).await;
let remaining_deletable = helper.count_objects(&bucket, "deletable/").await;
assert_eq!(
remaining_deletable, 0,
"All deletable/ objects should have been deleted"
);
let remaining_protected = helper.count_objects(&bucket, "protected/").await;
assert_eq!(
remaining_protected, 10,
"All protected/ objects should remain due to AccessDenied"
);
assert_eq!(
result.stats.stats_deleted_objects, 10,
"Should report 10 successfully deleted objects"
);
assert!(
result.stats.stats_failed_objects > 0,
"Should report failed objects due to access denial"
);
assert!(
result.has_warning || result.has_error,
"Pipeline should report warning or error for partial batch failure"
);
guard.cleanup().await;
});
}
#[tokio::test]
async fn e2e_warn_as_error() {
e2e_timeout!(async {
let helper = TestHelper::new().await;
let bucket = helper.generate_bucket_name();
helper.create_bucket(&bucket).await;
let guard = helper.bucket_guard(&bucket);
for i in 0..10 {
helper
.put_object(&bucket, &format!("warn/file{i}.dat"), vec![b'w'; 100])
.await;
}
let config = TestHelper::build_config(vec![
&format!("s3://{bucket}/warn/"),
"--warn-as-error",
"--force",
]);
let result = TestHelper::run_pipeline(config).await;
assert_eq!(
result.stats.stats_deleted_objects, 10,
"Should delete all 10 objects"
);
if !result.has_warning {
assert!(
!result.has_error,
"No warnings means --warn-as-error should not promote to error"
);
}
guard.cleanup().await;
});
}
#[tokio::test]
async fn e2e_exit_codes() {
e2e_timeout!(async {
let binary_path = env!("CARGO_BIN_EXE_s3rm");
let invalid_args_output = std::process::Command::new(binary_path)
.args(["--this-flag-does-not-exist"])
.output()
.expect("Failed to execute s3rm binary");
assert_eq!(
invalid_args_output.status.code(),
Some(2),
"Unknown flag should produce exit code 2; stderr: {}",
String::from_utf8_lossy(&invalid_args_output.stderr)
);
let helper = TestHelper::new().await;
let bucket = helper.generate_bucket_name();
helper.create_bucket(&bucket).await;
let guard = helper.bucket_guard(&bucket);
helper
.put_object(&bucket, "exit-test/file0.dat", vec![b'e'; 100])
.await;
let success_output = std::process::Command::new(binary_path)
.args([
&format!("s3://{bucket}/exit-test/"),
"--dry-run",
"--force",
"--target-profile",
"s3rm-e2e-test",
])
.output()
.expect("Failed to execute s3rm binary");
assert_eq!(
success_output.status.code(),
Some(0),
"Dry-run on real bucket should produce exit code 0; stderr: {}",
String::from_utf8_lossy(&success_output.stderr)
);
let remaining = helper.count_objects(&bucket, "exit-test/").await;
assert_eq!(
remaining, 1,
"Object should still exist after dry-run via CLI"
);
guard.cleanup().await;
});
}
#[tokio::test]
async fn e2e_rate_limit_less_than_batch_size_rejected() {
e2e_timeout!(async {
let helper = TestHelper::new().await;
let bucket = helper.generate_bucket_name();
helper.create_bucket(&bucket).await;
let guard = helper.bucket_guard(&bucket);
let args: Vec<String> = vec![
"s3rm".to_string(),
format!("s3://{bucket}/"),
"--rate-limit-objects".to_string(),
"10".to_string(),
"--batch-size".to_string(),
"200".to_string(),
"--force".to_string(),
"--target-profile".to_string(),
"s3rm-e2e-test".to_string(),
];
let result = s3rm_rs::config::args::build_config_from_args(args);
assert!(
result.is_err(),
"Config should be rejected when rate-limit-objects < batch-size"
);
let error_msg = result.unwrap_err();
assert!(
error_msg.contains("--rate-limit-objects"),
"Error message should mention --rate-limit-objects; got: {error_msg}"
);
assert!(
error_msg.contains("--batch-size"),
"Error message should mention --batch-size; got: {error_msg}"
);
guard.cleanup().await;
});
}
#[tokio::test]
async fn e2e_warn_as_error_with_partial_failure() {
e2e_timeout!(async {
let helper = TestHelper::new().await;
let bucket = helper.generate_bucket_name();
helper.create_bucket(&bucket).await;
let guard = helper.bucket_guard(&bucket);
for i in 0..10 {
helper
.put_object(&bucket, &format!("deletable/file{i}.dat"), vec![b'd'; 100])
.await;
}
for i in 0..10 {
helper
.put_object(&bucket, &format!("protected/file{i}.dat"), vec![b'p'; 100])
.await;
}
helper.deny_delete_on_prefix(&bucket, "protected/").await;
let config = TestHelper::build_config(vec![
&format!("s3://{bucket}/"),
"--warn-as-error",
"--force",
]);
let result = TestHelper::run_pipeline(config).await;
helper.delete_bucket_policy(&bucket).await;
assert!(
result.has_error,
"Pipeline should report error when --warn-as-error and partial failures occur"
);
let remaining_deletable = helper.count_objects(&bucket, "deletable/").await;
assert_eq!(
remaining_deletable, 0,
"All deletable/ objects should have been deleted"
);
let remaining_protected = helper.count_objects(&bucket, "protected/").await;
assert_eq!(
remaining_protected, 10,
"All protected/ objects should remain due to AccessDenied"
);
guard.cleanup().await;
});
}
#[tokio::test]
async fn e2e_single_deleter_partial_failure_error_code_in_events() {
e2e_timeout!(async {
let helper = TestHelper::new().await;
let bucket = helper.generate_bucket_name();
helper.create_bucket(&bucket).await;
let guard = helper.bucket_guard(&bucket);
for i in 0..5 {
helper
.put_object(&bucket, &format!("ok/file{i}.dat"), vec![b'o'; 100])
.await;
}
for i in 0..5 {
helper
.put_object(&bucket, &format!("denied/file{i}.dat"), vec![b'd'; 100])
.await;
}
helper.deny_delete_on_prefix(&bucket, "denied/").await;
let collected_events = Arc::new(Mutex::new(Vec::new()));
let callback = CollectingEventCallback {
events: Arc::clone(&collected_events),
};
let mut config = TestHelper::build_config(vec![
&format!("s3://{bucket}/"),
"--batch-size",
"1",
"--force",
]);
config
.event_manager
.register_callback(EventType::ALL_EVENTS, callback, false);
let result = TestHelper::run_pipeline(config).await;
helper.delete_bucket_policy(&bucket).await;
let remaining_ok = helper.count_objects(&bucket, "ok/").await;
assert_eq!(remaining_ok, 0, "All ok/ objects should have been deleted");
let remaining_denied = helper.count_objects(&bucket, "denied/").await;
assert_eq!(
remaining_denied, 5,
"All denied/ objects should remain due to AccessDenied"
);
{
let events = collected_events.lock().unwrap();
let failed_events: Vec<_> = events
.iter()
.filter(|e| e.event_type == EventType::DELETE_FAILED)
.collect();
assert!(
!failed_events.is_empty(),
"Should have DELETE_FAILED events for denied/ objects"
);
for event in &failed_events {
if let Some(ref error_msg) = event.error_message {
assert!(
error_msg.contains("AccessDenied"),
"DELETE_FAILED event error_message should contain 'AccessDenied', got: {error_msg}"
);
assert!(
!error_msg.contains("DeleteObjectError"),
"DELETE_FAILED event should NOT use generic 'DeleteObjectError', got: {error_msg}"
);
}
}
}
assert!(
result.has_warning || result.has_error,
"Pipeline should report warning or error for failures"
);
guard.cleanup().await;
});
}
#[tokio::test]
async fn e2e_batch_deleter_error_code_in_events() {
e2e_timeout!(async {
let helper = TestHelper::new().await;
let bucket = helper.generate_bucket_name();
helper.create_bucket(&bucket).await;
let guard = helper.bucket_guard(&bucket);
for i in 0..5 {
helper
.put_object(&bucket, &format!("ok/file{i}.dat"), vec![b'o'; 100])
.await;
}
for i in 0..5 {
helper
.put_object(&bucket, &format!("denied/file{i}.dat"), vec![b'd'; 100])
.await;
}
helper.deny_delete_on_prefix(&bucket, "denied/").await;
let collected_events = Arc::new(Mutex::new(Vec::new()));
let callback = CollectingEventCallback {
events: Arc::clone(&collected_events),
};
let mut config = TestHelper::build_config(vec![
&format!("s3://{bucket}/"),
"--batch-size",
"100",
"--force",
]);
config
.event_manager
.register_callback(EventType::ALL_EVENTS, callback, false);
let result = TestHelper::run_pipeline(config).await;
helper.delete_bucket_policy(&bucket).await;
{
let events = collected_events.lock().unwrap();
let failed_events: Vec<_> = events
.iter()
.filter(|e| e.event_type == EventType::DELETE_FAILED)
.collect();
assert!(
!failed_events.is_empty(),
"Should have DELETE_FAILED events for denied/ objects"
);
for event in &failed_events {
if let Some(ref error_msg) = event.error_message {
assert!(
error_msg.contains("AccessDenied"),
"BatchDeleter DELETE_FAILED event should contain 'AccessDenied', got: {error_msg}"
);
}
}
}
assert!(
result.has_warning || result.has_error,
"Pipeline should report warning or error for failures"
);
guard.cleanup().await;
});
}
#[tokio::test]
async fn e2e_invalid_credentials_pipeline_error_smoke_test() {
e2e_timeout!(async {
let helper = TestHelper::new().await;
let bucket = helper.generate_bucket_name();
helper.create_bucket(&bucket).await;
let guard = helper.bucket_guard(&bucket);
for i in 0..3 {
helper
.put_object(&bucket, &format!("chain/file{i}.dat"), vec![b'c'; 100])
.await;
}
let config = TestHelper::build_config(vec![
&format!("s3://{bucket}/chain/"),
"--target-access-key",
"INVALIDACCESSKEY123456",
"--target-secret-access-key",
"INVALIDSECRETKEY1234567890abcdefghijklmn",
"--target-region",
helper.region(),
"--force",
]);
let result = TestHelper::run_pipeline(config).await;
assert!(
result.has_error,
"Pipeline should report error for invalid credentials"
);
assert!(
!result.errors.is_empty(),
"Errors list should not be empty when pipeline fails"
);
let remaining = helper.count_objects(&bucket, "chain/").await;
assert_eq!(
remaining, 3,
"All objects should remain after failed deletion"
);
guard.cleanup().await;
});
}
#[tokio::test]
async fn e2e_warn_as_error_single_deleter_partial_failure() {
e2e_timeout!(async {
let helper = TestHelper::new().await;
let bucket = helper.generate_bucket_name();
helper.create_bucket(&bucket).await;
let guard = helper.bucket_guard(&bucket);
for i in 0..5 {
helper
.put_object(&bucket, &format!("ok/file{i}.dat"), vec![b'o'; 100])
.await;
}
for i in 0..5 {
helper
.put_object(&bucket, &format!("denied/file{i}.dat"), vec![b'd'; 100])
.await;
}
helper.deny_delete_on_prefix(&bucket, "denied/").await;
let config = TestHelper::build_config(vec![
&format!("s3://{bucket}/"),
"--batch-size",
"1",
"--warn-as-error",
"--force",
]);
let result = TestHelper::run_pipeline(config).await;
helper.delete_bucket_policy(&bucket).await;
assert!(
result.has_error,
"SingleDeleter with --warn-as-error should promote partial failures to error"
);
let remaining_denied = helper.count_objects(&bucket, "denied/").await;
assert_eq!(
remaining_denied, 5,
"All denied/ objects should remain due to AccessDenied"
);
guard.cleanup().await;
});
}