// 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. //! Integration tests for the AMQP notification target. //! //! These tests are ignored because they require a running RabbitMQ-compatible //! AMQP 0-9-1 broker. To run locally: //! //! ```bash //! docker run -d --name rustfs-rabbitmq -p 5672:5672 rabbitmq:3 //! cargo test -p rustfs-targets --test amqp_integration -- --ignored //! ``` //! //! Override the broker URL with `RUSTFS_TEST_AMQP_URL`. use lapin::{ BasicProperties, Connection, ConnectionProperties, options::{BasicAckOptions, BasicGetOptions, QueueBindOptions, QueueDeclareOptions, QueueDeleteOptions}, types::FieldTable, }; use rustfs_s3_types::EventName; use rustfs_targets::Target; use rustfs_targets::check_amqp_broker_available; use rustfs_targets::target::EntityTarget; use rustfs_targets::target::TargetType; use rustfs_targets::target::amqp::{AMQPArgs, AMQPTarget}; use serde_json::Value; use std::sync::Arc; use uuid::Uuid; fn broker_url() -> String { std::env::var("RUSTFS_TEST_AMQP_URL").unwrap_or_else(|_| "amqp://guest:guest@127.0.0.1:5672/%2f".to_string()) } fn test_args(routing_key: &str) -> AMQPArgs { AMQPArgs { enable: true, url: broker_url().parse().expect("valid AMQP URL"), exchange: "amq.topic".to_string(), routing_key: routing_key.to_string(), mandatory: true, persistent: true, username: String::new(), password: String::new(), tls_ca: String::new(), tls_client_cert: String::new(), tls_client_key: String::new(), queue_dir: String::new(), queue_limit: 100_000, target_type: TargetType::NotifyEvent, } } fn entity_for(bucket: &str, object: &str) -> Arc> { Arc::new(EntityTarget { bucket_name: bucket.to_string(), object_name: object.to_string(), event_name: EventName::ObjectCreatedPut, data: serde_json::json!({"bucket": bucket, "object": object}), }) } async fn bind_queue(queue: &str, routing_key: &str) -> lapin::Channel { let conn = Connection::connect(&broker_url(), ConnectionProperties::default()) .await .expect("connect to AMQP broker"); let channel = conn.create_channel().await.expect("create channel"); channel .queue_declare( queue.into(), QueueDeclareOptions { durable: false, exclusive: true, auto_delete: true, ..QueueDeclareOptions::default() }, FieldTable::default(), ) .await .expect("declare queue"); channel .queue_bind( queue.into(), "amq.topic".into(), routing_key.into(), QueueBindOptions::default(), FieldTable::default(), ) .await .expect("bind queue"); channel } async fn read_one(channel: &lapin::Channel, queue: &str) -> (Value, BasicProperties) { let msg = tokio::time::timeout(std::time::Duration::from_secs(5), async { loop { if let Some(msg) = channel .basic_get(queue.into(), BasicGetOptions::default()) .await .expect("basic_get") { msg.ack(BasicAckOptions::default()).await.expect("ack message"); break msg; } tokio::time::sleep(std::time::Duration::from_millis(100)).await; } }) .await .expect("message should arrive"); let properties = msg.properties.clone(); let payload = serde_json::from_slice(&msg.data).expect("message payload should be JSON"); (payload, properties) } #[tokio::test] #[ignore = "requires running RabbitMQ-compatible AMQP broker"] async fn test_check_amqp_broker_available() { check_amqp_broker_available(&test_args("rustfs.check")) .await .expect("broker check should succeed"); } #[tokio::test] #[ignore = "requires running RabbitMQ-compatible AMQP broker"] async fn test_direct_publish_delivers_json_payload() { let routing_key = format!("rustfs.test.{}", Uuid::new_v4().simple()); let queue = format!("rustfs-test-{}", Uuid::new_v4().simple()); let channel = bind_queue(&queue, &routing_key).await; let target = AMQPTarget::new("direct".to_string(), test_args(&routing_key)).expect("construct AMQP target"); target .save(entity_for("bucket1", "object-A")) .await .expect("publish should succeed"); let (payload, properties) = read_one(&channel, &queue).await; assert_eq!(payload["Key"], "bucket1/object-A"); assert_eq!(payload["Records"][0]["data"]["bucket"], "bucket1"); assert_eq!(properties.content_type().as_ref().map(|s| s.as_str()), Some("application/json")); assert_eq!(*properties.delivery_mode(), Some(2)); channel .queue_delete(queue.into(), QueueDeleteOptions::default()) .await .expect("delete queue"); } #[tokio::test] #[ignore = "requires running RabbitMQ-compatible AMQP broker"] async fn test_publish_reconnects_after_close() { let routing_key = format!("rustfs.reconnect.{}", Uuid::new_v4().simple()); let queue = format!("rustfs-test-{}", Uuid::new_v4().simple()); let channel = bind_queue(&queue, &routing_key).await; let target = AMQPTarget::new("reconnect".to_string(), test_args(&routing_key)).expect("construct AMQP target"); target .save(entity_for("bucket1", "object-before-close")) .await .expect("initial publish should succeed"); let (payload, _) = read_one(&channel, &queue).await; assert_eq!(payload["Key"], "bucket1/object-before-close"); target.close().await.expect("close cached AMQP connection"); target .save(entity_for("bucket1", "object-after-close")) .await .expect("publish should reconnect after close"); let (payload, _) = read_one(&channel, &queue).await; assert_eq!(payload["Key"], "bucket1/object-after-close"); channel .queue_delete(queue.into(), QueueDeleteOptions::default()) .await .expect("delete queue"); } #[tokio::test] #[ignore = "requires running RabbitMQ-compatible AMQP broker"] async fn test_queue_replay_delivers_and_removes_stored_payload() { let routing_key = format!("rustfs.replay.{}", Uuid::new_v4().simple()); let queue = format!("rustfs-test-{}", Uuid::new_v4().simple()); let channel = bind_queue(&queue, &routing_key).await; let queue_dir = std::env::temp_dir().join(format!("rustfs-amqp-integration-{}", Uuid::new_v4())); let mut args = test_args(&routing_key); args.queue_dir = queue_dir.to_string_lossy().to_string(); let target = AMQPTarget::new("queued".to_string(), args.clone()).expect("construct AMQP target"); target .save(entity_for("bucket1", "object-B")) .await .expect("store-backed save should queue"); assert_eq!(target.delivery_snapshot().queue_length, 1); let key = target.store().expect("store configured").list()[0].clone(); target.send_from_store(key).await.expect("replay should publish and delete"); let (payload, properties) = read_one(&channel, &queue).await; assert_eq!(payload["Key"], "bucket1/object-B"); assert_eq!(properties.content_type().as_ref().map(|s| s.as_str()), Some("application/json")); assert_eq!(*properties.delivery_mode(), Some(2)); assert_eq!(target.delivery_snapshot().queue_length, 0); channel .queue_delete(queue.into(), QueueDeleteOptions::default()) .await .expect("delete queue"); let _ = std::fs::remove_dir_all(args.queue_dir); }