diff --git a/crates/e2e_test/src/object_lock/common.rs b/crates/e2e_test/src/object_lock/common.rs index 17e41f366..a938756a4 100644 --- a/crates/e2e_test/src/object_lock/common.rs +++ b/crates/e2e_test/src/object_lock/common.rs @@ -21,6 +21,8 @@ //! - Bypass governance retention header handling use aws_sdk_s3::Client; +use aws_sdk_s3::error::SdkError; +use aws_sdk_s3::operation::delete_object::DeleteObjectError; use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::types::{ DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockMode, @@ -236,7 +238,7 @@ pub async fn delete_object_with_bypass( key: &str, version_id: Option<&str>, bypass_governance: bool, -) -> Result<(), Box> { +) -> Result<(), SdkError> { let mut request = client .delete_object() .bucket(bucket) diff --git a/crates/e2e_test/src/object_lock/object_lock_test.rs b/crates/e2e_test/src/object_lock/object_lock_test.rs index 7a9d6f9bc..f7e2cb6e8 100644 --- a/crates/e2e_test/src/object_lock/object_lock_test.rs +++ b/crates/e2e_test/src/object_lock/object_lock_test.rs @@ -26,7 +26,7 @@ use super::common::*; use aws_sdk_s3::Client; -use aws_sdk_s3::error::ProvideErrorMetadata; +use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError}; use aws_sdk_s3::primitives::{ByteStream, DateTimeFormat}; use aws_sdk_s3::types::{ CompletedMultipartUpload, CompletedPart, Delete, MetadataDirective, ObjectIdentifier, ObjectLockLegalHoldStatus, @@ -70,14 +70,21 @@ fn retention_timestamp(days: i64) -> aws_sdk_s3::primitives::DateTime { .expect("retention timestamp should parse") } -fn assert_access_denied(result: Result, context: &str) { - let err = match result { - Ok(_) => panic!("{context}"), - Err(err) => format!("{err:?}"), - }; - assert!( - err.contains("AccessDenied") || err.to_lowercase().contains("access denied"), - "{context}: expected AccessDenied, got: {err}" +fn assert_access_denied(result: Result>, context: &str) +where + T: std::fmt::Debug, + E: ProvideErrorMetadata + std::fmt::Debug, +{ + let error = result.expect_err(context); + assert_eq!( + error.raw_response().map(|response| response.status().as_u16()), + Some(403), + "{context}: expected HTTP 403, got: {error:?}" + ); + assert_eq!( + error.as_service_error().and_then(ProvideErrorMetadata::code), + Some("AccessDenied"), + "{context}: expected AccessDenied, got: {error:?}" ); } @@ -129,14 +136,15 @@ async fn test_delete_object_blocked_by_compliance_retention() { .unwrap(); // Attempt to delete - should fail - let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await; - assert!(delete_result.is_err(), "Delete should fail for COMPLIANCE locked object"); + assert_access_denied( + delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await, + "Delete should fail for COMPLIANCE locked object", + ); // Even with bypass header, COMPLIANCE should not allow deletion - let delete_with_bypass_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), true).await; - assert!( - delete_with_bypass_result.is_err(), - "Delete with bypass should still fail for COMPLIANCE mode" + assert_access_denied( + delete_object_with_bypass(&client, bucket, key, Some(&version_id), true).await, + "Delete with bypass should still fail for COMPLIANCE mode", ); info!("✅ Test passed: COMPLIANCE retention blocks deletion"); @@ -165,8 +173,10 @@ async fn test_delete_object_blocked_by_governance_without_bypass() { .unwrap(); // Attempt to delete without bypass - should fail - let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await; - assert!(delete_result.is_err(), "Delete without bypass should fail for GOVERNANCE locked object"); + assert_access_denied( + delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await, + "Delete without bypass should fail for GOVERNANCE locked object", + ); info!("✅ Test passed: GOVERNANCE retention blocks deletion without bypass"); } @@ -198,14 +208,19 @@ async fn test_delete_object_allowed_by_governance_with_bypass() { assert!(delete_result.is_ok(), "Delete with bypass should succeed for GOVERNANCE mode"); // Verify object is deleted - let head_result = client + let head_error = client .head_object() .bucket(bucket) .key(key) .version_id(&version_id) .send() - .await; - assert!(head_result.is_err(), "Object should be deleted"); + .await + .expect_err("Object should be deleted"); + assert_eq!( + head_error.raw_response().map(|response| response.status().as_u16()), + Some(404), + "deleted version should return HTTP 404: {head_error:?}" + ); info!("✅ Test passed: GOVERNANCE retention allows deletion with bypass"); } @@ -240,17 +255,18 @@ async fn test_delete_object_creates_delete_marker_for_retained_current_version() .expect("delete marker should have a version id") .to_string(); - let protected_delete = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await; - assert!(protected_delete.is_err(), "Retained version should still reject direct deletion"); + assert_access_denied( + delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await, + "Retained version should still reject direct deletion", + ); delete_object_with_bypass(&client, bucket, key, Some(&delete_marker_version_id), false) .await .unwrap(); - let still_protected = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await; - assert!( - still_protected.is_err(), - "Retained version should remain protected after delete marker removal" + assert_access_denied( + delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await, + "Retained version should remain protected after delete marker removal", ); delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true) @@ -282,12 +298,16 @@ async fn test_delete_object_blocked_by_legal_hold() { .unwrap(); // Attempt to delete - should fail (legal hold cannot be bypassed) - let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await; - assert!(delete_result.is_err(), "Delete should fail for legal hold object"); + assert_access_denied( + delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await, + "Delete should fail for legal hold object", + ); // Even with bypass header, legal hold should block deletion - let delete_with_bypass_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), true).await; - assert!(delete_with_bypass_result.is_err(), "Delete with bypass should still fail for legal hold"); + assert_access_denied( + delete_object_with_bypass(&client, bucket, key, Some(&version_id), true).await, + "Delete with bypass should still fail for legal hold", + ); info!("✅ Test passed: Legal Hold blocks deletion"); } @@ -315,14 +335,19 @@ async fn test_delete_object_allowed_with_legal_hold_off() { let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await; assert!(delete_result.is_ok(), "Delete should succeed when legal hold is OFF"); - let head_result = client + let head_error = client .head_object() .bucket(bucket) .key(key) .version_id(&version_id) .send() - .await; - assert!(head_result.is_err(), "Object should be deleted when legal hold is OFF"); + .await + .expect_err("Object should be deleted when legal hold is OFF"); + assert_eq!( + head_error.raw_response().map(|response| response.status().as_u16()), + Some(404), + "deleted version should return HTTP 404: {head_error:?}" + ); info!("✅ Test passed: Legal Hold OFF allows deletion"); } @@ -545,8 +570,10 @@ async fn test_put_object_overwrite_creates_new_version_under_legal_hold() { "held version must keep its legal hold after the overwrite" ); - let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&held_version_id), false).await; - assert!(delete_result.is_err(), "held version must stay delete-protected after the overwrite"); + assert_access_denied( + delete_object_with_bypass(&client, bucket, key, Some(&held_version_id), false).await, + "held version must stay delete-protected after the overwrite", + ); } #[tokio::test] @@ -768,8 +795,10 @@ async fn test_copy_object_overwrite_creates_new_version_under_legal_hold() { "held destination version must keep its legal hold after the copy" ); - let delete_result = delete_object_with_bypass(&client, bucket, dst_key, Some(&held_version_id), false).await; - assert!(delete_result.is_err(), "held destination version must stay delete-protected"); + assert_access_denied( + delete_object_with_bypass(&client, bucket, dst_key, Some(&held_version_id), false).await, + "held destination version must stay delete-protected", + ); } #[tokio::test] @@ -909,10 +938,9 @@ async fn test_create_multipart_upload_creates_new_version_under_compliance_reten // COMPLIANCE retention on the previous version survives the overwrite and // cannot be bypassed. - let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true).await; - assert!( - delete_result.is_err(), - "retained version must stay delete-protected even with governance bypass" + assert_access_denied( + delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true).await, + "retained version must stay delete-protected even with governance bypass", ); } @@ -971,8 +999,10 @@ async fn test_delete_completed_multipart_object_blocked_by_legal_hold() { .unwrap(); let version_id = complete_output.version_id().expect("multipart object should be versioned"); - let delete_result = delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await; - assert!(delete_result.is_err(), "Delete should fail for multipart object protected by legal hold"); + assert_access_denied( + delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await, + "Delete should fail for multipart object protected by legal hold", + ); } #[tokio::test] @@ -1032,8 +1062,10 @@ async fn test_delete_completed_multipart_object_blocked_by_retention() { .unwrap(); let version_id = complete_output.version_id().expect("multipart object should be versioned"); - let delete_result = delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await; - assert!(delete_result.is_err(), "Delete should fail for multipart object protected by retention"); + assert_access_denied( + delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await, + "Delete should fail for multipart object protected by retention", + ); } #[tokio::test] @@ -1111,8 +1143,10 @@ async fn test_complete_multipart_upload_creates_new_version_under_legal_hold() { "held version must keep its legal hold after multipart completion" ); - let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&held_version_id), false).await; - assert!(delete_result.is_err(), "held version must stay delete-protected"); + assert_access_denied( + delete_object_with_bypass(&client, bucket, key, Some(&held_version_id), false).await, + "held version must stay delete-protected", + ); } #[tokio::test] @@ -1181,10 +1215,9 @@ async fn test_complete_multipart_upload_creates_new_version_under_compliance_ret // COMPLIANCE retention on the previous version survives the overwrite and // cannot be bypassed. - let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true).await; - assert!( - delete_result.is_err(), - "retained version must stay delete-protected even with governance bypass" + assert_access_denied( + delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true).await, + "retained version must stay delete-protected even with governance bypass", ); } @@ -1621,8 +1654,10 @@ async fn test_default_retention_applied_to_new_objects() { let version_id = response.version_id().unwrap(); // Try to delete without bypass - should fail due to default retention - let delete_result = delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await; - assert!(delete_result.is_err(), "Delete should fail for object with default retention applied"); + assert_access_denied( + delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await, + "Delete should fail for object with default retention applied", + ); let retention = client .get_object_retention() @@ -1710,8 +1745,10 @@ async fn test_delete_object_creates_delete_marker_for_default_retained_current_v .expect("delete marker should have a version id") .to_string(); - let protected_delete = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await; - assert!(protected_delete.is_err(), "Default-retained version should still reject direct deletion"); + assert_access_denied( + delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await, + "Default-retained version should still reject direct deletion", + ); let retention_after_delete_marker = client .get_object_retention() @@ -2011,11 +2048,9 @@ async fn test_copy_object_retention_uses_destination_policy() { // COMPLIANCE retention on the previous destination version survives the // overwrite and cannot be bypassed. - let delete_result = - delete_object_with_bypass(&client, dst_bucket, "locked-destination", Some(&retained_version_id), true).await; - assert!( - delete_result.is_err(), - "retained destination version must stay delete-protected even with governance bypass" + assert_access_denied( + delete_object_with_bypass(&client, dst_bucket, "locked-destination", Some(&retained_version_id), true).await, + "retained destination version must stay delete-protected even with governance bypass", ); }