//! Live destructive versioned-bucket deletion coverage against docker-compose MinIO. //! //! This exercises the S3-compatible path that community deletion relies on when //! a bucket has versioning enabled: list object versions/delete markers with //! dual markers, delete exact `(Key, VersionId)` identifiers, retry an already //! deleted version, and prove final `ListObjectVersions` emptiness. //! //! Run it against the docker-compose MinIO (creds `buzz_dev`/`buzz_dev_secret`): //! //! ```bash //! docker compose up -d minio minio-init //! cargo test -p buzz-media --test versioned_minio -- --ignored --nocapture //! ``` //! //! The test creates and removes its own bucket. The MinIO container name is //! overridable with `BUZZ_MINIO_CONTAINER`; credentials/endpoint/region/addressing //! use the same `BUZZ_S3_*` env vars as `static_creds_minio`. use std::process::Command; use buzz_media::config::MediaConfig; use buzz_media::storage::{MediaStorage, ObjectVersionKind, ObjectVersionRef}; fn env_or(name: &str, default: &str) -> String { std::env::var(name).unwrap_or_else(|_| default.to_string()) } fn minio_config(bucket: String) -> MediaConfig { MediaConfig { s3_endpoint: env_or("BUZZ_S3_ENDPOINT", "http://localhost:9000"), s3_access_key: env_or("BUZZ_S3_ACCESS_KEY", "buzz_dev"), s3_secret_key: env_or("BUZZ_S3_SECRET_KEY", "buzz_dev_secret"), s3_bucket: bucket, s3_region: env_or("BUZZ_S3_REGION", "us-east-1"), s3_addressing_style: env_or("BUZZ_S3_ADDRESSING_STYLE", "path") .parse() .expect("BUZZ_S3_ADDRESSING_STYLE must be path or virtual"), max_image_bytes: 50 * 1024 * 1024, max_gif_bytes: 10 * 1024 * 1024, max_video_bytes: 524_288_000, max_file_bytes: 104_857_600, public_base_url: "http://localhost:3000/media".to_string(), upload_records_enabled: false, upload_ip_header: None, upload_port_header: None, } } fn run_mc(args: &[String]) -> Result<(), String> { let container = env_or("BUZZ_MINIO_CONTAINER", "buzz-minio"); let output = Command::new("docker") .arg("exec") .arg(container) .arg("mc") .args(args) .output() .map_err(|err| format!("failed to execute docker/mc: {err}"))?; if output.status.success() { Ok(()) } else { Err(format!( "mc {:?} failed with status {}\nstdout:\n{}\nstderr:\n{}", args, output.status, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) )) } } fn mc_alias(access_key: &str, secret_key: &str) -> Result<(), String> { run_mc(&[ "alias".to_string(), "set".to_string(), "local".to_string(), "http://localhost:9000".to_string(), access_key.to_string(), secret_key.to_string(), ]) } async fn list_all_versions( storage: &MediaStorage, prefix: &str, max_keys: usize, ) -> Vec { let mut entries = Vec::new(); let mut key_marker = None; let mut version_id_marker = None; loop { let page = storage .list_prefix_versions_page( prefix, key_marker.take(), version_id_marker.take(), max_keys, ) .await .expect("list object versions page"); if page.is_truncated { assert!( page.next_key_marker.is_some(), "truncated ListObjectVersions page must include NextKeyMarker" ); assert!( page.next_version_id_marker.is_some(), "truncated ListObjectVersions page must include NextVersionIdMarker" ); } entries.extend(page.entries); if !page.is_truncated { break; } key_marker = page.next_key_marker; version_id_marker = page.next_version_id_marker; } entries } fn refs_from(entries: &[buzz_media::storage::ObjectVersionEntry]) -> Vec { entries .iter() .map(|entry| ObjectVersionRef { key: entry.key.clone(), version_id: entry.version_id.clone(), }) .collect() } #[tokio::test] #[ignore = "requires live docker-compose MinIO; permanently deletes exact test object versions"] async fn never_versioned_bucket_lists_null_versions_and_exact_delete_empties_listing() { let bucket = format!("buzz-media-never-versioned-{}", std::process::id()); let bucket_path = format!("local/{bucket}"); let config = minio_config(bucket.clone()); mc_alias(&config.s3_access_key, &config.s3_secret_key).expect("configure mc alias"); run_mc(&[ "mb".to_string(), "--ignore-existing".to_string(), bucket_path.clone(), ]) .expect("create isolated never-versioned test bucket"); let storage = MediaStorage::new(&config).expect("static MinIO storage client"); let prefix = format!("_test/never-versioned-{}/", uuid::Uuid::new_v4()); let key = format!("{prefix}plain.bin"); storage .put(&key, b"plain", "application/octet-stream") .await .expect("put never-versioned object"); let listed = list_all_versions(&storage, &prefix, 2).await; assert_eq!( listed, vec![buzz_media::storage::ObjectVersionEntry { key: key.clone(), version_id: "null".to_string(), kind: ObjectVersionKind::Object, size: 5, }], "never-versioned buckets must still enumerate exact null-version objects" ); let delete = storage .delete_object_versions(&refs_from(&listed)) .await .expect("delete exact null-version object"); assert!(delete.failed.is_empty(), "{delete:?}"); assert!(delete.versioned_keys.is_empty(), "{delete:?}"); assert_eq!( delete.deleted + delete.already_missing, 1, "exact null-version delete must account for the listed object" ); assert!( list_all_versions(&storage, &prefix, 2).await.is_empty(), "final ListObjectVersions must be empty after deleting the exact null version" ); run_mc(&["rb".to_string(), "--force".to_string(), bucket_path.clone()]) .expect("remove isolated never-versioned test bucket"); } #[tokio::test] #[ignore = "requires live docker-compose MinIO; permanently deletes exact test object versions"] async fn versioned_bucket_exact_version_delete_reaches_final_list_versions_emptiness() { let bucket = format!("buzz-media-versioned-{}", std::process::id()); let bucket_path = format!("local/{bucket}"); let config = minio_config(bucket.clone()); mc_alias(&config.s3_access_key, &config.s3_secret_key).expect("configure mc alias"); run_mc(&[ "mb".to_string(), "--ignore-existing".to_string(), bucket_path.clone(), ]) .expect("create isolated versioned test bucket"); run_mc(&[ "version".to_string(), "enable".to_string(), bucket_path.clone(), ]) .expect("enable bucket versioning"); let storage = MediaStorage::new(&config).expect("static MinIO storage client"); let prefix = format!("_test/versioned-{}/", uuid::Uuid::new_v4()); let historical_key = format!("{prefix}historical.bin"); let marker_only_key = format!("{prefix}marker-only.bin"); let paginated_key = format!("{prefix}paginated.bin"); storage .put(&historical_key, b"v1", "application/octet-stream") .await .expect("put historical v1"); storage .put(&historical_key, b"v2", "application/octet-stream") .await .expect("put historical v2"); storage .delete(&historical_key) .await .expect("delete historical current version creates delete marker"); storage .put(&marker_only_key, b"marker-base", "application/octet-stream") .await .expect("put marker-only base version"); storage .delete(&marker_only_key) .await .expect("delete current version creates marker-only delete marker"); let marker_versions = list_all_versions(&storage, &marker_only_key, 2).await; let marker_objects: Vec = marker_versions .iter() .filter(|entry| entry.kind == ObjectVersionKind::Object) .map(|entry| ObjectVersionRef { key: entry.key.clone(), version_id: entry.version_id.clone(), }) .collect(); assert_eq!( marker_objects.len(), 1, "marker-only setup should have one object version: {marker_versions:?}" ); let marker_object_delete = storage .delete_object_versions(&marker_objects) .await .expect("delete marker-only base object version"); assert!( marker_object_delete.failed.is_empty(), "{marker_object_delete:?}" ); assert!( marker_object_delete.versioned_keys.is_empty(), "{marker_object_delete:?}" ); storage .put(&paginated_key, b"page-a", "application/octet-stream") .await .expect("put paginated v1"); storage .put(&paginated_key, b"page-b", "application/octet-stream") .await .expect("put paginated v2"); let listed = list_all_versions(&storage, &prefix, 2).await; assert!( listed.len() >= 6, "expected multiple versions/delete markers across small pages, got {listed:?}" ); assert!(listed.iter().any(|entry| { entry.key == historical_key && entry.kind == ObjectVersionKind::Object && entry.size == 2 })); assert!(listed.iter().any(|entry| { entry.key == historical_key && entry.kind == ObjectVersionKind::DeleteMarker })); assert!(listed.iter().any(|entry| { entry.key == marker_only_key && entry.kind == ObjectVersionKind::DeleteMarker })); let refs = refs_from(&listed); let first_chunk = &refs[..2.min(refs.len())]; let first_delete = storage .delete_object_versions(first_chunk) .await .expect("delete first explicit version chunk"); assert!(first_delete.failed.is_empty(), "{first_delete:?}"); assert!(first_delete.versioned_keys.is_empty(), "{first_delete:?}"); assert_eq!( first_delete.deleted + first_delete.already_missing, first_chunk.len() as u64, "explicit version delete should account for every requested identifier" ); let retry = storage .delete_object_versions(&first_chunk[..1]) .await .expect("retry already-deleted explicit version"); assert!(retry.failed.is_empty(), "{retry:?}"); assert!(retry.versioned_keys.is_empty(), "{retry:?}"); assert_eq!( retry.deleted + retry.already_missing, 1, "retry should be idempotently accounted as deleted/already missing" ); let rest_delete = storage .delete_object_versions(&refs[first_chunk.len()..]) .await .expect("delete remaining explicit versions"); assert!(rest_delete.failed.is_empty(), "{rest_delete:?}"); assert!(rest_delete.versioned_keys.is_empty(), "{rest_delete:?}"); assert_eq!( rest_delete.deleted + rest_delete.already_missing, (refs.len() - first_chunk.len()) as u64, "remaining explicit version delete should account for every requested identifier" ); let remaining = list_all_versions(&storage, &prefix, 2).await; assert!( remaining.is_empty(), "final ListObjectVersions must be empty after exact-version deletion: {remaining:?}" ); if run_mc(&[ "version".to_string(), "suspend".to_string(), bucket_path.clone(), ]) .is_ok() { let suspended_key = format!("{prefix}suspended.bin"); storage .put(&suspended_key, b"suspended", "application/octet-stream") .await .expect("put suspended-versioning object"); storage .delete(&suspended_key) .await .expect("delete suspended-versioning object"); let suspended_entries = list_all_versions(&storage, &prefix, 2).await; assert!( suspended_entries .iter() .any(|entry| entry.key == suspended_key), "suspended-versioning write/delete should be visible to ListObjectVersions" ); let suspended_delete = storage .delete_object_versions(&refs_from(&suspended_entries)) .await .expect("delete suspended-versioning entries by explicit version id"); assert!(suspended_delete.failed.is_empty(), "{suspended_delete:?}"); assert!( suspended_delete.versioned_keys.is_empty(), "{suspended_delete:?}" ); assert!(list_all_versions(&storage, &prefix, 2).await.is_empty()); } else { eprintln!("MinIO mc did not support version suspend; enabled-versioning coverage passed"); } run_mc(&["rb".to_string(), "--force".to_string(), bucket_path.clone()]) .expect("remove isolated versioned test bucket"); }