#[cfg(test)]
mod tests {
use crate::config::ProfilerConfig;
use crate::profiler::{is_likely_pii, redact_value, AnomalySignalType, Profiler};
fn default_config() -> ProfilerConfig {
ProfilerConfig {
enabled: true,
max_profiles: 100,
max_schemas: 50,
min_samples_for_validation: 10,
..Default::default()
}
}
#[test]
fn test_value_length_anomaly() {
let profiler = Profiler::new(default_config());
let template = "/api/test";
let training_values = ["hi", "hey", "hello", "short", "testing", "goodbye"];
for i in 0..50 {
let value = training_values[i % training_values.len()];
profiler.update_profile(template, 100, &[("param", value)], None);
}
let long_string = "a".repeat(100);
let result = profiler.analyze_request(template, 100, &[("param", &long_string)], None);
assert!(result
.signals
.iter()
.any(|s| s.signal_type == AnomalySignalType::ParamValueAnomaly));
}
#[test]
fn test_value_type_anomaly() {
let profiler = Profiler::new(default_config());
let template = "/api/test";
for i in 0..50 {
profiler.update_profile(template, 100, &[("id", &i.to_string())], None);
}
let result = profiler.analyze_request(template, 100, &[("id", "not_a_number")], None);
assert!(result
.signals
.iter()
.any(|s| s.signal_type == AnomalySignalType::ParamValueAnomaly));
}
#[test]
fn test_email_detection() {
let profiler = Profiler::new(default_config());
let template = "/api/users";
profiler.update_profile(template, 100, &[("email", "test@example.com")], None);
let profile = profiler.get_profile(template).unwrap();
let stats = profile.expected_params.get("email").unwrap();
assert_eq!(*stats.type_counts.get("email").unwrap_or(&0), 1);
}
#[test]
fn test_uuid_detection() {
let profiler = Profiler::new(default_config());
let template = "/api/items";
let uuid = "123e4567-e89b-12d3-a456-426614174000";
profiler.update_profile(template, 100, &[("id", uuid)], None);
let profile = profiler.get_profile(template).unwrap();
let stats = profile.expected_params.get("id").unwrap();
assert_eq!(*stats.type_counts.get("uuid").unwrap_or(&0), 1);
}
#[test]
fn test_redact_value_email() {
let email = "user@example.com";
let redacted = redact_value(email);
assert!(redacted.starts_with("us"));
assert!(redacted.ends_with("om"));
assert!(redacted.contains("*"));
assert!(!redacted.contains("@example"));
}
#[test]
fn test_redact_value_short() {
let short = "abc";
let redacted = redact_value(short);
assert_eq!(redacted, "***");
}
#[test]
fn test_redact_value_uuid() {
let uuid = "123e4567-e89b-12d3-a456-426614174000";
let redacted = redact_value(uuid);
assert!(redacted.starts_with("12"));
assert!(redacted.ends_with("00"));
assert!(redacted.contains("*"));
}
#[test]
fn test_is_likely_pii_email() {
assert!(is_likely_pii("test@example.com"));
assert!(is_likely_pii("user.name@company.org"));
assert!(!is_likely_pii("not-an-email"));
}
#[test]
fn test_is_likely_pii_uuid() {
assert!(is_likely_pii("123e4567-e89b-12d3-a456-426614174000"));
assert!(!is_likely_pii("not-a-uuid"));
}
#[test]
fn test_is_likely_pii_long_token() {
assert!(is_likely_pii("abcdefghijklmnopqrstuvwxyz123456"));
assert!(!is_likely_pii("short"));
}
#[test]
fn test_frozen_baseline_prevents_updates() {
let config = ProfilerConfig {
enabled: true,
max_profiles: 100,
max_schemas: 50,
min_samples_for_validation: 5,
freeze_after_samples: 10,
..Default::default()
};
let profiler = Profiler::new(config);
let template = "/api/frozen";
for i in 0..10 {
profiler.update_profile(template, 100, &[("val", &i.to_string())], None);
}
assert!(profiler.is_profile_frozen(template));
let count_before = profiler.get_profile(template).unwrap().sample_count;
for i in 10..20 {
profiler.update_profile(template, 100, &[("val", &i.to_string())], None);
}
let count_after = profiler.get_profile(template).unwrap().sample_count;
assert_eq!(count_before, count_after);
}
#[test]
fn test_unfrozen_profile_accepts_updates() {
let config = ProfilerConfig {
enabled: true,
max_profiles: 100,
max_schemas: 50,
min_samples_for_validation: 5,
freeze_after_samples: 0, ..Default::default()
};
let profiler = Profiler::new(config);
let template = "/api/unfrozen";
for i in 0..20 {
profiler.update_profile(template, 100, &[("val", &i.to_string())], None);
}
assert!(!profiler.is_profile_frozen(template));
assert_eq!(profiler.get_profile(template).unwrap().sample_count, 20);
}
#[test]
fn test_type_counts_bounded() {
use crate::profiler::ParamStats;
let mut stats = ParamStats::new();
for _ in 0..100 {
stats.update("12345"); stats.update("hello"); stats.update("test@example.com"); stats.update("123e4567-e89b-12d3-a456-426614174000"); }
assert!(stats.type_counts.len() <= 10);
}
#[test]
fn test_no_division_by_zero_empty_stats() {
let profiler = Profiler::new(default_config());
let template = "/api/divzero";
for _ in 0..10 {
profiler.update_profile(template, 100, &[], None);
}
let result = profiler.analyze_request(template, 100, &[("new_param", "value")], None);
assert!(result
.signals
.iter()
.any(|s| s.signal_type == AnomalySignalType::UnexpectedParam));
}
#[test]
fn test_configurable_z_threshold() {
let config = ProfilerConfig {
enabled: true,
max_profiles: 100,
max_schemas: 50,
min_samples_for_validation: 10,
payload_z_threshold: 10.0, ..Default::default()
};
let profiler = Profiler::new(config);
let template = "/api/threshold";
for _ in 0..50 {
profiler.update_profile(template, 100, &[], None);
}
let result = profiler.analyze_request(template, 200, &[], None);
assert!(!result
.signals
.iter()
.any(|s| s.signal_type == AnomalySignalType::PayloadSizeHigh));
}
#[test]
fn test_analyze_request_redacts_pii_in_signals() {
let mut config = default_config();
config.redact_pii = true;
let profiler = Profiler::new(config);
let template = "/api/pii";
for i in 0..20 {
let val = if i % 2 == 0 { "normal" } else { "standard" };
profiler.update_profile(template, 100, &[("user", val)], None);
}
let result = profiler.analyze_request(
template,
100,
&[(
"user",
"very-long-email-address-that-is-clearly-anomalous@example.com",
)],
None,
);
let signal = result
.signals
.iter()
.find(|s| {
s.signal_type == AnomalySignalType::ParamValueAnomaly
|| s.signal_type == AnomalySignalType::UnexpectedParam
})
.unwrap();
assert!(signal.detail.contains("ve***om") || signal.detail.contains("****"));
assert!(!signal.detail.contains("@example.com"));
}
#[test]
fn test_frozen_baseline_prevents_response_updates() {
let config = ProfilerConfig {
enabled: true,
freeze_after_samples: 5,
..Default::default()
};
let profiler = Profiler::new(config);
let template = "/api/resp-frozen";
for _i in 0..5 {
profiler.update_profile(template, 100, &[], None);
profiler.update_response_profile(template, 200, 200, None);
}
assert!(profiler.is_profile_frozen(template));
let profile_before = profiler.get_profile(template).unwrap();
profiler.update_response_profile(template, 10000, 500, None);
let profile_after = profiler.get_profile(template).unwrap();
assert_eq!(
profile_before.response_size.mean(),
profile_after.response_size.mean()
);
}
#[test]
fn test_get_or_create_profile_returns_none_at_capacity() {
let config = ProfilerConfig {
max_profiles: 2,
..Default::default()
};
let profiler = Profiler::new(config);
profiler.get_or_create_profile("/a");
profiler.get_or_create_profile("/b");
let result = profiler.get_or_create_profile("/c");
assert!(result.is_none());
}
#[test]
fn test_analyze_response_size_anomaly_detected() {
let profiler = Profiler::new(default_config());
let template = "/api/resp-size";
for _ in 0..15 {
profiler.update_profile(template, 100, &[], None);
}
for i in 0..20 {
let size = if i % 2 == 0 { 100 } else { 110 };
profiler.update_response_profile(template, size, 200, None);
}
let result = profiler.analyze_response(template, 10000, 200, None);
assert!(result
.signals
.iter()
.any(|s| s.signal_type == AnomalySignalType::PayloadSizeHigh));
}
#[test]
fn test_analyze_request_payload_size_low_detected() {
let profiler = Profiler::new(default_config());
let template = "/api/small-payload";
for i in 0..20 {
let size = if i % 2 == 0 { 1000 } else { 1100 };
profiler.update_profile(template, size, &[], None);
}
let result = profiler.analyze_request(template, 10, &[], None);
assert!(result
.signals
.iter()
.any(|s| s.signal_type == AnomalySignalType::PayloadSizeLow));
}
}