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
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<dyn std::error::Error + Send + Sync>> {
) -> Result<(), SdkError<DeleteObjectError>> {
let mut request = client
.delete_object()
.bucket(bucket)
@@ -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<T, E: std::fmt::Debug>(result: Result<T, E>, 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<T, E>(result: Result<T, SdkError<E>>, 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",
);
}