mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-02 11:29:17 +00:00
10ba2273c4
test(e2e): align object-lock overwrite tests with AWS new-version semantics PR #3179 made put-like writes (PUT, CopyObject, multipart create/complete) on versioned object-lock buckets create a new version instead of being blocked by the current version's legal hold or COMPLIANCE retention, which is the correct AWS behavior. Six e2e assertions in object_lock_test.rs still encoded the old blocking semantics and, because e2e_test is excluded from the CI test run, stayed red unnoticed since 2026-06-03. Verified against a real server built from current main: all six failed at their "should be blocked" assertions. Rewritten to assert the AWS contract: the write succeeds with a distinct new version id, the new content is current, the protected version keeps its content and lock metadata, and version-targeted deletion of the protected version stays blocked (for COMPLIANCE even with bypass_governance). Full object_lock module is green again (33/33).
2251 lines
70 KiB
Rust
2251 lines
70 KiB
Rust
// 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::Client;
|
|
use aws_sdk_s3::primitives::{ByteStream, DateTimeFormat};
|
|
use aws_sdk_s3::types::{
|
|
CompletedMultipartUpload, CompletedPart, Delete, MetadataDirective, ObjectIdentifier, ObjectLockLegalHoldStatus,
|
|
ObjectLockMode, ObjectLockRetentionMode,
|
|
};
|
|
use chrono::{DateTime, Duration, Utc};
|
|
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();
|
|
}
|
|
|
|
async fn put_bucket_deny_policy(
|
|
client: &Client,
|
|
bucket: &str,
|
|
sid: &str,
|
|
action: &str,
|
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
let policy = serde_json::json!({
|
|
"Version": "2012-10-17",
|
|
"Statement": [{
|
|
"Sid": sid,
|
|
"Effect": "Deny",
|
|
"Principal": "*",
|
|
"Action": action,
|
|
"Resource": format!("arn:aws:s3:::{}/*", bucket)
|
|
}]
|
|
})
|
|
.to_string();
|
|
|
|
client.put_bucket_policy().bucket(bucket).policy(policy).send().await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn retention_timestamp(days: i64) -> aws_sdk_s3::primitives::DateTime {
|
|
let retain_until = future_retain_until(days).format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
|
aws_sdk_s3::primitives::DateTime::from_str(&retain_until, aws_sdk_s3::primitives::DateTimeFormat::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_invalid_object_lock_retention_pair<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("InvalidRequest") || err.contains("must both be supplied"),
|
|
"{context}: expected invalid paired retention headers, got: {err}"
|
|
);
|
|
}
|
|
|
|
fn parse_s3_datetime(value: &aws_sdk_s3::primitives::DateTime) -> DateTime<Utc> {
|
|
let formatted = value
|
|
.fmt(DateTimeFormat::DateTime)
|
|
.expect("S3 timestamp should format as RFC3339");
|
|
DateTime::parse_from_rfc3339(&formatted)
|
|
.expect("S3 timestamp should parse as RFC3339")
|
|
.with_timezone(&Utc)
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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_creates_delete_marker_for_retained_current_version() {
|
|
init_logging();
|
|
info!("🧪 Test: DeleteObject creates delete marker for retained current version");
|
|
|
|
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
|
env.start_rustfs().await.unwrap();
|
|
|
|
let bucket = "test-retention-delete-marker";
|
|
let key = "retained-object";
|
|
let data = b"test data for retained current version";
|
|
|
|
env.create_object_lock_bucket(bucket).await.unwrap();
|
|
|
|
let client = env.s3_client();
|
|
|
|
let retain_until = future_retain_until(30);
|
|
let retained_version_id =
|
|
put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Governance, retain_until)
|
|
.await
|
|
.unwrap();
|
|
|
|
let delete_marker_output = client.delete_object().bucket(bucket).key(key).send().await.unwrap();
|
|
assert_eq!(delete_marker_output.delete_marker(), Some(true));
|
|
|
|
let delete_marker_version_id = delete_marker_output
|
|
.version_id()
|
|
.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");
|
|
|
|
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"
|
|
);
|
|
|
|
delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true)
|
|
.await
|
|
.unwrap();
|
|
|
|
info!("✅ Test passed: Delete marker is allowed while retained version stays protected");
|
|
}
|
|
|
|
#[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_allowed_with_legal_hold_off() {
|
|
init_logging();
|
|
info!("🧪 Test: DeleteObject allowed with Legal Hold OFF");
|
|
|
|
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
|
env.start_rustfs().await.unwrap();
|
|
|
|
let bucket = "test-legal-hold-off-delete";
|
|
let key = "legal-hold-off-object";
|
|
let data = b"test data for legal hold off";
|
|
|
|
env.create_object_lock_bucket(bucket).await.unwrap();
|
|
|
|
let client = env.s3_client();
|
|
|
|
let version_id = put_object_with_legal_hold(&client, bucket, key, data, ObjectLockLegalHoldStatus::Off)
|
|
.await
|
|
.unwrap();
|
|
|
|
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
|
|
.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");
|
|
|
|
info!("✅ Test passed: Legal Hold OFF allows 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");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_get_object_legal_hold_returns_updated_status() {
|
|
init_logging();
|
|
info!("🧪 Test: GetObjectLegalHold returns updated status");
|
|
|
|
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
|
env.start_rustfs().await.unwrap();
|
|
|
|
let bucket = "test-get-legal-hold";
|
|
let key = "legal-hold-object";
|
|
|
|
env.create_object_lock_bucket(bucket).await.unwrap();
|
|
|
|
let client = env.s3_client();
|
|
let version_id = put_object_with_legal_hold(&client, bucket, key, b"test data", ObjectLockLegalHoldStatus::On)
|
|
.await
|
|
.unwrap();
|
|
|
|
let on_hold = client
|
|
.get_object_legal_hold()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.version_id(&version_id)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
on_hold
|
|
.legal_hold()
|
|
.and_then(|value| value.status())
|
|
.map(|value| value.as_str()),
|
|
Some("ON")
|
|
);
|
|
|
|
put_object_legal_hold(&client, bucket, key, Some(&version_id), ObjectLockLegalHoldStatus::Off)
|
|
.await
|
|
.unwrap();
|
|
|
|
let off_hold = client
|
|
.get_object_legal_hold()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.version_id(&version_id)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
off_hold
|
|
.legal_hold()
|
|
.and_then(|value| value.status())
|
|
.map(|value| value.as_str()),
|
|
Some("OFF")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_get_object_retention_returns_configured_values() {
|
|
init_logging();
|
|
info!("🧪 Test: GetObjectRetention returns configured values");
|
|
|
|
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
|
env.start_rustfs().await.unwrap();
|
|
|
|
let bucket = "test-get-retention";
|
|
let key = "retained-object";
|
|
let retain_until = future_retain_until(30);
|
|
let retain_until_expected = retain_until.format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
|
|
|
env.create_object_lock_bucket(bucket).await.unwrap();
|
|
|
|
let client = env.s3_client();
|
|
let version_id =
|
|
put_object_with_retention(&client, bucket, key, b"test data", ObjectLockRetentionMode::Governance, retain_until)
|
|
.await
|
|
.unwrap();
|
|
|
|
let retention = client
|
|
.get_object_retention()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.version_id(&version_id)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let retention = retention.retention().expect("retention should be present");
|
|
|
|
assert_eq!(retention.mode().map(|value| value.as_str()), Some("GOVERNANCE"));
|
|
assert_eq!(
|
|
retention
|
|
.retain_until_date()
|
|
.expect("retain_until_date should be present")
|
|
.fmt(aws_sdk_s3::primitives::DateTimeFormat::DateTime)
|
|
.unwrap(),
|
|
retain_until_expected
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Put/Copy/Multipart Legal Hold Tests
|
|
// ============================================================================
|
|
|
|
// AWS semantics (PR #3179): Object Lock buckets are always versioned, so a
|
|
// plain PUT/copy/multipart write to a held or retained key succeeds by
|
|
// creating a new current version. The lock protects the existing version
|
|
// from deletion; it never blocks new versions.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_put_object_overwrite_creates_new_version_under_legal_hold() {
|
|
init_logging();
|
|
info!("🧪 Test: PutObject overwrite of a legal-hold version creates a new version");
|
|
|
|
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
|
env.start_rustfs().await.unwrap();
|
|
|
|
let bucket = "test-put-overwrite-legal-hold";
|
|
let key = "overwrite-object";
|
|
|
|
env.create_object_lock_bucket(bucket).await.unwrap();
|
|
|
|
let client = env.s3_client();
|
|
|
|
let held_version_id = put_object_with_legal_hold(&client, bucket, key, b"locked-body", ObjectLockLegalHoldStatus::On)
|
|
.await
|
|
.unwrap();
|
|
|
|
let overwrite_output = client
|
|
.put_object()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.body(ByteStream::from(b"replacement-body".to_vec()))
|
|
.send()
|
|
.await
|
|
.expect("PutObject overwrite should succeed by creating a new version");
|
|
|
|
let new_version_id = overwrite_output
|
|
.version_id()
|
|
.expect("versioned put should return a version id")
|
|
.to_string();
|
|
assert_ne!(new_version_id, held_version_id, "overwrite must create a distinct version");
|
|
|
|
let current_body = client
|
|
.get_object()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.body
|
|
.collect()
|
|
.await
|
|
.unwrap()
|
|
.into_bytes();
|
|
assert_eq!(current_body.as_ref(), b"replacement-body");
|
|
|
|
let held_body = client
|
|
.get_object()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.version_id(&held_version_id)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.body
|
|
.collect()
|
|
.await
|
|
.unwrap()
|
|
.into_bytes();
|
|
assert_eq!(held_body.as_ref(), b"locked-body", "held version must keep its original content");
|
|
|
|
let held_legal_hold = client
|
|
.get_object_legal_hold()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.version_id(&held_version_id)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
held_legal_hold
|
|
.legal_hold()
|
|
.and_then(|value| value.status())
|
|
.map(|value| value.as_str()),
|
|
Some("ON"),
|
|
"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");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_copy_object_applies_requested_legal_hold() {
|
|
init_logging();
|
|
info!("🧪 Test: CopyObject applies requested Legal Hold");
|
|
|
|
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
|
env.start_rustfs().await.unwrap();
|
|
|
|
let bucket = "test-copy-object-legal-hold";
|
|
let src_key = "src-object";
|
|
let dst_key = "dst-object";
|
|
|
|
env.create_object_lock_bucket(bucket).await.unwrap();
|
|
|
|
let client = env.s3_client();
|
|
client
|
|
.put_object()
|
|
.bucket(bucket)
|
|
.key(src_key)
|
|
.body(ByteStream::from(b"copy-source".to_vec()))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
client
|
|
.copy_object()
|
|
.copy_source(format!("{bucket}/{src_key}"))
|
|
.bucket(bucket)
|
|
.key(dst_key)
|
|
.object_lock_legal_hold_status(ObjectLockLegalHoldStatus::On)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let legal_hold = client
|
|
.get_object_legal_hold()
|
|
.bucket(bucket)
|
|
.key(dst_key)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
legal_hold
|
|
.legal_hold()
|
|
.and_then(|value| value.status())
|
|
.map(|value| value.as_str()),
|
|
Some("ON")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_copy_object_does_not_inherit_source_legal_hold() {
|
|
init_logging();
|
|
info!("🧪 Test: CopyObject does not inherit source Legal Hold");
|
|
|
|
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
|
env.start_rustfs().await.unwrap();
|
|
|
|
let bucket = "test-copy-object-legal-hold-inherit";
|
|
let src_key = "held-source";
|
|
|
|
env.create_object_lock_bucket(bucket).await.unwrap();
|
|
|
|
let client = env.s3_client();
|
|
put_object_with_legal_hold(&client, bucket, src_key, b"copy-source", ObjectLockLegalHoldStatus::On)
|
|
.await
|
|
.unwrap();
|
|
|
|
client
|
|
.copy_object()
|
|
.copy_source(format!("{bucket}/{src_key}"))
|
|
.bucket(bucket)
|
|
.key("implicit-copy")
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let implicit_legal_hold = client
|
|
.get_object_legal_hold()
|
|
.bucket(bucket)
|
|
.key("implicit-copy")
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
implicit_legal_hold
|
|
.legal_hold()
|
|
.and_then(|value| value.status())
|
|
.map(|value| value.as_str()),
|
|
Some("OFF")
|
|
);
|
|
|
|
client
|
|
.copy_object()
|
|
.copy_source(format!("{bucket}/{src_key}"))
|
|
.bucket(bucket)
|
|
.key("explicit-on-copy")
|
|
.object_lock_legal_hold_status(ObjectLockLegalHoldStatus::On)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let explicit_on_legal_hold = client
|
|
.get_object_legal_hold()
|
|
.bucket(bucket)
|
|
.key("explicit-on-copy")
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
explicit_on_legal_hold
|
|
.legal_hold()
|
|
.and_then(|value| value.status())
|
|
.map(|value| value.as_str()),
|
|
Some("ON")
|
|
);
|
|
|
|
client
|
|
.copy_object()
|
|
.copy_source(format!("{bucket}/{src_key}"))
|
|
.bucket(bucket)
|
|
.key("explicit-off-copy")
|
|
.object_lock_legal_hold_status(ObjectLockLegalHoldStatus::Off)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let explicit_off_legal_hold = client
|
|
.get_object_legal_hold()
|
|
.bucket(bucket)
|
|
.key("explicit-off-copy")
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
explicit_off_legal_hold
|
|
.legal_hold()
|
|
.and_then(|value| value.status())
|
|
.map(|value| value.as_str()),
|
|
Some("OFF")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_copy_object_overwrite_creates_new_version_under_legal_hold() {
|
|
init_logging();
|
|
info!("🧪 Test: CopyObject overwrite of a legal-hold destination creates a new version");
|
|
|
|
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
|
env.start_rustfs().await.unwrap();
|
|
|
|
let bucket = "test-copy-overwrite-legal-hold";
|
|
let src_key = "src-object";
|
|
let dst_key = "dst-object";
|
|
|
|
env.create_object_lock_bucket(bucket).await.unwrap();
|
|
|
|
let client = env.s3_client();
|
|
client
|
|
.put_object()
|
|
.bucket(bucket)
|
|
.key(src_key)
|
|
.body(ByteStream::from(b"copy-source".to_vec()))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let held_version_id =
|
|
put_object_with_legal_hold(&client, bucket, dst_key, b"locked-destination", ObjectLockLegalHoldStatus::On)
|
|
.await
|
|
.unwrap();
|
|
|
|
let copy_output = client
|
|
.copy_object()
|
|
.copy_source(format!("{bucket}/{src_key}"))
|
|
.bucket(bucket)
|
|
.key(dst_key)
|
|
.send()
|
|
.await
|
|
.expect("CopyObject overwrite should succeed by creating a new destination version");
|
|
|
|
let new_version_id = copy_output
|
|
.version_id()
|
|
.expect("versioned copy should return a version id")
|
|
.to_string();
|
|
assert_ne!(new_version_id, held_version_id, "copy must create a distinct destination version");
|
|
|
|
let current_body = client
|
|
.get_object()
|
|
.bucket(bucket)
|
|
.key(dst_key)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.body
|
|
.collect()
|
|
.await
|
|
.unwrap()
|
|
.into_bytes();
|
|
assert_eq!(current_body.as_ref(), b"copy-source");
|
|
|
|
let held_legal_hold = client
|
|
.get_object_legal_hold()
|
|
.bucket(bucket)
|
|
.key(dst_key)
|
|
.version_id(&held_version_id)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
held_legal_hold
|
|
.legal_hold()
|
|
.and_then(|value| value.status())
|
|
.map(|value| value.as_str()),
|
|
Some("ON"),
|
|
"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");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_create_multipart_upload_applies_requested_legal_hold() {
|
|
init_logging();
|
|
info!("🧪 Test: CreateMultipartUpload applies requested Legal Hold");
|
|
|
|
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
|
env.start_rustfs().await.unwrap();
|
|
|
|
let bucket = "test-multipart-legal-hold";
|
|
let key = "multipart-object";
|
|
|
|
env.create_object_lock_bucket(bucket).await.unwrap();
|
|
|
|
let client = env.s3_client();
|
|
let create_output = client
|
|
.create_multipart_upload()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.object_lock_legal_hold_status(ObjectLockLegalHoldStatus::On)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let upload_id = create_output.upload_id().unwrap();
|
|
let upload_part_output = client
|
|
.upload_part()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.upload_id(upload_id)
|
|
.part_number(1)
|
|
.body(ByteStream::from(b"multipart-body".to_vec()))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let completed_upload = CompletedMultipartUpload::builder()
|
|
.parts(
|
|
CompletedPart::builder()
|
|
.part_number(1)
|
|
.e_tag(upload_part_output.e_tag().unwrap_or_default())
|
|
.build(),
|
|
)
|
|
.build();
|
|
|
|
client
|
|
.complete_multipart_upload()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.upload_id(upload_id)
|
|
.multipart_upload(completed_upload)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let legal_hold = client.get_object_legal_hold().bucket(bucket).key(key).send().await.unwrap();
|
|
|
|
assert_eq!(
|
|
legal_hold
|
|
.legal_hold()
|
|
.and_then(|value| value.status())
|
|
.map(|value| value.as_str()),
|
|
Some("ON")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_create_multipart_upload_creates_new_version_under_compliance_retention() {
|
|
init_logging();
|
|
info!("🧪 Test: CreateMultipartUpload over a COMPLIANCE-retained key creates a new version");
|
|
|
|
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
|
env.start_rustfs().await.unwrap();
|
|
|
|
let bucket = "test-multipart-create-compliance";
|
|
let key = "protected-object";
|
|
|
|
env.create_object_lock_bucket(bucket).await.unwrap();
|
|
|
|
let client = env.s3_client();
|
|
let retained_version_id = put_object_with_retention(
|
|
&client,
|
|
bucket,
|
|
key,
|
|
b"locked-destination",
|
|
ObjectLockRetentionMode::Compliance,
|
|
future_retain_until(30),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let create_output = client
|
|
.create_multipart_upload()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.send()
|
|
.await
|
|
.expect("CreateMultipartUpload should succeed; completion will create a new version");
|
|
|
|
let upload_id = create_output.upload_id().unwrap();
|
|
let upload_part_output = client
|
|
.upload_part()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.upload_id(upload_id)
|
|
.part_number(1)
|
|
.body(ByteStream::from(b"multipart-body".to_vec()))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let completed_upload = CompletedMultipartUpload::builder()
|
|
.parts(
|
|
CompletedPart::builder()
|
|
.part_number(1)
|
|
.e_tag(upload_part_output.e_tag().unwrap_or_default())
|
|
.build(),
|
|
)
|
|
.build();
|
|
|
|
let complete_output = client
|
|
.complete_multipart_upload()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.upload_id(upload_id)
|
|
.multipart_upload(completed_upload)
|
|
.send()
|
|
.await
|
|
.expect("CompleteMultipartUpload should succeed by creating a new version");
|
|
|
|
let new_version_id = complete_output
|
|
.version_id()
|
|
.expect("versioned multipart completion should return a version id")
|
|
.to_string();
|
|
assert_ne!(new_version_id, retained_version_id, "completion must create a distinct version");
|
|
|
|
// 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"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_delete_completed_multipart_object_blocked_by_legal_hold() {
|
|
init_logging();
|
|
info!("🧪 Test: Delete completed multipart object blocked by Legal Hold");
|
|
|
|
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
|
env.start_rustfs().await.unwrap();
|
|
|
|
let bucket = "test-multipart-delete-legal-hold";
|
|
let key = "multipart-object";
|
|
|
|
env.create_object_lock_bucket(bucket).await.unwrap();
|
|
|
|
let client = env.s3_client();
|
|
let create_output = client
|
|
.create_multipart_upload()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.object_lock_legal_hold_status(ObjectLockLegalHoldStatus::On)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let upload_id = create_output.upload_id().unwrap();
|
|
let upload_part_output = client
|
|
.upload_part()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.upload_id(upload_id)
|
|
.part_number(1)
|
|
.body(ByteStream::from(b"multipart-body".to_vec()))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let completed_upload = CompletedMultipartUpload::builder()
|
|
.parts(
|
|
CompletedPart::builder()
|
|
.part_number(1)
|
|
.e_tag(upload_part_output.e_tag().unwrap_or_default())
|
|
.build(),
|
|
)
|
|
.build();
|
|
|
|
let complete_output = client
|
|
.complete_multipart_upload()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.upload_id(upload_id)
|
|
.multipart_upload(completed_upload)
|
|
.send()
|
|
.await
|
|
.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");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_delete_completed_multipart_object_blocked_by_retention() {
|
|
init_logging();
|
|
info!("🧪 Test: Delete completed multipart object blocked by retention");
|
|
|
|
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
|
env.start_rustfs().await.unwrap();
|
|
|
|
let bucket = "test-multipart-delete-retention";
|
|
let key = "multipart-object";
|
|
let retain_until = retention_timestamp(30);
|
|
|
|
env.create_object_lock_bucket(bucket).await.unwrap();
|
|
|
|
let client = env.s3_client();
|
|
let create_output = client
|
|
.create_multipart_upload()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.object_lock_mode(aws_sdk_s3::types::ObjectLockMode::Compliance)
|
|
.object_lock_retain_until_date(retain_until)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let upload_id = create_output.upload_id().unwrap();
|
|
let upload_part_output = client
|
|
.upload_part()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.upload_id(upload_id)
|
|
.part_number(1)
|
|
.body(ByteStream::from(b"multipart-body".to_vec()))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let completed_upload = CompletedMultipartUpload::builder()
|
|
.parts(
|
|
CompletedPart::builder()
|
|
.part_number(1)
|
|
.e_tag(upload_part_output.e_tag().unwrap_or_default())
|
|
.build(),
|
|
)
|
|
.build();
|
|
|
|
let complete_output = client
|
|
.complete_multipart_upload()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.upload_id(upload_id)
|
|
.multipart_upload(completed_upload)
|
|
.send()
|
|
.await
|
|
.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");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_complete_multipart_upload_creates_new_version_under_legal_hold() {
|
|
init_logging();
|
|
info!("🧪 Test: CompleteMultipartUpload creates a new version when the current version is under Legal Hold");
|
|
|
|
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
|
env.start_rustfs().await.unwrap();
|
|
|
|
let bucket = "test-complete-multipart-legal-hold";
|
|
let key = "multipart-race-object";
|
|
|
|
env.create_object_lock_bucket(bucket).await.unwrap();
|
|
|
|
let client = env.s3_client();
|
|
let create_output = client.create_multipart_upload().bucket(bucket).key(key).send().await.unwrap();
|
|
|
|
let upload_id = create_output.upload_id().unwrap();
|
|
let upload_part_output = client
|
|
.upload_part()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.upload_id(upload_id)
|
|
.part_number(1)
|
|
.body(ByteStream::from(b"multipart-body".to_vec()))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let held_version_id =
|
|
put_object_with_legal_hold(&client, bucket, key, b"locked-current-version", ObjectLockLegalHoldStatus::On)
|
|
.await
|
|
.unwrap();
|
|
|
|
let completed_upload = CompletedMultipartUpload::builder()
|
|
.parts(
|
|
CompletedPart::builder()
|
|
.part_number(1)
|
|
.e_tag(upload_part_output.e_tag().unwrap_or_default())
|
|
.build(),
|
|
)
|
|
.build();
|
|
|
|
let complete_output = client
|
|
.complete_multipart_upload()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.upload_id(upload_id)
|
|
.multipart_upload(completed_upload)
|
|
.send()
|
|
.await
|
|
.expect("CompleteMultipartUpload should succeed by creating a new version over the held one");
|
|
|
|
let new_version_id = complete_output
|
|
.version_id()
|
|
.expect("versioned multipart completion should return a version id")
|
|
.to_string();
|
|
assert_ne!(new_version_id, held_version_id, "completion must create a distinct version");
|
|
|
|
let held_legal_hold = client
|
|
.get_object_legal_hold()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.version_id(&held_version_id)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
held_legal_hold
|
|
.legal_hold()
|
|
.and_then(|value| value.status())
|
|
.map(|value| value.as_str()),
|
|
Some("ON"),
|
|
"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");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_complete_multipart_upload_creates_new_version_under_compliance_retention() {
|
|
init_logging();
|
|
info!("🧪 Test: CompleteMultipartUpload creates a new version when the current version is under COMPLIANCE retention");
|
|
|
|
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
|
env.start_rustfs().await.unwrap();
|
|
|
|
let bucket = "test-complete-multipart-compliance";
|
|
let key = "multipart-race-object";
|
|
|
|
env.create_object_lock_bucket(bucket).await.unwrap();
|
|
|
|
let client = env.s3_client();
|
|
let create_output = client.create_multipart_upload().bucket(bucket).key(key).send().await.unwrap();
|
|
|
|
let upload_id = create_output.upload_id().unwrap();
|
|
let upload_part_output = client
|
|
.upload_part()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.upload_id(upload_id)
|
|
.part_number(1)
|
|
.body(ByteStream::from(b"multipart-body".to_vec()))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let retained_version_id = put_object_with_retention(
|
|
&client,
|
|
bucket,
|
|
key,
|
|
b"locked-current-version",
|
|
ObjectLockRetentionMode::Compliance,
|
|
future_retain_until(30),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let completed_upload = CompletedMultipartUpload::builder()
|
|
.parts(
|
|
CompletedPart::builder()
|
|
.part_number(1)
|
|
.e_tag(upload_part_output.e_tag().unwrap_or_default())
|
|
.build(),
|
|
)
|
|
.build();
|
|
|
|
let complete_output = client
|
|
.complete_multipart_upload()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.upload_id(upload_id)
|
|
.multipart_upload(completed_upload)
|
|
.send()
|
|
.await
|
|
.expect("CompleteMultipartUpload should succeed by creating a new version over the retained one");
|
|
|
|
let new_version_id = complete_output
|
|
.version_id()
|
|
.expect("versioned multipart completion should return a version id")
|
|
.to_string();
|
|
assert_ne!(new_version_id, retained_version_id, "completion must create a distinct version");
|
|
|
|
// 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"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_write_paths_require_put_object_legal_hold_permission() {
|
|
init_logging();
|
|
info!("🧪 Test: write paths require PutObjectLegalHold permission");
|
|
|
|
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
|
env.start_rustfs().await.unwrap();
|
|
|
|
let bucket = "test-legal-hold-permissions";
|
|
let src_key = "src-object";
|
|
|
|
env.create_object_lock_bucket(bucket).await.unwrap();
|
|
|
|
let client = env.s3_client();
|
|
client
|
|
.put_object()
|
|
.bucket(bucket)
|
|
.key(src_key)
|
|
.body(ByteStream::from(b"copy-source".to_vec()))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
put_bucket_deny_policy(&client, bucket, "DenyPutObjectLegalHold", "s3:PutObjectLegalHold")
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_access_denied(
|
|
client
|
|
.put_object()
|
|
.bucket(bucket)
|
|
.key("put-target")
|
|
.body(ByteStream::from(b"put-body".to_vec()))
|
|
.object_lock_legal_hold_status(ObjectLockLegalHoldStatus::On)
|
|
.send()
|
|
.await,
|
|
"PutObject with legal hold should require s3:PutObjectLegalHold",
|
|
);
|
|
|
|
assert_access_denied(
|
|
client
|
|
.copy_object()
|
|
.copy_source(format!("{bucket}/{src_key}"))
|
|
.bucket(bucket)
|
|
.key("copy-target")
|
|
.object_lock_legal_hold_status(ObjectLockLegalHoldStatus::On)
|
|
.send()
|
|
.await,
|
|
"CopyObject with legal hold should require s3:PutObjectLegalHold",
|
|
);
|
|
|
|
assert_access_denied(
|
|
client
|
|
.create_multipart_upload()
|
|
.bucket(bucket)
|
|
.key("multipart-target")
|
|
.object_lock_legal_hold_status(ObjectLockLegalHoldStatus::On)
|
|
.send()
|
|
.await,
|
|
"CreateMultipartUpload with legal hold should require s3:PutObjectLegalHold",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_write_paths_require_put_object_retention_permission() {
|
|
init_logging();
|
|
info!("🧪 Test: write paths require PutObjectRetention permission");
|
|
|
|
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
|
env.start_rustfs().await.unwrap();
|
|
|
|
let bucket = "test-retention-permissions";
|
|
let src_key = "src-object";
|
|
let retain_until = retention_timestamp(30);
|
|
|
|
env.create_object_lock_bucket(bucket).await.unwrap();
|
|
|
|
let client = env.s3_client();
|
|
client
|
|
.put_object()
|
|
.bucket(bucket)
|
|
.key(src_key)
|
|
.body(ByteStream::from(b"copy-source".to_vec()))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
put_bucket_deny_policy(&client, bucket, "DenyPutObjectRetention", "s3:PutObjectRetention")
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_access_denied(
|
|
client
|
|
.put_object()
|
|
.bucket(bucket)
|
|
.key("put-target")
|
|
.body(ByteStream::from(b"put-body".to_vec()))
|
|
.object_lock_mode(aws_sdk_s3::types::ObjectLockMode::Governance)
|
|
.object_lock_retain_until_date(retain_until)
|
|
.send()
|
|
.await,
|
|
"PutObject with retention should require s3:PutObjectRetention",
|
|
);
|
|
|
|
assert_access_denied(
|
|
client
|
|
.copy_object()
|
|
.copy_source(format!("{bucket}/{src_key}"))
|
|
.bucket(bucket)
|
|
.key("copy-target")
|
|
.object_lock_mode(aws_sdk_s3::types::ObjectLockMode::Governance)
|
|
.object_lock_retain_until_date(retain_until)
|
|
.send()
|
|
.await,
|
|
"CopyObject with retention should require s3:PutObjectRetention",
|
|
);
|
|
|
|
assert_access_denied(
|
|
client
|
|
.create_multipart_upload()
|
|
.bucket(bucket)
|
|
.key("multipart-target")
|
|
.object_lock_mode(aws_sdk_s3::types::ObjectLockMode::Governance)
|
|
.object_lock_retain_until_date(retain_until)
|
|
.send()
|
|
.await,
|
|
"CreateMultipartUpload with retention should require s3:PutObjectRetention",
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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");
|
|
|
|
let retention = client
|
|
.get_object_retention()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.version_id(version_id)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let retention = retention.retention().expect("default retention should be readable");
|
|
assert_eq!(retention.mode().map(|value| value.as_str()), Some("GOVERNANCE"));
|
|
assert!(
|
|
retention.retain_until_date().is_some(),
|
|
"default retention should write a retain-until date"
|
|
);
|
|
|
|
let head = client
|
|
.head_object()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.version_id(version_id)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(head.object_lock_mode().map(|value| value.as_str()), Some("GOVERNANCE"));
|
|
assert!(
|
|
head.object_lock_retain_until_date().is_some(),
|
|
"HeadObject should expose the default retention retain-until date"
|
|
);
|
|
|
|
info!("✅ Test passed: Default retention is applied to new objects");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_delete_object_creates_delete_marker_for_default_retained_current_version() {
|
|
init_logging();
|
|
info!("🧪 Test: DeleteObject creates delete marker for default-retained current version");
|
|
|
|
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
|
env.start_rustfs().await.unwrap();
|
|
|
|
let bucket = "test-default-retention-delete-marker";
|
|
let key = "default-retained-object";
|
|
let data = b"test data for default-retained current version";
|
|
|
|
env.create_object_lock_bucket(bucket).await.unwrap();
|
|
|
|
let client = env.s3_client();
|
|
put_object_lock_configuration(&client, bucket, ObjectLockRetentionMode::Governance, Some(30), None)
|
|
.await
|
|
.unwrap();
|
|
|
|
let put_output = client
|
|
.put_object()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.body(ByteStream::from(data.to_vec()))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let retained_version_id = put_output
|
|
.version_id()
|
|
.expect("default-retained object should have a version id")
|
|
.to_string();
|
|
|
|
let retention = client
|
|
.get_object_retention()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.version_id(&retained_version_id)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let retention = retention.retention().expect("default retention should be readable");
|
|
assert_eq!(retention.mode().map(|value| value.as_str()), Some("GOVERNANCE"));
|
|
assert!(
|
|
retention.retain_until_date().is_some(),
|
|
"default retention should write a retain-until date"
|
|
);
|
|
|
|
let delete_marker_output = client.delete_object().bucket(bucket).key(key).send().await.unwrap();
|
|
assert_eq!(delete_marker_output.delete_marker(), Some(true));
|
|
let delete_marker_version_id = delete_marker_output
|
|
.version_id()
|
|
.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");
|
|
|
|
let retention_after_delete_marker = client
|
|
.get_object_retention()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.version_id(&retained_version_id)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let retention_after_delete_marker = retention_after_delete_marker
|
|
.retention()
|
|
.expect("default retention should remain readable by version id after delete marker creation");
|
|
assert_eq!(retention_after_delete_marker.mode().map(|value| value.as_str()), Some("GOVERNANCE"));
|
|
assert!(
|
|
retention_after_delete_marker.retain_until_date().is_some(),
|
|
"retained version should keep its retain-until date after delete marker creation"
|
|
);
|
|
|
|
delete_object_with_bypass(&client, bucket, key, Some(&delete_marker_version_id), false)
|
|
.await
|
|
.unwrap();
|
|
delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true)
|
|
.await
|
|
.unwrap();
|
|
|
|
info!("✅ Test passed: Delete marker is allowed while default-retained version stays protected");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_put_copy_and_multipart_reject_incomplete_retention_headers() {
|
|
init_logging();
|
|
info!("🧪 Test: write paths reject incomplete Object Lock retention headers");
|
|
|
|
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
|
env.start_rustfs().await.unwrap();
|
|
|
|
let bucket = "test-incomplete-retention";
|
|
let src_key = "copy-source";
|
|
|
|
env.create_object_lock_bucket(bucket).await.unwrap();
|
|
|
|
let client = env.s3_client();
|
|
client
|
|
.put_object()
|
|
.bucket(bucket)
|
|
.key(src_key)
|
|
.body(ByteStream::from(b"copy-source".to_vec()))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
put_object_lock_configuration(&client, bucket, ObjectLockRetentionMode::Governance, Some(30), None)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_invalid_object_lock_retention_pair(
|
|
client
|
|
.put_object()
|
|
.bucket(bucket)
|
|
.key("put-mode-only")
|
|
.body(ByteStream::from(b"put-body".to_vec()))
|
|
.object_lock_mode(ObjectLockMode::Governance)
|
|
.send()
|
|
.await,
|
|
"PutObject with mode only should fail",
|
|
);
|
|
|
|
assert_invalid_object_lock_retention_pair(
|
|
client
|
|
.put_object()
|
|
.bucket(bucket)
|
|
.key("put-date-only")
|
|
.body(ByteStream::from(b"put-body".to_vec()))
|
|
.object_lock_retain_until_date(retention_timestamp(30))
|
|
.send()
|
|
.await,
|
|
"PutObject with retain-until-date only should fail",
|
|
);
|
|
|
|
assert_invalid_object_lock_retention_pair(
|
|
client
|
|
.copy_object()
|
|
.copy_source(format!("{bucket}/{src_key}"))
|
|
.bucket(bucket)
|
|
.key("copy-mode-only")
|
|
.object_lock_mode(ObjectLockMode::Governance)
|
|
.send()
|
|
.await,
|
|
"CopyObject with mode only should fail",
|
|
);
|
|
|
|
assert_invalid_object_lock_retention_pair(
|
|
client
|
|
.copy_object()
|
|
.copy_source(format!("{bucket}/{src_key}"))
|
|
.bucket(bucket)
|
|
.key("copy-date-only")
|
|
.object_lock_retain_until_date(retention_timestamp(30))
|
|
.send()
|
|
.await,
|
|
"CopyObject with retain-until-date only should fail",
|
|
);
|
|
|
|
assert_invalid_object_lock_retention_pair(
|
|
client
|
|
.create_multipart_upload()
|
|
.bucket(bucket)
|
|
.key("multipart-mode-only")
|
|
.object_lock_mode(ObjectLockMode::Governance)
|
|
.send()
|
|
.await,
|
|
"CreateMultipartUpload with mode only should fail",
|
|
);
|
|
|
|
assert_invalid_object_lock_retention_pair(
|
|
client
|
|
.create_multipart_upload()
|
|
.bucket(bucket)
|
|
.key("multipart-date-only")
|
|
.object_lock_retain_until_date(retention_timestamp(30))
|
|
.send()
|
|
.await,
|
|
"CreateMultipartUpload with retain-until-date only should fail",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_copy_object_retention_uses_destination_policy() {
|
|
init_logging();
|
|
info!("🧪 Test: CopyObject retention follows destination policy");
|
|
|
|
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
|
env.start_rustfs().await.unwrap();
|
|
|
|
let src_bucket = "test-copy-retention-src";
|
|
let dst_bucket = "test-copy-retention-dst";
|
|
let no_default_bucket = "test-copy-retention-nodef";
|
|
let src_key = "retained-source";
|
|
|
|
env.create_object_lock_bucket(src_bucket).await.unwrap();
|
|
env.create_object_lock_bucket(dst_bucket).await.unwrap();
|
|
env.create_object_lock_bucket(no_default_bucket).await.unwrap();
|
|
|
|
let client = env.s3_client();
|
|
put_object_lock_configuration(&client, dst_bucket, ObjectLockRetentionMode::Governance, Some(1), None)
|
|
.await
|
|
.unwrap();
|
|
|
|
put_object_with_retention(
|
|
&client,
|
|
src_bucket,
|
|
src_key,
|
|
b"copy-source",
|
|
ObjectLockRetentionMode::Compliance,
|
|
future_retain_until(30),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let copy_started = Utc::now();
|
|
client
|
|
.copy_object()
|
|
.copy_source(format!("{src_bucket}/{src_key}"))
|
|
.bucket(dst_bucket)
|
|
.key("default-copy")
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let retention = client
|
|
.get_object_retention()
|
|
.bucket(dst_bucket)
|
|
.key("default-copy")
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let retention = retention
|
|
.retention()
|
|
.expect("destination default retention should be present");
|
|
assert_eq!(retention.mode().map(|value| value.as_str()), Some("GOVERNANCE"));
|
|
let retain_until = parse_s3_datetime(retention.retain_until_date().expect("retain-until date should be present"));
|
|
assert!(
|
|
retain_until < copy_started + Duration::days(3),
|
|
"destination default retention should not inherit the source's longer retention"
|
|
);
|
|
|
|
client
|
|
.copy_object()
|
|
.copy_source(format!("{src_bucket}/{src_key}"))
|
|
.bucket(dst_bucket)
|
|
.key("replace-copy")
|
|
.metadata_directive(MetadataDirective::Replace)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let replace_retention = client
|
|
.get_object_retention()
|
|
.bucket(dst_bucket)
|
|
.key("replace-copy")
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
replace_retention
|
|
.retention()
|
|
.and_then(|value| value.mode())
|
|
.map(|value| value.as_str()),
|
|
Some("GOVERNANCE")
|
|
);
|
|
|
|
client
|
|
.copy_object()
|
|
.copy_source(format!("{src_bucket}/{src_key}"))
|
|
.bucket(dst_bucket)
|
|
.key("explicit-copy")
|
|
.object_lock_mode(ObjectLockMode::Compliance)
|
|
.object_lock_retain_until_date(retention_timestamp(30))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let explicit_retention = client
|
|
.get_object_retention()
|
|
.bucket(dst_bucket)
|
|
.key("explicit-copy")
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
explicit_retention
|
|
.retention()
|
|
.and_then(|value| value.mode())
|
|
.map(|value| value.as_str()),
|
|
Some("COMPLIANCE")
|
|
);
|
|
let explicit_retain_until = parse_s3_datetime(
|
|
explicit_retention
|
|
.retention()
|
|
.and_then(|value| value.retain_until_date())
|
|
.expect("explicit retain-until date should be present"),
|
|
);
|
|
assert!(
|
|
explicit_retain_until > Utc::now() + Duration::days(20),
|
|
"explicit retention should override the shorter bucket default"
|
|
);
|
|
|
|
client
|
|
.copy_object()
|
|
.copy_source(format!("{src_bucket}/{src_key}"))
|
|
.bucket(no_default_bucket)
|
|
.key("no-default-copy")
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let no_default_retention = client
|
|
.get_object_retention()
|
|
.bucket(no_default_bucket)
|
|
.key("no-default-copy")
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let no_default_retention = no_default_retention
|
|
.retention()
|
|
.expect("retention response should be present");
|
|
assert!(no_default_retention.mode().is_none());
|
|
assert!(no_default_retention.retain_until_date().is_none());
|
|
|
|
let retained_version_id = put_object_with_retention(
|
|
&client,
|
|
dst_bucket,
|
|
"locked-destination",
|
|
b"locked-target",
|
|
ObjectLockRetentionMode::Compliance,
|
|
future_retain_until(30),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let overwrite_output = client
|
|
.copy_object()
|
|
.copy_source(format!("{src_bucket}/{src_key}"))
|
|
.bucket(dst_bucket)
|
|
.key("locked-destination")
|
|
.send()
|
|
.await
|
|
.expect("CopyObject overwrite should succeed by creating a new destination version");
|
|
let overwrite_version_id = overwrite_output
|
|
.version_id()
|
|
.expect("versioned copy should return a version id")
|
|
.to_string();
|
|
assert_ne!(
|
|
overwrite_version_id, retained_version_id,
|
|
"copy must create a distinct destination version"
|
|
);
|
|
|
|
// 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"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_multipart_default_retention_fixed_at_create() {
|
|
init_logging();
|
|
info!("🧪 Test: multipart default retention is fixed at CreateMultipartUpload");
|
|
|
|
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
|
env.start_rustfs().await.unwrap();
|
|
|
|
let bucket = "test-multipart-default-drift";
|
|
let key = "multipart-object";
|
|
|
|
env.create_object_lock_bucket(bucket).await.unwrap();
|
|
|
|
let client = env.s3_client();
|
|
put_object_lock_configuration(&client, bucket, ObjectLockRetentionMode::Governance, Some(1), None)
|
|
.await
|
|
.unwrap();
|
|
|
|
let create_started = Utc::now();
|
|
let create_output = client.create_multipart_upload().bucket(bucket).key(key).send().await.unwrap();
|
|
let upload_id = create_output.upload_id().unwrap();
|
|
|
|
let upload_part_output = client
|
|
.upload_part()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.upload_id(upload_id)
|
|
.part_number(1)
|
|
.body(ByteStream::from(b"multipart-body".to_vec()))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
put_object_lock_configuration(&client, bucket, ObjectLockRetentionMode::Governance, Some(10), None)
|
|
.await
|
|
.unwrap();
|
|
|
|
let completed_upload = CompletedMultipartUpload::builder()
|
|
.parts(
|
|
CompletedPart::builder()
|
|
.part_number(1)
|
|
.e_tag(upload_part_output.e_tag().unwrap_or_default())
|
|
.build(),
|
|
)
|
|
.build();
|
|
|
|
client
|
|
.complete_multipart_upload()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.upload_id(upload_id)
|
|
.multipart_upload(completed_upload)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let retention = client.get_object_retention().bucket(bucket).key(key).send().await.unwrap();
|
|
let retention = retention.retention().expect("multipart default retention should be present");
|
|
assert_eq!(retention.mode().map(|value| value.as_str()), Some("GOVERNANCE"));
|
|
let retain_until = parse_s3_datetime(retention.retain_until_date().expect("retain-until date should be present"));
|
|
assert!(
|
|
retain_until < create_started + Duration::days(3),
|
|
"CompleteMultipartUpload should keep the default retention calculated at create time"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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");
|
|
}
|