mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-26 08:18:18 +00:00
fix(notify): Fix XML Filter parsing and add comprehensive tests (#2191)
This commit is contained in:
Generated
+1
@@ -6596,6 +6596,7 @@ version = "0.39.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"memchr",
|
||||
"serde",
|
||||
"tokio",
|
||||
|
||||
@@ -37,7 +37,6 @@ chrono = { workspace = true, features = ["serde"] }
|
||||
futures = { workspace = true }
|
||||
form_urlencoded = { workspace = true }
|
||||
hashbrown = { workspace = true }
|
||||
quick-xml = { workspace = true, features = ["serialize", "async-tokio"] }
|
||||
rayon = { workspace = true }
|
||||
rumqttc = { workspace = true }
|
||||
rustc-hash = { workspace = true }
|
||||
@@ -49,6 +48,13 @@ tracing = { workspace = true }
|
||||
url = { workspace = true }
|
||||
wildmatch = { workspace = true, features = ["serde"] }
|
||||
|
||||
# quick-xml dependencies for custom S3KeyFilter XML deserialization
|
||||
# Custom deserializer implemented for S3KeyFilter to handle both XML structures:
|
||||
# - Direct FilterRule elements under S3Key (AWS S3 format)
|
||||
# - FilterRuleList wrapper format
|
||||
# This makes quick-xml 0.39.2 work correctly with Filter elements
|
||||
quick-xml = { workspace = true, features = ["serialize", "serde-types", "encoding"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
|
||||
@@ -111,15 +111,11 @@ pub mod notifier_global {
|
||||
suffix: &str,
|
||||
target_ids: &[TargetID],
|
||||
) -> Result<(), NotificationError> {
|
||||
// Construct pattern, simple splicing of prefixes and suffixes
|
||||
let mut pattern = String::new();
|
||||
if !prefix.is_empty() {
|
||||
pattern.push_str(prefix);
|
||||
}
|
||||
pattern.push('*');
|
||||
if !suffix.is_empty() {
|
||||
pattern.push_str(suffix);
|
||||
}
|
||||
// Construct pattern using proper pattern function
|
||||
let pattern = crate::rules::pattern::new_pattern(
|
||||
if prefix.is_empty() { None } else { Some(prefix) },
|
||||
if suffix.is_empty() { None } else { Some(suffix) },
|
||||
);
|
||||
|
||||
// Create BucketNotificationConfig
|
||||
let mut bucket_config = BucketNotificationConfig::new(region);
|
||||
|
||||
@@ -117,7 +117,7 @@ impl BucketNotificationConfig {
|
||||
// The ARN in queue_conf should now have its region set if it was originally empty.
|
||||
// Ensure TargetID can be cloned or extracted correctly.
|
||||
let target_id = queue_conf.arn.target_id.clone();
|
||||
let pattern_str = queue_conf.filter.filter_rule_list.pattern();
|
||||
let pattern_str = queue_conf.filter.pattern();
|
||||
rules_map.add_rule_config(&queue_conf.events, pattern_str, target_id);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
// 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 BucketNotificationConfig
|
||||
//!
|
||||
//! This module contains tests that simulate the exact flow from XML configuration
|
||||
//! to event matching, including filter rules with prefix and suffix.
|
||||
|
||||
use super::*;
|
||||
use rustfs_s3_common::EventName;
|
||||
use rustfs_targets::arn::{ARN, TargetID};
|
||||
use std::io::Cursor;
|
||||
|
||||
#[cfg(test)]
|
||||
mod integration_tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_arn() -> ARN {
|
||||
ARN {
|
||||
partition: "rustfs".to_string(),
|
||||
service: "sqs".to_string(),
|
||||
region: "".to_string(),
|
||||
target_id: TargetID::new("primary".to_string(), "webhook".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_arn() {
|
||||
let arn = create_test_arn();
|
||||
|
||||
assert_eq!(arn.partition, "rustfs");
|
||||
assert_eq!(arn.service, "sqs");
|
||||
assert_eq!(arn.region, "");
|
||||
assert_eq!(arn.target_id, TargetID::new("primary".to_string(), "webhook".to_string()));
|
||||
}
|
||||
|
||||
/// Test exact bug scenario from the bug report
|
||||
#[test]
|
||||
fn test_bug_report_exact_scenario_xml() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<NotificationConfiguration>
|
||||
<QueueConfiguration>
|
||||
<Id>test-queue</Id>
|
||||
<Queue>arn:rustfs:sqs:ap-northeast-1:primary:webhook</Queue>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRule>
|
||||
<Name>prefix</Name>
|
||||
<Value>uploads/</Value>
|
||||
</FilterRule>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>.csv</Value>
|
||||
</FilterRule>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>"#;
|
||||
|
||||
let current_region = "ap-northeast-1";
|
||||
let arn_list = vec!["arn:rustfs:sqs:ap-northeast-1:primary:webhook".to_string()];
|
||||
|
||||
let config = BucketNotificationConfig::from_xml(Cursor::new(xml.as_bytes()), current_region, &arn_list).unwrap();
|
||||
|
||||
// Verify pattern is "uploads/*.csv"
|
||||
let rules_map = config.get_rules_map();
|
||||
|
||||
// Debug: Print the patterns stored
|
||||
println!("\nPatterns stored in rules_map:");
|
||||
for (event_name, pattern_rules) in rules_map.inner().iter() {
|
||||
for pattern in pattern_rules.inner().keys() {
|
||||
println!(" Event: {:?}, Pattern: '{}'", event_name, pattern);
|
||||
}
|
||||
}
|
||||
|
||||
// The event should be registered for ObjectCreatedAll and all its expansions
|
||||
assert!(rules_map.has_subscriber(&EventName::ObjectCreatedAll));
|
||||
assert!(rules_map.has_subscriber(&EventName::ObjectCreatedPut));
|
||||
|
||||
// Test matching "uploads/test.csv" with ObjectCreatedPut
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/test.csv");
|
||||
|
||||
println!("Matching 'uploads/test.csv' with ObjectCreatedPut: {} targets", targets.len());
|
||||
for target in &targets {
|
||||
println!(" Target: {:?}", target);
|
||||
}
|
||||
|
||||
// Should find the target
|
||||
assert!(!targets.is_empty(), "Should find at least one target");
|
||||
|
||||
// Test matching "uploads/subdir/file.csv" - should also match
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/subdir/file.csv");
|
||||
assert!(!targets.is_empty(), "Nested CSV files should match");
|
||||
|
||||
// Test matching "uploads/test.txt" - should NOT match
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/test.txt");
|
||||
assert!(targets.is_empty(), "TXT files should not match");
|
||||
|
||||
// Test matching "files/test.csv" - should NOT match (wrong prefix)
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "files/test.csv");
|
||||
assert!(targets.is_empty(), "Files in wrong prefix should not match");
|
||||
}
|
||||
|
||||
/// Test prefix only filter
|
||||
#[test]
|
||||
fn test_prefix_only_filter_xml() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<NotificationConfiguration>
|
||||
<QueueConfiguration>
|
||||
<Id>test-queue</Id>
|
||||
<Queue>arn:rustfs:sqs:ap-northeast-1:primary:webhook</Queue>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRuleList>
|
||||
<FilterRule>
|
||||
<Name>prefix</Name>
|
||||
<Value>images/</Value>
|
||||
</FilterRule>
|
||||
</FilterRuleList>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>"#;
|
||||
|
||||
let current_region = "ap-northeast-1";
|
||||
let arn_list = vec!["arn:rustfs:sqs:ap-northeast-1:primary:webhook".to_string()];
|
||||
|
||||
let config = BucketNotificationConfig::from_xml(Cursor::new(xml.as_bytes()), current_region, &arn_list).unwrap();
|
||||
|
||||
let rules_map = config.get_rules_map();
|
||||
|
||||
// Debug: Print the patterns stored
|
||||
println!("\nPatterns stored in rules_map:");
|
||||
for (event_name, pattern_rules) in rules_map.inner().iter() {
|
||||
for pattern in pattern_rules.inner().keys() {
|
||||
println!(" Event: {:?}, Pattern: '{}'", event_name, pattern);
|
||||
}
|
||||
}
|
||||
|
||||
// Test matching "images/photo.jpg"
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "images/photo.jpg");
|
||||
println!("Matching 'images/photo.jpg': {} targets", targets.len());
|
||||
assert!(!targets.is_empty(), "Files in images/ should match");
|
||||
|
||||
// Test matching "uploads/photo.jpg" - should NOT match
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/photo.jpg");
|
||||
println!("Matching 'uploads/photo.jpg': {} targets", targets.len());
|
||||
assert!(targets.is_empty(), "Files not in images/ should not match");
|
||||
}
|
||||
|
||||
/// Test suffix only filter
|
||||
#[test]
|
||||
fn test_suffix_only_filter_xml() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<NotificationConfiguration>
|
||||
<QueueConfiguration>
|
||||
<Id>test-queue</Id>
|
||||
<Queue>arn:rustfs:sqs:ap-northeast-1:primary:webhook</Queue>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRuleList>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>.pdf</Value>
|
||||
</FilterRule>
|
||||
</FilterRuleList>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>"#;
|
||||
|
||||
let current_region = "ap-northeast-1";
|
||||
let arn_list = vec!["arn:rustfs:sqs:ap-northeast-1:primary:webhook".to_string()];
|
||||
|
||||
let config = BucketNotificationConfig::from_xml(Cursor::new(xml.as_bytes()), current_region, &arn_list).unwrap();
|
||||
|
||||
let rules_map = config.get_rules_map();
|
||||
|
||||
// Test matching "document.pdf" - should match
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "document.pdf");
|
||||
assert!(!targets.is_empty(), "PDF files should match");
|
||||
|
||||
// Test matching "document.txt" - should NOT match
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "document.txt");
|
||||
assert!(targets.is_empty(), "Non-PDF files should not match");
|
||||
}
|
||||
|
||||
/// Test no filter (match all)
|
||||
#[test]
|
||||
fn test_no_filter_xml() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<NotificationConfiguration>
|
||||
<QueueConfiguration>
|
||||
<Id>test-queue</Id>
|
||||
<Queue>arn:rustfs:sqs:ap-northeast-1:primary:webhook</Queue>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>"#;
|
||||
|
||||
let current_region = "ap-northeast-1";
|
||||
let arn_list = vec!["arn:rustfs:sqs:ap-northeast-1:primary:webhook".to_string()];
|
||||
|
||||
let config = BucketNotificationConfig::from_xml(Cursor::new(xml.as_bytes()), current_region, &arn_list).unwrap();
|
||||
|
||||
let rules_map = config.get_rules_map();
|
||||
|
||||
// All files should match
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "anything.csv");
|
||||
assert!(!targets.is_empty(), "All files should match");
|
||||
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/test.txt");
|
||||
assert!(!targets.is_empty(), "All files should match");
|
||||
}
|
||||
|
||||
/// Test specific event type instead of compound
|
||||
#[test]
|
||||
fn test_specific_event_type_xml() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<NotificationConfiguration>
|
||||
<QueueConfiguration>
|
||||
<Id>test-queue</Id>
|
||||
<Queue>arn:rustfs:sqs:ap-northeast-1:primary:webhook</Queue>
|
||||
<Event>s3:ObjectCreated:Put</Event>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRule>
|
||||
<Name>prefix</Name>
|
||||
<Value>uploads/</Value>
|
||||
</FilterRule>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>.csv</Value>
|
||||
</FilterRule>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>"#;
|
||||
|
||||
let current_region = "ap-northeast-1";
|
||||
let arn_list = vec!["arn:rustfs:sqs:ap-northeast-1:primary:webhook".to_string()];
|
||||
|
||||
let config = BucketNotificationConfig::from_xml(Cursor::new(xml.as_bytes()), current_region, &arn_list).unwrap();
|
||||
|
||||
let rules_map = config.get_rules_map();
|
||||
|
||||
// Debug: Print all events that have subscribers
|
||||
println!("\nAll events with subscribers:");
|
||||
for (event_name, _pattern_rules) in rules_map.inner().iter() {
|
||||
println!(" Event: {:?}", event_name);
|
||||
}
|
||||
|
||||
// Note: has_subscriber uses bitmask logic, so has_subscriber(&ObjectCreatedAll) returns true
|
||||
// if any ObjectCreated* event is registered. This is intentional for efficiency.
|
||||
// Only ObjectCreatedPut was explicitly registered in the XML.
|
||||
|
||||
// Should have subscriber for ObjectCreatedPut
|
||||
assert!(rules_map.has_subscriber(&EventName::ObjectCreatedPut));
|
||||
|
||||
// Should NOT have subscriber for ObjectCreatedPost (only Put was configured)
|
||||
assert!(!rules_map.has_subscriber(&EventName::ObjectCreatedPost));
|
||||
|
||||
// Test matching with Put event
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/test.csv");
|
||||
assert!(!targets.is_empty(), "Put event should match");
|
||||
}
|
||||
|
||||
/// Test URL-encoded object keys
|
||||
#[test]
|
||||
fn test_url_encoded_keys() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<NotificationConfiguration>
|
||||
<QueueConfiguration>
|
||||
<Id>test-queue</Id>
|
||||
<Queue>arn:rustfs:sqs:ap-northeast-1:primary:webhook</Queue>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRule>
|
||||
<Name>prefix</Name>
|
||||
<Value>uploads/</Value>
|
||||
</FilterRule>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>.csv</Value>
|
||||
</FilterRule>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>"#;
|
||||
|
||||
let current_region = "ap-northeast-1";
|
||||
let arn_list = vec!["arn:rustfs:sqs:ap-northeast-1:primary:webhook".to_string()];
|
||||
|
||||
let config = BucketNotificationConfig::from_xml(Cursor::new(xml.as_bytes()), current_region, &arn_list).unwrap();
|
||||
|
||||
let rules_map = config.get_rules_map();
|
||||
|
||||
// Test with URL-encoded space: "uploads/my%20file.csv"
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/my%20file.csv");
|
||||
// This may or may not match depending on encoding
|
||||
println!("URL-encoded key 'uploads/my%20file.csv' matched: {}", !targets.is_empty());
|
||||
|
||||
// Test with unencoded version
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/my file.csv");
|
||||
assert!(!targets.is_empty(), "Unencoded key should match");
|
||||
}
|
||||
|
||||
/// Test multiple queue configurations
|
||||
#[test]
|
||||
fn test_multiple_queue_configs_xml() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<NotificationConfiguration>
|
||||
<QueueConfiguration>
|
||||
<Id>csv-queue</Id>
|
||||
<Queue>arn:rustfs:sqs::primary:webhook-csv</Queue>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRule>
|
||||
<Name>prefix</Name>
|
||||
<Value>uploads/</Value>
|
||||
</FilterRule>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>.csv</Value>
|
||||
</FilterRule>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
</QueueConfiguration>
|
||||
<QueueConfiguration>
|
||||
<Id>jpg-queue</Id>
|
||||
<Queue>arn:rustfs:sqs::primary:webhook-jpg</Queue>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRuleList>
|
||||
<FilterRule>
|
||||
<Name>prefix</Name>
|
||||
<Value>images/</Value>
|
||||
</FilterRule>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>.jpg</Value>
|
||||
</FilterRule>
|
||||
</FilterRuleList>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>"#;
|
||||
|
||||
let current_region = "ap-northeast-1";
|
||||
let arn_list = vec![
|
||||
"arn:rustfs:sqs:ap-northeast-1:primary:webhook-csv".to_string(),
|
||||
"arn:rustfs:sqs:ap-northeast-1:primary:webhook-jpg".to_string(),
|
||||
];
|
||||
|
||||
let config = BucketNotificationConfig::from_xml(Cursor::new(xml.as_bytes()), current_region, &arn_list).unwrap();
|
||||
|
||||
let rules_map = config.get_rules_map();
|
||||
|
||||
// Test CSV files
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/test.csv");
|
||||
println!("Targets for 'uploads/test.csv': {:?}", targets);
|
||||
assert!(!targets.is_empty(), "CSV files should match");
|
||||
// Should have csv target (ARN: arn:rustfs:sqs::primary:webhook-csv)
|
||||
let csv_target = TargetID::new("primary".to_string(), "webhook-csv".to_string());
|
||||
assert!(targets.contains(&csv_target), "Should find CSV webhook, got: {:?}", targets);
|
||||
|
||||
// Test JPG files
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "images/photo.jpg");
|
||||
assert!(!targets.is_empty(), "JPG files should match");
|
||||
// Should have jpg target (ARN: arn:rustfs:sqs:primary:webhook-jpg)
|
||||
let jpg_target = TargetID::new("primary".to_string(), "webhook-jpg".to_string());
|
||||
assert!(targets.contains(&jpg_target), "Should find JPG webhook");
|
||||
}
|
||||
|
||||
/// Test compound event type expansion
|
||||
#[test]
|
||||
fn test_compound_event_expansion_integration() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<NotificationConfiguration>
|
||||
<QueueConfiguration>
|
||||
<Id>test-queue</Id>
|
||||
<Queue>arn:rustfs:sqs:ap-northeast-1:primary:webhook</Queue>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRuleList>
|
||||
<FilterRule>
|
||||
<Name>prefix</Name>
|
||||
<Value>data/</Value>
|
||||
</FilterRule>
|
||||
</FilterRuleList>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>"#;
|
||||
|
||||
let current_region = "ap-northeast-1";
|
||||
let arn_list = vec!["arn:rustfs:sqs:ap-northeast-1:primary:webhook".to_string()];
|
||||
|
||||
let config = BucketNotificationConfig::from_xml(Cursor::new(xml.as_bytes()), current_region, &arn_list).unwrap();
|
||||
|
||||
let rules_map = config.get_rules_map();
|
||||
|
||||
// ObjectCreated:* should be expanded to all ObjectCreated events
|
||||
let event_types = [
|
||||
EventName::ObjectCreatedPut,
|
||||
EventName::ObjectCreatedPost,
|
||||
EventName::ObjectCreatedCopy,
|
||||
EventName::ObjectCreatedCompleteMultipartUpload,
|
||||
EventName::ObjectCreatedPutRetention,
|
||||
EventName::ObjectCreatedPutLegalHold,
|
||||
EventName::ObjectCreatedPutTagging,
|
||||
EventName::ObjectCreatedDeleteTagging,
|
||||
];
|
||||
|
||||
for event_type in event_types {
|
||||
assert!(rules_map.has_subscriber(&event_type), "Event {:?} should have subscribers", event_type);
|
||||
|
||||
// All should match "data/file.csv"
|
||||
let targets = rules_map.match_rules(event_type, "data/file.csv");
|
||||
assert!(!targets.is_empty(), "Event {:?} should match", event_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,14 @@
|
||||
// limitations under the License.
|
||||
|
||||
mod config;
|
||||
#[cfg(test)]
|
||||
mod config_test;
|
||||
pub mod pattern;
|
||||
mod pattern_rules;
|
||||
#[cfg(test)]
|
||||
mod pattern_rules_test;
|
||||
#[cfg(test)]
|
||||
mod pattern_test;
|
||||
mod rules_map;
|
||||
mod subscriber_index;
|
||||
mod subscriber_snapshot;
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
// 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.
|
||||
|
||||
//! Tests for PatternRules and RulesMap
|
||||
//!
|
||||
//! This module contains comprehensive tests for the rule matching logic
|
||||
//! used in bucket notifications, specifically testing the flow from
|
||||
//! configuration to event matching.
|
||||
|
||||
use super::*;
|
||||
use rustfs_s3_common::EventName;
|
||||
use rustfs_targets::arn::TargetID;
|
||||
|
||||
#[cfg(test)]
|
||||
mod pattern_rules_tests {
|
||||
use super::*;
|
||||
|
||||
/// Test basic pattern rules matching
|
||||
#[test]
|
||||
fn test_pattern_rules_basic_matching() {
|
||||
let mut rules = PatternRules::new();
|
||||
let target_id = TargetID::new("test-id".to_string(), "webhook".to_string());
|
||||
|
||||
// Add a rule for *.csv files
|
||||
rules.add("*.csv".to_string(), target_id.clone());
|
||||
|
||||
// Test matching
|
||||
assert!(rules.match_simple("test.csv"));
|
||||
assert!(rules.match_simple("uploads/test.csv"));
|
||||
assert!(!rules.match_simple("test.txt"));
|
||||
|
||||
// Test match_targets
|
||||
let targets = rules.match_targets("test.csv");
|
||||
assert!(targets.contains(&target_id));
|
||||
|
||||
let targets = rules.match_targets("test.txt");
|
||||
assert!(!targets.contains(&target_id));
|
||||
}
|
||||
|
||||
/// Test multiple patterns with different targets
|
||||
#[test]
|
||||
fn test_multiple_patterns() {
|
||||
let mut rules = PatternRules::new();
|
||||
let csv_target = TargetID::new("csv-target".to_string(), "webhook".to_string());
|
||||
let jpg_target = TargetID::new("jpg-target".to_string(), "webhook".to_string());
|
||||
|
||||
rules.add("*.csv".to_string(), csv_target.clone());
|
||||
rules.add("*.jpg".to_string(), jpg_target.clone());
|
||||
|
||||
// CSV files match csv_target
|
||||
let targets = rules.match_targets("test.csv");
|
||||
assert!(targets.contains(&csv_target));
|
||||
assert!(!targets.contains(&jpg_target));
|
||||
|
||||
// JPG files match jpg_target
|
||||
let targets = rules.match_targets("test.jpg");
|
||||
assert!(!targets.contains(&csv_target));
|
||||
assert!(targets.contains(&jpg_target));
|
||||
|
||||
// Both patterns match for nested paths
|
||||
let targets = rules.match_targets("uploads/test.csv");
|
||||
assert!(targets.contains(&csv_target));
|
||||
|
||||
let targets = rules.match_targets("images/photo.jpg");
|
||||
assert!(targets.contains(&jpg_target));
|
||||
}
|
||||
|
||||
/// Test prefix patterns
|
||||
#[test]
|
||||
fn test_prefix_patterns() {
|
||||
let mut rules = PatternRules::new();
|
||||
let target_id = TargetID::new("prefix-target".to_string(), "webhook".to_string());
|
||||
|
||||
rules.add("uploads/*".to_string(), target_id.clone());
|
||||
rules.add("images/*".to_string(), target_id.clone());
|
||||
|
||||
assert!(rules.match_simple("uploads/test.csv"));
|
||||
assert!(rules.match_simple("uploads/subdir/test.csv"));
|
||||
assert!(rules.match_simple("images/photo.jpg"));
|
||||
assert!(!rules.match_simple("documents/test.txt"));
|
||||
}
|
||||
|
||||
/// Test prefix + suffix pattern (bug scenario)
|
||||
#[test]
|
||||
fn test_prefix_suffix_pattern_bug_scenario() {
|
||||
let mut rules = PatternRules::new();
|
||||
let target_id = TargetID::new("webhook-target".to_string(), "webhook".to_string());
|
||||
|
||||
// This is the pattern generated from prefix="uploads/", suffix=".csv"
|
||||
rules.add("uploads/*.csv".to_string(), target_id.clone());
|
||||
|
||||
// These should match
|
||||
assert!(rules.match_simple("uploads/test.csv"));
|
||||
assert!(rules.match_simple("uploads/file1.csv"));
|
||||
assert!(rules.match_simple("uploads/nested/path/file.csv"));
|
||||
|
||||
// These should not match
|
||||
assert!(!rules.match_simple("uploads/test.txt"));
|
||||
assert!(!rules.match_simple("files/test.csv"));
|
||||
|
||||
// Verify match_targets returns correct targets
|
||||
let targets = rules.match_targets("uploads/test.csv");
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert!(targets.contains(&target_id));
|
||||
}
|
||||
|
||||
/// Test match_all pattern
|
||||
#[test]
|
||||
fn test_match_all_pattern() {
|
||||
let mut rules = PatternRules::new();
|
||||
let target_id = TargetID::new("all-target".to_string(), "webhook".to_string());
|
||||
|
||||
rules.add("*".to_string(), target_id.clone());
|
||||
|
||||
assert!(rules.match_simple("anything"));
|
||||
assert!(rules.match_simple("uploads/test.csv"));
|
||||
assert!(rules.match_simple(""));
|
||||
|
||||
let targets = rules.match_targets("any_key");
|
||||
assert!(targets.contains(&target_id));
|
||||
}
|
||||
|
||||
/// Test empty rules
|
||||
#[test]
|
||||
fn test_empty_rules() {
|
||||
let rules = PatternRules::new();
|
||||
|
||||
assert!(rules.is_empty());
|
||||
assert!(!rules.match_simple("anything"));
|
||||
|
||||
let targets = rules.match_targets("test.csv");
|
||||
assert!(targets.is_empty());
|
||||
}
|
||||
|
||||
/// Test union operation
|
||||
#[test]
|
||||
fn test_union() {
|
||||
let mut rules1 = PatternRules::new();
|
||||
let mut rules2 = PatternRules::new();
|
||||
|
||||
let target1 = TargetID::new("target1".to_string(), "webhook".to_string());
|
||||
let target2 = TargetID::new("target2".to_string(), "webhook".to_string());
|
||||
|
||||
rules1.add("*.csv".to_string(), target1.clone());
|
||||
rules2.add("*.jpg".to_string(), target2.clone());
|
||||
|
||||
let combined = rules1.union(&rules2);
|
||||
|
||||
assert!(combined.match_simple("test.csv"));
|
||||
assert!(combined.match_simple("test.jpg"));
|
||||
assert!(!combined.match_simple("test.png"));
|
||||
|
||||
let targets = combined.match_targets("test.csv");
|
||||
assert!(targets.contains(&target1));
|
||||
}
|
||||
|
||||
/// Test difference operation
|
||||
#[test]
|
||||
fn test_difference() {
|
||||
let mut rules1 = PatternRules::new();
|
||||
let mut rules2 = PatternRules::new();
|
||||
|
||||
let target1 = TargetID::new("target1".to_string(), "webhook".to_string());
|
||||
let target2 = TargetID::new("target2".to_string(), "webhook".to_string());
|
||||
|
||||
// Add same target to multiple patterns in rules1
|
||||
rules1.add("*.csv".to_string(), target1.clone());
|
||||
rules1.add("*.jpg".to_string(), target1.clone());
|
||||
rules1.add("*.txt".to_string(), target1.clone());
|
||||
|
||||
// Add different target to .jpg pattern in rules2
|
||||
rules2.add("*.jpg".to_string(), target2.clone());
|
||||
|
||||
let diff = rules1.difference(&rules2);
|
||||
|
||||
// After difference:
|
||||
// *.csv: target1 remains (pattern doesn't exist in rules2)
|
||||
// *.jpg: target1 remains (target1 != target2, so it's not removed)
|
||||
// *.txt: target1 remains (pattern doesn't exist in rules2)
|
||||
assert!(diff.match_simple("test.csv"));
|
||||
assert!(diff.match_simple("test.jpg"));
|
||||
assert!(diff.match_simple("test.txt"));
|
||||
}
|
||||
|
||||
/// Test difference operation removing same target
|
||||
#[test]
|
||||
fn test_difference_same_target() {
|
||||
let mut rules1 = PatternRules::new();
|
||||
let mut rules2 = PatternRules::new();
|
||||
|
||||
let target1 = TargetID::new("target1".to_string(), "webhook".to_string());
|
||||
|
||||
// Add same target to multiple patterns in rules1
|
||||
rules1.add("*.csv".to_string(), target1.clone());
|
||||
rules1.add("*.jpg".to_string(), target1.clone());
|
||||
rules1.add("*.txt".to_string(), target1.clone());
|
||||
|
||||
// Add same target to .jpg pattern in rules2
|
||||
rules2.add("*.jpg".to_string(), target1.clone());
|
||||
|
||||
let diff = rules1.difference(&rules2);
|
||||
|
||||
// After difference:
|
||||
// *.csv: target1 remains (pattern doesn't exist in rules2)
|
||||
// *.jpg: target1 is removed (same target in both)
|
||||
// *.txt: target1 remains (pattern doesn't exist in rules2)
|
||||
assert!(diff.match_simple("test.csv"));
|
||||
assert!(!diff.match_simple("test.jpg"));
|
||||
assert!(diff.match_simple("test.txt"));
|
||||
}
|
||||
|
||||
/// Test remove_pattern
|
||||
#[test]
|
||||
fn test_remove_pattern() {
|
||||
let mut rules = PatternRules::new();
|
||||
let target_id = TargetID::new("test-target".to_string(), "webhook".to_string());
|
||||
|
||||
rules.add("*.csv".to_string(), target_id.clone());
|
||||
rules.add("*.jpg".to_string(), target_id.clone());
|
||||
|
||||
assert!(rules.match_simple("test.csv"));
|
||||
assert!(rules.match_simple("test.jpg"));
|
||||
|
||||
// Remove .csv pattern
|
||||
assert!(rules.remove_pattern("*.csv"));
|
||||
|
||||
assert!(!rules.match_simple("test.csv"));
|
||||
assert!(rules.match_simple("test.jpg"));
|
||||
|
||||
// Remove non-existent pattern
|
||||
assert!(!rules.remove_pattern("*.png"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rules_map_tests {
|
||||
use super::*;
|
||||
|
||||
/// Test basic rule addition and matching
|
||||
#[test]
|
||||
fn test_rules_map_basic() {
|
||||
let mut rules_map = RulesMap::new();
|
||||
let target_id = TargetID::new("test-target".to_string(), "webhook".to_string());
|
||||
|
||||
// Add rule for ObjectCreatedPut with pattern *
|
||||
rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*".to_string(), target_id.clone());
|
||||
|
||||
// Check has_subscriber
|
||||
assert!(rules_map.has_subscriber(&EventName::ObjectCreatedPut));
|
||||
assert!(!rules_map.has_subscriber(&EventName::ObjectRemovedDelete));
|
||||
|
||||
// Check match_rules
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "any_key");
|
||||
assert!(targets.contains(&target_id));
|
||||
|
||||
// Check event with different name doesn't match
|
||||
let targets = rules_map.match_rules(EventName::ObjectRemovedDelete, "any_key");
|
||||
assert!(!targets.contains(&target_id));
|
||||
}
|
||||
|
||||
/// Test compound event expansion
|
||||
#[test]
|
||||
fn test_compound_event_expansion() {
|
||||
let mut rules_map = RulesMap::new();
|
||||
let target_id = TargetID::new("test-target".to_string(), "webhook".to_string());
|
||||
|
||||
// Add rule for ObjectCreatedAll (compound event)
|
||||
rules_map.add_rule_config(&[EventName::ObjectCreatedAll], "*.csv".to_string(), target_id.clone());
|
||||
|
||||
// ObjectCreatedAll should be expanded to all ObjectCreated events
|
||||
assert!(rules_map.has_subscriber(&EventName::ObjectCreatedAll));
|
||||
assert!(rules_map.has_subscriber(&EventName::ObjectCreatedPut));
|
||||
assert!(rules_map.has_subscriber(&EventName::ObjectCreatedPost));
|
||||
assert!(rules_map.has_subscriber(&EventName::ObjectCreatedCopy));
|
||||
|
||||
// All should match .csv files
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "test.csv");
|
||||
assert!(targets.contains(&target_id));
|
||||
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPost, "test.csv");
|
||||
assert!(targets.contains(&target_id));
|
||||
}
|
||||
|
||||
/// Test exact bug scenario
|
||||
#[test]
|
||||
fn test_bug_scenario_exact_flow() {
|
||||
let mut rules_map = RulesMap::new();
|
||||
let target_id = TargetID::new("webhook-primary".to_string(), "webhook".to_string());
|
||||
|
||||
// Configuration: Events=["s3:ObjectCreated:*"], prefix="uploads/", suffix=".csv"
|
||||
// This generates pattern "uploads/*.csv"
|
||||
rules_map.add_rule_config(&[EventName::ObjectCreatedAll], "uploads/*.csv".to_string(), target_id.clone());
|
||||
|
||||
// When ObjectCreatedPut event occurs with key "uploads/test.csv"
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/test.csv");
|
||||
|
||||
// Should match
|
||||
assert!(!targets.is_empty(), "Targets should not be empty for matching event");
|
||||
assert!(targets.contains(&target_id), "Should find webhook-primary target");
|
||||
|
||||
// Test non-matching key
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/test.txt");
|
||||
assert!(targets.is_empty(), "Targets should be empty for non-matching key");
|
||||
|
||||
// Test matching different suffix
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/subdir/file.csv");
|
||||
assert!(!targets.is_empty(), "Nested CSV files should also match");
|
||||
assert!(targets.contains(&target_id));
|
||||
}
|
||||
|
||||
/// Test multiple patterns for same event
|
||||
#[test]
|
||||
fn test_multiple_patterns_same_event() {
|
||||
let mut rules_map = RulesMap::new();
|
||||
let csv_target = TargetID::new("csv-target".to_string(), "webhook".to_string());
|
||||
let jpg_target = TargetID::new("jpg-target".to_string(), "webhook".to_string());
|
||||
|
||||
// Add different patterns for same event
|
||||
rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*.csv".to_string(), csv_target.clone());
|
||||
rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*.jpg".to_string(), jpg_target.clone());
|
||||
|
||||
// Both patterns should work
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "test.csv");
|
||||
assert!(targets.contains(&csv_target));
|
||||
assert!(!targets.contains(&jpg_target));
|
||||
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "test.jpg");
|
||||
assert!(!targets.contains(&csv_target));
|
||||
assert!(targets.contains(&jpg_target));
|
||||
}
|
||||
|
||||
/// Test prefix only filter
|
||||
#[test]
|
||||
fn test_prefix_only_filter() {
|
||||
let mut rules_map = RulesMap::new();
|
||||
let target_id = TargetID::new("test-target".to_string(), "webhook".to_string());
|
||||
|
||||
// Configuration: prefix="images/", no suffix
|
||||
// Pattern should be "images/*"
|
||||
rules_map.add_rule_config(&[EventName::ObjectCreatedAll], "images/*".to_string(), target_id.clone());
|
||||
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "images/photo.jpg");
|
||||
assert!(targets.contains(&target_id));
|
||||
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/photo.jpg");
|
||||
assert!(!targets.contains(&target_id));
|
||||
}
|
||||
|
||||
/// Test suffix only filter
|
||||
#[test]
|
||||
fn test_suffix_only_filter() {
|
||||
let mut rules_map = RulesMap::new();
|
||||
let target_id = TargetID::new("test-target".to_string(), "webhook".to_string());
|
||||
|
||||
// Configuration: no prefix, suffix=".pdf"
|
||||
// Pattern should be "*.pdf"
|
||||
rules_map.add_rule_config(&[EventName::ObjectCreatedAll], "*.pdf".to_string(), target_id.clone());
|
||||
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "document.pdf");
|
||||
assert!(targets.contains(&target_id));
|
||||
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "document.txt");
|
||||
assert!(!targets.contains(&target_id));
|
||||
}
|
||||
|
||||
/// Test empty pattern (no filter)
|
||||
#[test]
|
||||
fn test_no_filter() {
|
||||
let mut rules_map = RulesMap::new();
|
||||
let target_id = TargetID::new("test-target".to_string(), "webhook".to_string());
|
||||
|
||||
// Configuration: no prefix, no suffix
|
||||
// Pattern should default to "*" (match all)
|
||||
rules_map.add_rule_config(&[EventName::ObjectCreatedAll], "".to_string(), target_id.clone());
|
||||
|
||||
// All keys should match
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "anything.csv");
|
||||
assert!(targets.contains(&target_id));
|
||||
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/test.txt");
|
||||
assert!(targets.contains(&target_id));
|
||||
}
|
||||
|
||||
/// Test different event types
|
||||
#[test]
|
||||
fn test_different_event_types() {
|
||||
let mut rules_map = RulesMap::new();
|
||||
let put_target = TargetID::new("put-target".to_string(), "webhook".to_string());
|
||||
let delete_target = TargetID::new("delete-target".to_string(), "webhook".to_string());
|
||||
|
||||
// Add rules for different event types
|
||||
rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*.csv".to_string(), put_target.clone());
|
||||
rules_map.add_rule_config(&[EventName::ObjectRemovedDelete], "*.csv".to_string(), delete_target.clone());
|
||||
|
||||
// Put event should match put_target
|
||||
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "test.csv");
|
||||
assert!(targets.contains(&put_target));
|
||||
assert!(!targets.contains(&delete_target));
|
||||
|
||||
// Delete event should match delete_target
|
||||
let targets = rules_map.match_rules(EventName::ObjectRemovedDelete, "test.csv");
|
||||
assert!(!targets.contains(&put_target));
|
||||
assert!(targets.contains(&delete_target));
|
||||
}
|
||||
|
||||
/// Test remove_map
|
||||
#[test]
|
||||
fn test_remove_map() {
|
||||
let mut rules_map1 = RulesMap::new();
|
||||
let mut rules_map2 = RulesMap::new();
|
||||
|
||||
let target1 = TargetID::new("target1".to_string(), "webhook".to_string());
|
||||
let target2 = TargetID::new("target2".to_string(), "webhook".to_string());
|
||||
|
||||
rules_map1.add_rule_config(&[EventName::ObjectCreatedPut], "*.csv".to_string(), target1.clone());
|
||||
rules_map1.add_rule_config(&[EventName::ObjectCreatedPost], "*.jpg".to_string(), target1.clone());
|
||||
|
||||
rules_map2.add_rule_config(&[EventName::ObjectCreatedPut], "*.csv".to_string(), target2.clone());
|
||||
|
||||
// Remove rules_map2 from rules_map1
|
||||
rules_map1.remove_map(&rules_map2);
|
||||
|
||||
// ObjectCreatedPut rule should still exist with target1 (different from target2)
|
||||
let targets = rules_map1.match_rules(EventName::ObjectCreatedPut, "test.csv");
|
||||
assert!(targets.contains(&target1));
|
||||
assert!(!targets.contains(&target2));
|
||||
|
||||
// ObjectCreatedPost rule should still exist
|
||||
let targets = rules_map1.match_rules(EventName::ObjectCreatedPost, "test.jpg");
|
||||
assert!(targets.contains(&target1));
|
||||
}
|
||||
|
||||
/// Test remove_map removing same target
|
||||
#[test]
|
||||
fn test_remove_map_same_target() {
|
||||
let mut rules_map1 = RulesMap::new();
|
||||
let mut rules_map2 = RulesMap::new();
|
||||
|
||||
let target1 = TargetID::new("target1".to_string(), "webhook".to_string());
|
||||
|
||||
rules_map1.add_rule_config(&[EventName::ObjectCreatedPut], "*.csv".to_string(), target1.clone());
|
||||
rules_map1.add_rule_config(&[EventName::ObjectCreatedPost], "*.jpg".to_string(), target1.clone());
|
||||
|
||||
rules_map2.add_rule_config(&[EventName::ObjectCreatedPut], "*.csv".to_string(), target1.clone());
|
||||
|
||||
// Remove rules_map2 from rules_map1
|
||||
rules_map1.remove_map(&rules_map2);
|
||||
|
||||
// ObjectCreatedPut rule should be removed (same target and pattern)
|
||||
let targets = rules_map1.match_rules(EventName::ObjectCreatedPut, "test.csv");
|
||||
assert!(targets.is_empty());
|
||||
|
||||
// ObjectCreatedPost rule should still exist
|
||||
let targets = rules_map1.match_rules(EventName::ObjectCreatedPost, "test.jpg");
|
||||
assert!(targets.contains(&target1));
|
||||
}
|
||||
|
||||
/// Test contains_target_id
|
||||
#[test]
|
||||
fn test_contains_target_id() {
|
||||
let mut rules_map = RulesMap::new();
|
||||
let target_id = TargetID::new("test-target".to_string(), "webhook".to_string());
|
||||
|
||||
assert!(!rules_map.contains_target_id(&target_id));
|
||||
|
||||
rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*.csv".to_string(), target_id.clone());
|
||||
|
||||
assert!(rules_map.contains_target_id(&target_id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// 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.
|
||||
|
||||
//! Pattern matching tests for notification filter rules
|
||||
//!
|
||||
//! This module contains tests for the pattern matching logic used in
|
||||
//! bucket notification filters, specifically testing prefix and suffix
|
||||
//! filter rules as used in S3 bucket notifications.
|
||||
|
||||
use super::pattern;
|
||||
|
||||
#[cfg(test)]
|
||||
mod bug_reproduction_tests {
|
||||
use super::*;
|
||||
|
||||
/// Test case from bug report:
|
||||
/// - Prefix: "uploads/"
|
||||
/// - Suffix: ".csv"
|
||||
/// - Object key: "uploads/test.csv"
|
||||
#[test]
|
||||
fn test_uploads_csv_pattern() {
|
||||
let pattern = pattern::new_pattern(Some("uploads/"), Some(".csv"));
|
||||
assert_eq!(pattern, "uploads/*.csv");
|
||||
assert!(pattern::match_simple(&pattern, "uploads/test.csv"));
|
||||
assert!(pattern::match_simple(&pattern, "uploads/subdir/file.csv"));
|
||||
assert!(!pattern::match_simple(&pattern, "uploads/test.txt"));
|
||||
}
|
||||
|
||||
/// Test prefix only
|
||||
#[test]
|
||||
fn test_prefix_only() {
|
||||
let pattern = pattern::new_pattern(Some("images/"), None);
|
||||
assert_eq!(pattern, "images/*");
|
||||
assert!(pattern::match_simple(&pattern, "images/photo.jpg"));
|
||||
assert!(pattern::match_simple(&pattern, "images/subdir/photo.png"));
|
||||
assert!(!pattern::match_simple(&pattern, "documents/photo.jpg"));
|
||||
}
|
||||
|
||||
/// Test suffix only
|
||||
#[test]
|
||||
fn test_suffix_only() {
|
||||
let pattern = pattern::new_pattern(None, Some(".jpg"));
|
||||
assert_eq!(pattern, "*.jpg");
|
||||
assert!(pattern::match_simple(&pattern, "photo.jpg"));
|
||||
assert!(pattern::match_simple(&pattern, "uploads/photo.jpg"));
|
||||
assert!(pattern::match_simple(&pattern, "a/b/photo.jpg"));
|
||||
assert!(!pattern::match_simple(&pattern, "photo.png"));
|
||||
}
|
||||
|
||||
/// Test empty pattern (no filter)
|
||||
#[test]
|
||||
fn test_empty_pattern() {
|
||||
let pattern = pattern::new_pattern(None, None);
|
||||
assert_eq!(pattern, "");
|
||||
// Empty pattern should not match anything
|
||||
assert!(!pattern::match_simple(&pattern, "anything"));
|
||||
}
|
||||
|
||||
/// Test complex patterns
|
||||
#[test]
|
||||
fn test_complex_patterns() {
|
||||
// Pattern with both prefix and suffix
|
||||
let pattern = pattern::new_pattern(Some("data/uploads/"), Some(".csv"));
|
||||
assert_eq!(pattern, "data/uploads/*.csv");
|
||||
assert!(pattern::match_simple(&pattern, "data/uploads/test.csv"));
|
||||
assert!(!pattern::match_simple(&pattern, "data/test.csv"));
|
||||
|
||||
// Pattern with prefix containing special characters
|
||||
let pattern = pattern::new_pattern(Some("special-chars_123/"), Some(".txt"));
|
||||
assert_eq!(pattern, "special-chars_123/*.txt");
|
||||
assert!(pattern::match_simple(&pattern, "special-chars_123/file.txt"));
|
||||
}
|
||||
|
||||
/// Test edge cases
|
||||
#[test]
|
||||
fn test_edge_cases() {
|
||||
// Empty prefix and non-empty suffix
|
||||
let pattern = pattern::new_pattern(Some(""), Some(".csv"));
|
||||
assert_eq!(pattern, "*.csv");
|
||||
|
||||
// Non-empty prefix and empty suffix
|
||||
let pattern = pattern::new_pattern(Some("uploads/"), Some(""));
|
||||
assert_eq!(pattern, "uploads/*");
|
||||
|
||||
// Pattern ending with star in prefix
|
||||
let pattern = pattern::new_pattern(Some("uploads*"), Some(".csv"));
|
||||
assert_eq!(pattern, "uploads*.csv");
|
||||
|
||||
// Pattern starting with star in suffix
|
||||
let pattern = pattern::new_pattern(Some("uploads/"), Some("*csv"));
|
||||
assert_eq!(pattern, "uploads/*csv");
|
||||
}
|
||||
|
||||
/// Test the exact scenario from bug report
|
||||
#[test]
|
||||
fn test_bug_report_exact_scenario() {
|
||||
// Configuration: prefix="uploads/", suffix=".csv"
|
||||
let prefix = Some("uploads/");
|
||||
let suffix = Some(".csv");
|
||||
let object_key = "uploads/test.csv";
|
||||
|
||||
// Generate pattern from filter rules
|
||||
let pattern = pattern::new_pattern(prefix, suffix);
|
||||
println!("Generated pattern: '{}'", pattern);
|
||||
|
||||
// Verify pattern is correct
|
||||
assert_eq!(pattern, "uploads/*.csv");
|
||||
|
||||
// Test matching
|
||||
let matches = pattern::match_simple(&pattern, object_key);
|
||||
println!("Pattern '{}' matches key '{}': {}", pattern, object_key, matches);
|
||||
|
||||
assert!(matches, "Pattern '{}' should match object key '{}'", pattern, object_key);
|
||||
|
||||
// Test other keys
|
||||
assert!(pattern::match_simple(&pattern, "uploads/file1.csv"));
|
||||
assert!(pattern::match_simple(&pattern, "uploads/nested/file.csv"));
|
||||
assert!(!pattern::match_simple(&pattern, "uploads/file.txt"));
|
||||
assert!(!pattern::match_simple(&pattern, "files/test.csv"));
|
||||
}
|
||||
|
||||
/// Test with multiple slashes in path
|
||||
#[test]
|
||||
fn test_multiple_slashes() {
|
||||
let pattern = pattern::new_pattern(Some("a/b/c/"), Some(".csv"));
|
||||
assert_eq!(pattern, "a/b/c/*.csv");
|
||||
assert!(pattern::match_simple(&pattern, "a/b/c/test.csv"));
|
||||
assert!(!pattern::match_simple(&pattern, "a/b/test.csv"));
|
||||
}
|
||||
|
||||
/// Test wildcard behavior
|
||||
#[test]
|
||||
fn test_wildcard_behavior() {
|
||||
// Test that * matches any characters including slashes
|
||||
let pattern = "uploads/*.csv";
|
||||
assert!(pattern::match_simple(pattern, "uploads/test.csv"));
|
||||
assert!(pattern::match_simple(pattern, "uploads/subdir/test.csv"));
|
||||
assert!(pattern::match_simple(pattern, "uploads/a/b/c/test.csv"));
|
||||
|
||||
// Test that multiple wildcards work
|
||||
let pattern = "a*b*c.csv";
|
||||
assert!(pattern::match_simple(pattern, "a123b456c.csv"));
|
||||
assert!(pattern::match_simple(pattern, "abc.csv"));
|
||||
}
|
||||
|
||||
/// Test match_all pattern
|
||||
#[test]
|
||||
fn test_match_all_pattern() {
|
||||
// S3 uses "*" to match all objects
|
||||
let pattern = "*";
|
||||
assert!(pattern::match_simple(pattern, "anything"));
|
||||
assert!(pattern::match_simple(pattern, "uploads/test.csv"));
|
||||
assert!(pattern::match_simple(pattern, ""));
|
||||
}
|
||||
}
|
||||
@@ -83,17 +83,20 @@ impl FilterRule {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
|
||||
pub struct FilterRuleList {
|
||||
#[derive(Debug, Serialize, Clone, Default, PartialEq, Eq)]
|
||||
pub struct S3KeyFilter {
|
||||
#[serde(rename = "FilterRule", default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub rules: Vec<FilterRule>,
|
||||
pub filter_rule_list: Vec<FilterRule>,
|
||||
}
|
||||
|
||||
impl FilterRuleList {
|
||||
impl S3KeyFilter {
|
||||
/// Validate filter rules for duplicates.
|
||||
/// According to AWS S3 documentation, there can be at most one prefix
|
||||
/// and one suffix filter rule per queue configuration.
|
||||
pub fn validate(&self) -> Result<(), ParseConfigError> {
|
||||
let mut has_prefix = false;
|
||||
let mut has_suffix = false;
|
||||
for rule in &self.rules {
|
||||
for rule in &self.filter_rule_list {
|
||||
rule.validate()?;
|
||||
if rule.name == "prefix" {
|
||||
if has_prefix {
|
||||
@@ -110,11 +113,19 @@ impl FilterRuleList {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if filter rule list is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.filter_rule_list.is_empty()
|
||||
}
|
||||
|
||||
/// Generate pattern string from filter rules.
|
||||
/// This method extracts prefix and suffix values from filter rules
|
||||
/// and generates a wildcard pattern string for matching object keys.
|
||||
pub fn pattern(&self) -> String {
|
||||
let mut prefix_val: Option<&str> = None;
|
||||
let mut suffix_val: Option<&str> = None;
|
||||
|
||||
for rule in &self.rules {
|
||||
for rule in &self.filter_rule_list {
|
||||
if rule.name == "prefix" {
|
||||
prefix_val = Some(&rule.value);
|
||||
} else if rule.name == "suffix" {
|
||||
@@ -123,16 +134,92 @@ impl FilterRuleList {
|
||||
}
|
||||
pattern::new_pattern(prefix_val, suffix_val)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rules.is_empty()
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct S3KeyContent {
|
||||
#[serde(rename = "FilterRule", default)]
|
||||
filter_rule_list: Vec<FilterRule>,
|
||||
#[serde(rename = "FilterRuleList", default)]
|
||||
filter_rule_list_wrapper: Option<S3KeyFilterRuleList>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct S3KeyFilterRuleList {
|
||||
#[serde(rename = "FilterRule", default)]
|
||||
filter_rule_list: Vec<FilterRule>,
|
||||
}
|
||||
|
||||
impl S3KeyContent {
|
||||
/// Get all filter rules from this S3Key content, handling both direct FilterRule
|
||||
/// and FilterRuleList wrapper structures
|
||||
fn get_filter_rules(&self) -> Vec<FilterRule> {
|
||||
// If we have a FilterRuleList wrapper, use that
|
||||
if let Some(wrapper) = &self.filter_rule_list_wrapper
|
||||
&& !wrapper.filter_rule_list.is_empty()
|
||||
{
|
||||
return wrapper.filter_rule_list.clone();
|
||||
}
|
||||
// Otherwise use direct FilterRule list
|
||||
self.filter_rule_list.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
|
||||
pub struct S3KeyFilter {
|
||||
#[serde(rename = "FilterRuleList", default, skip_serializing_if = "FilterRuleList::is_empty")]
|
||||
pub filter_rule_list: FilterRuleList,
|
||||
/// Custom deserializer for S3KeyFilter to handle Filter element correctly.
|
||||
/// AWS S3 XML structure: <Filter><S3Key><FilterRule>...</FilterRule></S3Key></Filter>
|
||||
impl<'de> Deserialize<'de> for S3KeyFilter {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct S3KeyFilterVisitor {
|
||||
filter_rules: Vec<FilterRule>,
|
||||
}
|
||||
|
||||
impl S3KeyFilterVisitor {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
filter_rules: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> serde::de::Visitor<'de> for S3KeyFilterVisitor {
|
||||
type Value = S3KeyFilter;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(
|
||||
formatter,
|
||||
"an S3Key filter map with an `S3Key` element containing one or more \
|
||||
`FilterRule` children (e.g. <Filter><S3Key><FilterRule>...</FilterRule></S3Key></Filter>)"
|
||||
)
|
||||
}
|
||||
|
||||
fn visit_map<V>(mut self, mut map: V) -> Result<Self::Value, V::Error>
|
||||
where
|
||||
V: serde::de::MapAccess<'de>,
|
||||
{
|
||||
while let Some(key) = map.next_key::<String>()? {
|
||||
match key.as_str() {
|
||||
"S3Key" => {
|
||||
// Parse S3Key content which contains FilterRule(s)
|
||||
let s3key_content: S3KeyContent = map.next_value()?;
|
||||
self.filter_rules = s3key_content.get_filter_rules();
|
||||
}
|
||||
_ => {
|
||||
map.next_value::<serde::de::IgnoredAny>()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(S3KeyFilter {
|
||||
filter_rule_list: self.filter_rules,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_map(S3KeyFilterVisitor::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
@@ -163,7 +250,7 @@ impl QueueConfig {
|
||||
return Err(ParseConfigError::DuplicateEventName(event.to_string()));
|
||||
}
|
||||
}
|
||||
self.filter.filter_rule_list.validate()?;
|
||||
self.filter.validate()?;
|
||||
|
||||
// Validate ARN (similar to Go's Queue.Validate)
|
||||
// The Go code checks targetList.Exists(q.ARN.TargetID)
|
||||
@@ -241,6 +328,7 @@ pub struct NotificationConfiguration {
|
||||
skip_serializing_if = "Vec::is_empty"
|
||||
)]
|
||||
pub lambda_list: Vec<LambdaConfigDetail>, // Modify: Use a new structure
|
||||
|
||||
#[serde(
|
||||
rename = "TopicConfiguration", // Tags for each topic configuration item in XML
|
||||
default,
|
||||
|
||||
Reference in New Issue
Block a user