feat: object retention (#1589)

This commit is contained in:
GatewayJ
2026-01-24 22:12:45 +08:00
committed by GitHub
parent db5e72e475
commit 9285acba06
12 changed files with 1832 additions and 120 deletions
+258
View File
@@ -0,0 +1,258 @@
// 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.
//! Object Lock E2E test utilities
//!
//! This module provides Object Lock-specific functionality for testing:
//! - Creating buckets with Object Lock enabled
//! - Setting retention policies
//! - Testing legal hold operations
//! - Bypass governance retention header handling
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockMode,
ObjectLockRetention, ObjectLockRetentionMode, ObjectLockRule,
};
use chrono::{DateTime, Duration, Utc};
use tracing::info;
use crate::common::RustFSTestEnvironment;
/// Object Lock test environment
pub struct ObjectLockTestEnvironment {
pub base_env: RustFSTestEnvironment,
}
impl ObjectLockTestEnvironment {
/// Create a new Object Lock test environment
pub async fn new() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let base_env = RustFSTestEnvironment::new().await?;
Ok(Self { base_env })
}
/// Start RustFS server (no special flags needed for Object Lock)
pub async fn start_rustfs(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.base_env.start_rustfs_server(Vec::new()).await
}
/// Get S3 client
pub fn s3_client(&self) -> Client {
self.base_env.create_s3_client()
}
/// Create a bucket with Object Lock enabled
pub async fn create_object_lock_bucket(&self, bucket_name: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = self.s3_client();
// Create bucket with Object Lock enabled
client
.create_bucket()
.bucket(bucket_name)
.object_lock_enabled_for_bucket(true)
.send()
.await?;
info!("Created bucket with Object Lock enabled: {}", bucket_name);
Ok(())
}
}
/// Put Object Lock configuration on a bucket
pub async fn put_object_lock_configuration(
client: &Client,
bucket: &str,
mode: ObjectLockRetentionMode,
days: Option<i32>,
years: Option<i32>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut default_retention_builder = DefaultRetention::builder().mode(mode);
if let Some(d) = days {
default_retention_builder = default_retention_builder.days(d);
}
if let Some(y) = years {
default_retention_builder = default_retention_builder.years(y);
}
let default_retention = default_retention_builder.build();
let rule = ObjectLockRule::builder().default_retention(default_retention).build();
let config = ObjectLockConfiguration::builder()
.object_lock_enabled(ObjectLockEnabled::Enabled)
.rule(rule)
.build();
client
.put_object_lock_configuration()
.bucket(bucket)
.object_lock_configuration(config)
.send()
.await?;
info!("Put Object Lock configuration on bucket: {}", bucket);
Ok(())
}
/// Convert ObjectLockRetentionMode to ObjectLockMode for put_object
fn retention_mode_to_lock_mode(mode: ObjectLockRetentionMode) -> ObjectLockMode {
match mode.as_str() {
"GOVERNANCE" => ObjectLockMode::Governance,
"COMPLIANCE" => ObjectLockMode::Compliance,
_ => ObjectLockMode::Governance,
}
}
/// Put an object with retention
pub async fn put_object_with_retention(
client: &Client,
bucket: &str,
key: &str,
data: &[u8],
mode: ObjectLockRetentionMode,
retain_until: DateTime<Utc>,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
// AWS SDK requires UTC time without timezone offset (e.g., "2026-01-24T11:20:14Z")
let retain_until_str = retain_until.format("%Y-%m-%dT%H:%M:%SZ").to_string();
let retain_until_datetime =
aws_sdk_s3::primitives::DateTime::from_str(&retain_until_str, aws_sdk_s3::primitives::DateTimeFormat::DateTime)?;
let lock_mode = retention_mode_to_lock_mode(mode.clone());
let response = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(data.to_vec()))
.object_lock_mode(lock_mode)
.object_lock_retain_until_date(retain_until_datetime)
.send()
.await?;
let version_id = response.version_id().unwrap_or("null").to_string();
info!("Put object {} with retention mode {:?}, version: {}", key, mode, version_id);
Ok(version_id)
}
/// Put an object with legal hold
pub async fn put_object_with_legal_hold(
client: &Client,
bucket: &str,
key: &str,
data: &[u8],
legal_hold: ObjectLockLegalHoldStatus,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let legal_hold_str = format!("{:?}", legal_hold);
let response = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(data.to_vec()))
.object_lock_legal_hold_status(legal_hold)
.send()
.await?;
let version_id = response.version_id().unwrap_or("null").to_string();
info!("Put object {} with legal hold {}, version: {}", key, legal_hold_str, version_id);
Ok(version_id)
}
/// Put object retention on existing object
pub async fn put_object_retention(
client: &Client,
bucket: &str,
key: &str,
version_id: Option<&str>,
mode: ObjectLockRetentionMode,
retain_until: DateTime<Utc>,
bypass_governance: bool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// AWS SDK requires UTC time without timezone offset (e.g., "2026-01-24T11:20:14Z")
let retain_until_str = retain_until.format("%Y-%m-%dT%H:%M:%SZ").to_string();
let retain_until_datetime =
aws_sdk_s3::primitives::DateTime::from_str(&retain_until_str, aws_sdk_s3::primitives::DateTimeFormat::DateTime)?;
let retention = ObjectLockRetention::builder()
.mode(mode.clone())
.retain_until_date(retain_until_datetime)
.build();
let mut request = client
.put_object_retention()
.bucket(bucket)
.key(key)
.retention(retention)
.bypass_governance_retention(bypass_governance);
if let Some(vid) = version_id {
request = request.version_id(vid);
}
request.send().await?;
info!("Put object retention on {} with mode {:?}", key, mode);
Ok(())
}
/// Put object legal hold
pub async fn put_object_legal_hold(
client: &Client,
bucket: &str,
key: &str,
version_id: Option<&str>,
status: ObjectLockLegalHoldStatus,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let status_str = format!("{:?}", status);
let legal_hold = ObjectLockLegalHold::builder().status(status).build();
let mut request = client.put_object_legal_hold().bucket(bucket).key(key).legal_hold(legal_hold);
if let Some(vid) = version_id {
request = request.version_id(vid);
}
request.send().await?;
info!("Put legal hold {} on {}", status_str, key);
Ok(())
}
/// Delete object with optional bypass governance retention
pub async fn delete_object_with_bypass(
client: &Client,
bucket: &str,
key: &str,
version_id: Option<&str>,
bypass_governance: bool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut request = client
.delete_object()
.bucket(bucket)
.key(key)
.bypass_governance_retention(bypass_governance);
if let Some(vid) = version_id {
request = request.version_id(vid);
}
request.send().await?;
info!("Deleted object {} (bypass: {})", key, bypass_governance);
Ok(())
}
/// Calculate a future retain_until date
pub fn future_retain_until(days: i64) -> DateTime<Utc> {
Utc::now() + Duration::days(days)
}
+25
View File
@@ -0,0 +1,25 @@
// 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.
//! Object Lock E2E tests
//!
//! This module contains end-to-end tests for S3 Object Lock functionality, including:
//! - COMPLIANCE and GOVERNANCE retention modes
//! - Legal Hold
//! - Bypass Governance Retention
//! - Default bucket retention configuration
//! - Delete operations on locked objects
pub mod common;
mod object_lock_test;
@@ -0,0 +1,665 @@
// 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.
#![cfg(test)]
//! Object Lock E2E Tests
//!
//! These tests verify the complete Object Lock workflow including:
//! - COMPLIANCE mode blocks deletion
//! - GOVERNANCE mode blocks deletion without bypass header
//! - GOVERNANCE mode allows deletion with bypass header
//! - Legal Hold blocks deletion
//! - PutObjectRetention modification restrictions
//! - Default bucket retention is applied to new objects
use super::common::*;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{Delete, ObjectIdentifier, ObjectLockLegalHoldStatus, ObjectLockRetentionMode};
use serial_test::serial;
use tracing::info;
/// Initialize test logging
fn init_logging() {
let _ = tracing_subscriber::fmt()
.with_env_filter("e2e_test=debug,rustfs=info")
.try_init();
}
// ============================================================================
// DeleteObject Tests
// ============================================================================
#[tokio::test]
#[serial]
async fn test_delete_object_blocked_by_compliance_retention() {
init_logging();
info!("🧪 Test: DeleteObject blocked by COMPLIANCE retention");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-compliance-delete";
let key = "locked-object";
let data = b"test data for compliance mode";
// Create bucket with Object Lock enabled
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Put object with COMPLIANCE retention (30 days in future)
let retain_until = future_retain_until(30);
let version_id = put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Compliance, retain_until)
.await
.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");
// 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"
);
info!("✅ Test passed: COMPLIANCE retention blocks deletion");
}
#[tokio::test]
#[serial]
async fn test_delete_object_blocked_by_governance_without_bypass() {
init_logging();
info!("🧪 Test: DeleteObject blocked by GOVERNANCE retention without bypass");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-governance-no-bypass";
let key = "governance-locked-object";
let data = b"test data for governance mode";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Put object with GOVERNANCE retention
let retain_until = future_retain_until(30);
let version_id = put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Governance, retain_until)
.await
.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");
info!("✅ Test passed: GOVERNANCE retention blocks deletion without bypass");
}
#[tokio::test]
#[serial]
async fn test_delete_object_allowed_by_governance_with_bypass() {
init_logging();
info!("🧪 Test: DeleteObject allowed by GOVERNANCE retention with bypass");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-governance-with-bypass";
let key = "governance-bypass-object";
let data = b"test data for governance bypass";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Put object with GOVERNANCE retention
let retain_until = future_retain_until(30);
let version_id = put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Governance, retain_until)
.await
.unwrap();
// Delete with bypass header - should succeed
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), true).await;
assert!(delete_result.is_ok(), "Delete with bypass should succeed for GOVERNANCE mode");
// Verify object is deleted
let head_result = client
.head_object()
.bucket(bucket)
.key(key)
.version_id(&version_id)
.send()
.await;
assert!(head_result.is_err(), "Object should be deleted");
info!("✅ Test passed: GOVERNANCE retention allows deletion with bypass");
}
#[tokio::test]
#[serial]
async fn test_delete_object_blocked_by_legal_hold() {
init_logging();
info!("🧪 Test: DeleteObject blocked by Legal Hold");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-legal-hold-delete";
let key = "legal-hold-object";
let data = b"test data for legal hold";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Put object with legal hold ON
let version_id = put_object_with_legal_hold(&client, bucket, key, data, ObjectLockLegalHoldStatus::On)
.await
.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");
// 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");
info!("✅ Test passed: Legal Hold blocks deletion");
}
#[tokio::test]
#[serial]
async fn test_delete_object_after_legal_hold_removed() {
init_logging();
info!("🧪 Test: DeleteObject succeeds after Legal Hold is removed");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-legal-hold-remove";
let key = "legal-hold-remove-object";
let data = b"test data for legal hold removal";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Put object with legal hold ON
let version_id = put_object_with_legal_hold(&client, bucket, key, data, ObjectLockLegalHoldStatus::On)
.await
.unwrap();
// Remove legal hold
put_object_legal_hold(&client, bucket, key, Some(&version_id), ObjectLockLegalHoldStatus::Off)
.await
.unwrap();
// Now deletion should succeed
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await;
assert!(delete_result.is_ok(), "Delete should succeed after legal hold is removed");
info!("✅ Test passed: Deletion succeeds after Legal Hold removal");
}
// ============================================================================
// DeleteObjects (Batch Delete) Tests
// ============================================================================
#[tokio::test]
#[serial]
async fn test_delete_objects_mixed_locked_unlocked() {
init_logging();
info!("🧪 Test: DeleteObjects with mixed locked and unlocked objects");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-batch-delete-mixed";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Put unlocked object
let unlocked_key = "unlocked-object";
client
.put_object()
.bucket(bucket)
.key(unlocked_key)
.body(ByteStream::from(b"unlocked data".to_vec()))
.send()
.await
.unwrap();
// Put locked object with COMPLIANCE
let locked_key = "locked-object";
let retain_until = future_retain_until(30);
let locked_version = put_object_with_retention(
&client,
bucket,
locked_key,
b"locked data",
ObjectLockRetentionMode::Compliance,
retain_until,
)
.await
.unwrap();
// Batch delete both objects
let delete = Delete::builder()
.objects(ObjectIdentifier::builder().key(unlocked_key).build().unwrap())
.objects(
ObjectIdentifier::builder()
.key(locked_key)
.version_id(&locked_version)
.build()
.unwrap(),
)
.build()
.unwrap();
let result = client.delete_objects().bucket(bucket).delete(delete).send().await.unwrap();
// Unlocked object should be deleted
let deleted_count = result.deleted().len();
let error_count = result.errors().len();
info!("Deleted: {}, Errors: {}", deleted_count, error_count);
// Should have 1 successful delete (unlocked) and 1 error (locked)
assert_eq!(deleted_count, 1, "One object should be deleted");
assert_eq!(error_count, 1, "One object should have error (locked)");
// Verify locked object still exists
let head_result = client
.head_object()
.bucket(bucket)
.key(locked_key)
.version_id(&locked_version)
.send()
.await;
assert!(head_result.is_ok(), "Locked object should still exist");
info!("✅ Test passed: Batch delete correctly handles mixed locked/unlocked objects");
}
// ============================================================================
// PutObjectRetention Tests
// ============================================================================
#[tokio::test]
#[serial]
async fn test_put_retention_compliance_cannot_shorten() {
init_logging();
info!("🧪 Test: PutObjectRetention cannot shorten COMPLIANCE retention");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-retention-shorten";
let key = "compliance-shorten-object";
let data = b"test data";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Put object with COMPLIANCE retention for 60 days
let retain_until_60 = future_retain_until(60);
let version_id = put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Compliance, retain_until_60)
.await
.unwrap();
// Try to shorten to 30 days - should fail
let retain_until_30 = future_retain_until(30);
let shorten_result = put_object_retention(
&client,
bucket,
key,
Some(&version_id),
ObjectLockRetentionMode::Compliance,
retain_until_30,
false,
)
.await;
assert!(shorten_result.is_err(), "Shortening COMPLIANCE retention should fail");
info!("✅ Test passed: Cannot shorten COMPLIANCE retention");
}
#[tokio::test]
#[serial]
async fn test_put_retention_compliance_can_extend() {
init_logging();
info!("🧪 Test: PutObjectRetention can extend COMPLIANCE retention");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-retention-extend";
let key = "compliance-extend-object";
let data = b"test data";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Put object with COMPLIANCE retention for 30 days
let retain_until_30 = future_retain_until(30);
let version_id = put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Compliance, retain_until_30)
.await
.unwrap();
// Extend to 60 days - should succeed
let retain_until_60 = future_retain_until(60);
let extend_result = put_object_retention(
&client,
bucket,
key,
Some(&version_id),
ObjectLockRetentionMode::Compliance,
retain_until_60,
false,
)
.await;
assert!(extend_result.is_ok(), "Extending COMPLIANCE retention should succeed");
info!("✅ Test passed: Can extend COMPLIANCE retention");
}
#[tokio::test]
#[serial]
async fn test_put_retention_governance_extend_without_bypass() {
init_logging();
info!("🧪 Test: PutObjectRetention on GOVERNANCE can extend without bypass");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-governance-extend";
let key = "governance-extend-object";
let data = b"test data";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Put object with GOVERNANCE retention for 30 days
let retain_until_30 = future_retain_until(30);
let version_id = put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Governance, retain_until_30)
.await
.unwrap();
// Extend to 60 days without bypass - should succeed (AWS S3 behavior)
let retain_until_60 = future_retain_until(60);
let extend_without_bypass = put_object_retention(
&client,
bucket,
key,
Some(&version_id),
ObjectLockRetentionMode::Governance,
retain_until_60,
false,
)
.await;
assert!(
extend_without_bypass.is_ok(),
"Extending GOVERNANCE retention without bypass should succeed"
);
info!("✅ Test passed: GOVERNANCE retention can be extended without bypass");
}
#[tokio::test]
#[serial]
async fn test_put_retention_governance_shorten_requires_bypass() {
init_logging();
info!("🧪 Test: PutObjectRetention on GOVERNANCE requires bypass to shorten");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-governance-shorten";
let key = "governance-shorten-object";
let data = b"test data";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Put object with GOVERNANCE retention for 60 days
let retain_until_60 = future_retain_until(60);
let version_id = put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Governance, retain_until_60)
.await
.unwrap();
// Try to shorten to 30 days without bypass - should fail
let retain_until_30 = future_retain_until(30);
let shorten_without_bypass = put_object_retention(
&client,
bucket,
key,
Some(&version_id),
ObjectLockRetentionMode::Governance,
retain_until_30,
false,
)
.await;
assert!(
shorten_without_bypass.is_err(),
"Shortening GOVERNANCE retention without bypass should fail"
);
// Shorten with bypass - should succeed
let shorten_with_bypass = put_object_retention(
&client,
bucket,
key,
Some(&version_id),
ObjectLockRetentionMode::Governance,
retain_until_30,
true,
)
.await;
assert!(shorten_with_bypass.is_ok(), "Shortening GOVERNANCE retention with bypass should succeed");
info!("✅ Test passed: GOVERNANCE retention shortening requires bypass");
}
// ============================================================================
// Default Retention Tests
// ============================================================================
#[tokio::test]
#[serial]
async fn test_default_retention_applied_to_new_objects() {
init_logging();
info!("🧪 Test: Default retention is applied to new objects");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-default-retention";
let key = "object-with-default-retention";
let data = b"test data";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Set default retention: GOVERNANCE for 30 days
put_object_lock_configuration(&client, bucket, ObjectLockRetentionMode::Governance, Some(30), None)
.await
.unwrap();
// Put object without explicit retention
let response = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(data.to_vec()))
.send()
.await
.unwrap();
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");
info!("✅ Test passed: Default retention is applied to new objects");
}
// ============================================================================
// Versioning Auto-Enable Tests
// ============================================================================
#[tokio::test]
#[serial]
async fn test_versioning_auto_enabled_with_object_lock() {
init_logging();
info!("🧪 Test: Versioning is auto-enabled when Object Lock is configured");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-versioning-auto-enable";
// Create bucket with Object Lock enabled
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Check versioning status - should be Enabled
let versioning = client.get_bucket_versioning().bucket(bucket).send().await.unwrap();
// Object Lock buckets must have versioning enabled
// Note: Some S3 implementations may report MfaDelete status as well
let status = versioning.status();
info!("Versioning status: {:?}", status);
// Put an object and verify it gets a version ID
let key = "versioned-object";
let response = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(b"v1".to_vec()))
.send()
.await
.unwrap();
let version1 = response.version_id();
assert!(version1.is_some(), "Object should have a version ID");
// Put another version
let response2 = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(b"v2".to_vec()))
.send()
.await
.unwrap();
let version2 = response2.version_id();
assert!(version2.is_some(), "Second object should have a version ID");
assert_ne!(version1, version2, "Version IDs should be different");
info!("✅ Test passed: Versioning is auto-enabled with Object Lock");
}
// ============================================================================
// Error Message Tests
// ============================================================================
#[tokio::test]
#[serial]
async fn test_error_message_distinguishes_legal_hold_from_retention() {
init_logging();
info!("🧪 Test: Error messages distinguish Legal Hold from Retention");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-error-messages";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Put object with legal hold
let legal_hold_key = "legal-hold-object";
let lh_version = put_object_with_legal_hold(&client, bucket, legal_hold_key, b"data", ObjectLockLegalHoldStatus::On)
.await
.unwrap();
// Put object with retention
let retention_key = "retention-object";
let retain_until = future_retain_until(30);
let ret_version =
put_object_with_retention(&client, bucket, retention_key, b"data", ObjectLockRetentionMode::Compliance, retain_until)
.await
.unwrap();
// Delete legal hold object - check error
let lh_delete_result = client
.delete_object()
.bucket(bucket)
.key(legal_hold_key)
.version_id(&lh_version)
.send()
.await;
if let Err(e) = lh_delete_result {
let error_str = format!("{:?}", e);
info!("Legal hold delete error: {}", error_str);
// Error should mention legal hold
assert!(
error_str.to_lowercase().contains("legal") || error_str.to_lowercase().contains("hold"),
"Error should mention legal hold"
);
}
// Delete retention object - check error
let ret_delete_result = client
.delete_object()
.bucket(bucket)
.key(retention_key)
.version_id(&ret_version)
.send()
.await;
if let Err(e) = ret_delete_result {
let error_str = format!("{:?}", e);
info!("Retention delete error: {}", error_str);
// Error should mention retention
assert!(
error_str.to_lowercase().contains("retention") || error_str.to_lowercase().contains("compliance"),
"Error should mention retention"
);
}
info!("✅ Test passed: Error messages distinguish lock types");
}