test(e2e): require exact object lock errors (#6532)

This commit is contained in:
Zhengchao An
2026-08-24 22:08:07 +08:00
committed by GitHub
parent c80d970d58
commit 7a7871ca67
2 changed files with 98 additions and 61 deletions
+3 -1
View File
@@ -21,6 +21,8 @@
//! - Bypass governance retention header handling //! - Bypass governance retention header handling
use aws_sdk_s3::Client; 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::primitives::ByteStream;
use aws_sdk_s3::types::{ use aws_sdk_s3::types::{
DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockMode, DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockMode,
@@ -236,7 +238,7 @@ pub async fn delete_object_with_bypass(
key: &str, key: &str,
version_id: Option<&str>, version_id: Option<&str>,
bypass_governance: bool, bypass_governance: bool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), SdkError<DeleteObjectError>> {
let mut request = client let mut request = client
.delete_object() .delete_object()
.bucket(bucket) .bucket(bucket)
@@ -26,7 +26,7 @@
use super::common::*; use super::common::*;
use aws_sdk_s3::Client; 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::primitives::{ByteStream, DateTimeFormat};
use aws_sdk_s3::types::{ use aws_sdk_s3::types::{
CompletedMultipartUpload, CompletedPart, Delete, MetadataDirective, ObjectIdentifier, ObjectLockLegalHoldStatus, 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") .expect("retention timestamp should parse")
} }
fn assert_access_denied<T, E: std::fmt::Debug>(result: Result<T, E>, context: &str) { fn assert_access_denied<T, E>(result: Result<T, SdkError<E>>, context: &str)
let err = match result { where
Ok(_) => panic!("{context}"), T: std::fmt::Debug,
Err(err) => format!("{err:?}"), E: ProvideErrorMetadata + std::fmt::Debug,
}; {
assert!( let error = result.expect_err(context);
err.contains("AccessDenied") || err.to_lowercase().contains("access denied"), assert_eq!(
"{context}: expected AccessDenied, got: {err}" 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(); .unwrap();
// Attempt to delete - should fail // Attempt to delete - should fail
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await; assert_access_denied(
assert!(delete_result.is_err(), "Delete should fail for COMPLIANCE locked object"); 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 // 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_access_denied(
assert!( delete_object_with_bypass(&client, bucket, key, Some(&version_id), true).await,
delete_with_bypass_result.is_err(), "Delete with bypass should still fail for COMPLIANCE mode",
"Delete with bypass should still fail for COMPLIANCE mode"
); );
info!("✅ Test passed: COMPLIANCE retention blocks deletion"); info!("✅ Test passed: COMPLIANCE retention blocks deletion");
@@ -165,8 +173,10 @@ async fn test_delete_object_blocked_by_governance_without_bypass() {
.unwrap(); .unwrap();
// Attempt to delete without bypass - should fail // Attempt to delete without bypass - should fail
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await; assert_access_denied(
assert!(delete_result.is_err(), "Delete without bypass should fail for GOVERNANCE locked object"); 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"); 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"); assert!(delete_result.is_ok(), "Delete with bypass should succeed for GOVERNANCE mode");
// Verify object is deleted // Verify object is deleted
let head_result = client let head_error = client
.head_object() .head_object()
.bucket(bucket) .bucket(bucket)
.key(key) .key(key)
.version_id(&version_id) .version_id(&version_id)
.send() .send()
.await; .await
assert!(head_result.is_err(), "Object should be deleted"); .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"); 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") .expect("delete marker should have a version id")
.to_string(); .to_string();
let protected_delete = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await; assert_access_denied(
assert!(protected_delete.is_err(), "Retained version should still reject direct deletion"); 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) delete_object_with_bypass(&client, bucket, key, Some(&delete_marker_version_id), false)
.await .await
.unwrap(); .unwrap();
let still_protected = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await; assert_access_denied(
assert!( delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await,
still_protected.is_err(), "Retained version should remain protected after delete marker removal",
"Retained version should remain protected after delete marker removal"
); );
delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true) 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(); .unwrap();
// Attempt to delete - should fail (legal hold cannot be bypassed) // 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_access_denied(
assert!(delete_result.is_err(), "Delete should fail for legal hold object"); 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 // 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_access_denied(
assert!(delete_with_bypass_result.is_err(), "Delete with bypass should still fail for legal hold"); 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"); 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; 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"); assert!(delete_result.is_ok(), "Delete should succeed when legal hold is OFF");
let head_result = client let head_error = client
.head_object() .head_object()
.bucket(bucket) .bucket(bucket)
.key(key) .key(key)
.version_id(&version_id) .version_id(&version_id)
.send() .send()
.await; .await
assert!(head_result.is_err(), "Object should be deleted when legal hold is OFF"); .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"); 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" "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_access_denied(
assert!(delete_result.is_err(), "held version must stay delete-protected after the overwrite"); delete_object_with_bypass(&client, bucket, key, Some(&held_version_id), false).await,
"held version must stay delete-protected after the overwrite",
);
} }
#[tokio::test] #[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" "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_access_denied(
assert!(delete_result.is_err(), "held destination version must stay delete-protected"); delete_object_with_bypass(&client, bucket, dst_key, Some(&held_version_id), false).await,
"held destination version must stay delete-protected",
);
} }
#[tokio::test] #[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 // COMPLIANCE retention on the previous version survives the overwrite and
// cannot be bypassed. // cannot be bypassed.
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true).await; assert_access_denied(
assert!( delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true).await,
delete_result.is_err(), "retained version must stay delete-protected even with governance bypass",
"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(); .unwrap();
let version_id = complete_output.version_id().expect("multipart object should be versioned"); 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_access_denied(
assert!(delete_result.is_err(), "Delete should fail for multipart object protected by legal hold"); delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await,
"Delete should fail for multipart object protected by legal hold",
);
} }
#[tokio::test] #[tokio::test]
@@ -1032,8 +1062,10 @@ async fn test_delete_completed_multipart_object_blocked_by_retention() {
.unwrap(); .unwrap();
let version_id = complete_output.version_id().expect("multipart object should be versioned"); 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_access_denied(
assert!(delete_result.is_err(), "Delete should fail for multipart object protected by retention"); delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await,
"Delete should fail for multipart object protected by retention",
);
} }
#[tokio::test] #[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" "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_access_denied(
assert!(delete_result.is_err(), "held version must stay delete-protected"); delete_object_with_bypass(&client, bucket, key, Some(&held_version_id), false).await,
"held version must stay delete-protected",
);
} }
#[tokio::test] #[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 // COMPLIANCE retention on the previous version survives the overwrite and
// cannot be bypassed. // cannot be bypassed.
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true).await; assert_access_denied(
assert!( delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true).await,
delete_result.is_err(), "retained version must stay delete-protected even with governance bypass",
"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(); let version_id = response.version_id().unwrap();
// Try to delete without bypass - should fail due to default retention // 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_access_denied(
assert!(delete_result.is_err(), "Delete should fail for object with default retention applied"); delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await,
"Delete should fail for object with default retention applied",
);
let retention = client let retention = client
.get_object_retention() .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") .expect("delete marker should have a version id")
.to_string(); .to_string();
let protected_delete = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await; assert_access_denied(
assert!(protected_delete.is_err(), "Default-retained version should still reject direct deletion"); 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 let retention_after_delete_marker = client
.get_object_retention() .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 // COMPLIANCE retention on the previous destination version survives the
// overwrite and cannot be bypassed. // overwrite and cannot be bypassed.
let delete_result = assert_access_denied(
delete_object_with_bypass(&client, dst_bucket, "locked-destination", Some(&retained_version_id), true).await; delete_object_with_bypass(&client, dst_bucket, "locked-destination", Some(&retained_version_id), true).await,
assert!( "retained destination version must stay delete-protected even with governance bypass",
delete_result.is_err(),
"retained destination version must stay delete-protected even with governance bypass"
); );
} }