Files
attune/crates/api/tests/webhook_security_tests.rs

1122 lines
33 KiB
Rust

//! Comprehensive integration tests for webhook security features (Phase 3)
//!
//! Tests cover:
//! - HMAC signature verification (SHA256, SHA512, SHA1)
//! - Rate limiting
//! - IP whitelisting
//! - Payload size limits
//! - Event logging
//! - Error scenarios
use attune_api::{AppState, Server};
use attune_common::{
config::Config,
db::Database,
repositories::{
pack::{CreatePackInput, PackRepository},
trigger::{CreateTriggerInput, TriggerRepository},
Create,
},
};
use axum::{
body::Body,
http::{Request, StatusCode},
};
use serde_json::json;
use tower::ServiceExt;
/// Helper to create test database and state
async fn setup_test_state() -> AppState {
let config = Config::load().expect("Failed to load config");
let database = Database::new(&config.database)
.await
.expect("Failed to connect to database");
AppState::new(database.pool().clone(), config)
}
/// Helper to create a test pack
async fn create_test_pack(state: &AppState, name: &str) -> i64 {
let input = CreatePackInput {
r#ref: name.to_string(),
label: format!("{} Pack", name),
description: Some(format!("Test pack for {}", name)),
version: "1.0.0".to_string(),
conf_schema: serde_json::json!({}),
config: serde_json::json!({}),
meta: serde_json::json!({}),
tags: vec![],
runtime_deps: vec![],
dependencies: vec![],
is_standard: false,
installers: json!({}),
};
let pack = PackRepository::create(&state.db, input)
.await
.expect("Failed to create pack");
pack.id
}
/// Helper to create a test trigger
async fn create_test_trigger(
state: &AppState,
pack_id: i64,
pack_ref: &str,
trigger_ref: &str,
) -> i64 {
let input = CreateTriggerInput {
r#ref: trigger_ref.to_string(),
pack: Some(pack_id),
pack_ref: Some(pack_ref.to_string()),
label: format!("{} Trigger", trigger_ref),
description: Some(format!("Test trigger {}", trigger_ref)),
enabled: true,
param_schema: None,
out_schema: None,
is_adhoc: false,
};
let trigger = TriggerRepository::create(&state.db, input)
.await
.expect("Failed to create trigger");
trigger.id
}
/// Helper to generate HMAC signature
fn generate_hmac_signature(payload: &[u8], secret: &str, algorithm: &str) -> String {
use hmac::{Hmac, Mac};
use sha1::Sha1;
use sha2::{Sha256, Sha512};
match algorithm {
"sha256" => {
type HmacSha256 = Hmac<Sha256>;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(payload);
let result = mac.finalize();
format!("sha256={}", hex::encode(result.into_bytes()))
}
"sha512" => {
type HmacSha512 = Hmac<Sha512>;
let mut mac = HmacSha512::new_from_slice(secret.as_bytes()).unwrap();
mac.update(payload);
let result = mac.finalize();
format!("sha512={}", hex::encode(result.into_bytes()))
}
"sha1" => {
type HmacSha1 = Hmac<Sha1>;
let mut mac = HmacSha1::new_from_slice(secret.as_bytes()).unwrap();
mac.update(payload);
let result = mac.finalize();
format!("sha1={}", hex::encode(result.into_bytes()))
}
_ => panic!("Unsupported algorithm: {}", algorithm),
}
}
// ============================================================================
// HMAC SIGNATURE TESTS
// ============================================================================
#[tokio::test]
#[ignore]
async fn test_webhook_hmac_sha256_valid() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
let app = server.router();
// Create test data
let pack_id = create_test_pack(&state, "hmac_sha256_test").await;
let trigger_id = create_test_trigger(
&state,
pack_id,
"hmac_sha256_test",
"hmac_sha256_test.trigger",
)
.await;
// Enable webhooks
let webhook_info = TriggerRepository::enable_webhook(&state.db, trigger_id)
.await
.expect("Failed to enable webhook");
// Configure HMAC
let hmac_secret = "test-secret-key-12345";
sqlx::query(
"UPDATE attune.trigger SET
webhook_hmac_enabled = true,
webhook_hmac_algorithm = 'sha256',
webhook_hmac_secret = $1
WHERE id = $2",
)
.bind(hmac_secret)
.bind(trigger_id)
.execute(&state.db)
.await
.expect("Failed to configure HMAC");
// Prepare webhook payload
let webhook_payload = json!({
"payload": {
"event": "test_event",
"data": {"foo": "bar"}
}
});
let payload_bytes = serde_json::to_vec(&webhook_payload).unwrap();
// Generate valid signature
let signature = generate_hmac_signature(&payload_bytes, hmac_secret, "sha256");
// Send webhook with valid signature
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/webhooks/{}", webhook_info.webhook_key))
.header("content-type", "application/json")
.header("x-webhook-signature", signature)
.body(Body::from(payload_bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
#[ignore]
async fn test_webhook_hmac_sha512_valid() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
let app = server.router();
let pack_id = create_test_pack(&state, "hmac_sha512_test").await;
let trigger_id = create_test_trigger(
&state,
pack_id,
"hmac_sha512_test",
"hmac_sha512_test.trigger",
)
.await;
let webhook_info = TriggerRepository::enable_webhook(&state.db, trigger_id)
.await
.expect("Failed to enable webhook");
let hmac_secret = "test-secret-sha512";
sqlx::query(
"UPDATE attune.trigger SET
webhook_hmac_enabled = true,
webhook_hmac_algorithm = 'sha512',
webhook_hmac_secret = $1
WHERE id = $2",
)
.bind(hmac_secret)
.bind(trigger_id)
.execute(&state.db)
.await
.expect("Failed to configure HMAC");
let webhook_payload = json!({
"payload": {"message": "test"}
});
let payload_bytes = serde_json::to_vec(&webhook_payload).unwrap();
let signature = generate_hmac_signature(&payload_bytes, hmac_secret, "sha512");
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/webhooks/{}", webhook_info.webhook_key))
.header("content-type", "application/json")
.header("x-webhook-signature", signature)
.body(Body::from(payload_bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
#[ignore]
async fn test_webhook_hmac_invalid_signature() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
let app = server.router();
let pack_id = create_test_pack(&state, "hmac_invalid_test").await;
let trigger_id = create_test_trigger(
&state,
pack_id,
"hmac_invalid_test",
"hmac_invalid_test.trigger",
)
.await;
let webhook_info = TriggerRepository::enable_webhook(&state.db, trigger_id)
.await
.expect("Failed to enable webhook");
let hmac_secret = "test-secret-key";
sqlx::query(
"UPDATE attune.trigger SET
webhook_hmac_enabled = true,
webhook_hmac_algorithm = 'sha256',
webhook_hmac_secret = $1
WHERE id = $2",
)
.bind(hmac_secret)
.bind(trigger_id)
.execute(&state.db)
.await
.expect("Failed to configure HMAC");
let webhook_payload = json!({
"payload": {"message": "test"}
});
// Send with invalid signature
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/webhooks/{}", webhook_info.webhook_key))
.header("content-type", "application/json")
.header("x-webhook-signature", "sha256=invalid_signature_here")
.body(Body::from(serde_json::to_string(&webhook_payload).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
#[ignore]
async fn test_webhook_hmac_missing_signature() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
let app = server.router();
let pack_id = create_test_pack(&state, "hmac_missing_test").await;
let trigger_id = create_test_trigger(
&state,
pack_id,
"hmac_missing_test",
"hmac_missing_test.trigger",
)
.await;
let webhook_info = TriggerRepository::enable_webhook(&state.db, trigger_id)
.await
.expect("Failed to enable webhook");
sqlx::query(
"UPDATE attune.trigger SET
webhook_hmac_enabled = true,
webhook_hmac_algorithm = 'sha256',
webhook_hmac_secret = 'secret'
WHERE id = $1",
)
.bind(trigger_id)
.execute(&state.db)
.await
.expect("Failed to configure HMAC");
let webhook_payload = json!({
"payload": {"message": "test"}
});
// Send without signature header
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/webhooks/{}", webhook_info.webhook_key))
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&webhook_payload).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
#[ignore]
async fn test_webhook_hmac_wrong_secret() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
let app = server.router();
let pack_id = create_test_pack(&state, "hmac_wrong_secret_test").await;
let trigger_id = create_test_trigger(
&state,
pack_id,
"hmac_wrong_secret_test",
"hmac_wrong_secret_test.trigger",
)
.await;
let webhook_info = TriggerRepository::enable_webhook(&state.db, trigger_id)
.await
.expect("Failed to enable webhook");
let hmac_secret = "correct-secret";
sqlx::query(
"UPDATE attune.trigger SET
webhook_hmac_enabled = true,
webhook_hmac_algorithm = 'sha256',
webhook_hmac_secret = $1
WHERE id = $2",
)
.bind(hmac_secret)
.bind(trigger_id)
.execute(&state.db)
.await
.expect("Failed to configure HMAC");
let webhook_payload = json!({
"payload": {"message": "test"}
});
let payload_bytes = serde_json::to_vec(&webhook_payload).unwrap();
// Generate signature with wrong secret
let wrong_signature = generate_hmac_signature(&payload_bytes, "wrong-secret", "sha256");
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/webhooks/{}", webhook_info.webhook_key))
.header("content-type", "application/json")
.header("x-webhook-signature", wrong_signature)
.body(Body::from(payload_bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
// ============================================================================
// RATE LIMITING TESTS
// ============================================================================
#[tokio::test]
#[ignore]
async fn test_webhook_rate_limit_enforced() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
let pack_id = create_test_pack(&state, "rate_limit_test").await;
let trigger_id = create_test_trigger(
&state,
pack_id,
"rate_limit_test",
"rate_limit_test.trigger",
)
.await;
let webhook_info = TriggerRepository::enable_webhook(&state.db, trigger_id)
.await
.expect("Failed to enable webhook");
// Configure rate limit: 3 requests per 60 seconds
sqlx::query(
"UPDATE attune.trigger SET
webhook_rate_limit_enabled = true,
webhook_rate_limit_requests = 3,
webhook_rate_limit_window_seconds = 60
WHERE id = $1",
)
.bind(trigger_id)
.execute(&state.db)
.await
.expect("Failed to configure rate limit");
let webhook_payload = json!({
"payload": {"message": "test"}
});
// Send 3 requests (should succeed)
for i in 0..3 {
let app = server.router();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/webhooks/{}", webhook_info.webhook_key))
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&webhook_payload).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"Request {} should succeed",
i + 1
);
}
// 4th request should be rate limited
let app = server.router();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/webhooks/{}", webhook_info.webhook_key))
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&webhook_payload).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
}
#[tokio::test]
#[ignore]
async fn test_webhook_rate_limit_disabled() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
let pack_id = create_test_pack(&state, "no_rate_limit_test").await;
let trigger_id = create_test_trigger(
&state,
pack_id,
"no_rate_limit_test",
"no_rate_limit_test.trigger",
)
.await;
let webhook_info = TriggerRepository::enable_webhook(&state.db, trigger_id)
.await
.expect("Failed to enable webhook");
// Ensure rate limiting is disabled (default)
let webhook_payload = json!({
"payload": {"message": "test"}
});
// Send multiple requests - all should succeed
for _ in 0..10 {
let app = server.router();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/webhooks/{}", webhook_info.webhook_key))
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&webhook_payload).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
}
// ============================================================================
// IP WHITELISTING TESTS
// ============================================================================
#[tokio::test]
#[ignore]
async fn test_webhook_ip_whitelist_allowed() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
let app = server.router();
let pack_id = create_test_pack(&state, "ip_whitelist_test").await;
let trigger_id = create_test_trigger(
&state,
pack_id,
"ip_whitelist_test",
"ip_whitelist_test.trigger",
)
.await;
let webhook_info = TriggerRepository::enable_webhook(&state.db, trigger_id)
.await
.expect("Failed to enable webhook");
// Configure IP whitelist
sqlx::query(
"UPDATE attune.trigger SET
webhook_ip_whitelist_enabled = true,
webhook_ip_whitelist = ARRAY['192.168.1.0/24', '10.0.0.1']
WHERE id = $1",
)
.bind(trigger_id)
.execute(&state.db)
.await
.expect("Failed to configure IP whitelist");
let webhook_payload = json!({
"payload": {"message": "test"}
});
// Test with allowed IP in CIDR range
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/webhooks/{}", webhook_info.webhook_key))
.header("content-type", "application/json")
.header("x-forwarded-for", "192.168.1.100")
.body(Body::from(serde_json::to_string(&webhook_payload).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// Test with exact match IP
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/webhooks/{}", webhook_info.webhook_key))
.header("content-type", "application/json")
.header("x-forwarded-for", "10.0.0.1")
.body(Body::from(serde_json::to_string(&webhook_payload).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
#[ignore]
async fn test_webhook_ip_whitelist_blocked() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
let app = server.router();
let pack_id = create_test_pack(&state, "ip_blocked_test").await;
let trigger_id = create_test_trigger(
&state,
pack_id,
"ip_blocked_test",
"ip_blocked_test.trigger",
)
.await;
let webhook_info = TriggerRepository::enable_webhook(&state.db, trigger_id)
.await
.expect("Failed to enable webhook");
sqlx::query(
"UPDATE attune.trigger SET
webhook_ip_whitelist_enabled = true,
webhook_ip_whitelist = ARRAY['192.168.1.0/24']
WHERE id = $1",
)
.bind(trigger_id)
.execute(&state.db)
.await
.expect("Failed to configure IP whitelist");
let webhook_payload = json!({
"payload": {"message": "test"}
});
// Test with IP not in whitelist
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/webhooks/{}", webhook_info.webhook_key))
.header("content-type", "application/json")
.header("x-forwarded-for", "8.8.8.8")
.body(Body::from(serde_json::to_string(&webhook_payload).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
// ============================================================================
// PAYLOAD SIZE LIMIT TESTS
// ============================================================================
#[tokio::test]
#[ignore]
async fn test_webhook_payload_size_limit_enforced() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
let app = server.router();
let pack_id = create_test_pack(&state, "size_limit_test").await;
let trigger_id = create_test_trigger(
&state,
pack_id,
"size_limit_test",
"size_limit_test.trigger",
)
.await;
let webhook_info = TriggerRepository::enable_webhook(&state.db, trigger_id)
.await
.expect("Failed to enable webhook");
// Set small payload limit: 1 KB
sqlx::query("UPDATE attune.trigger SET webhook_payload_size_limit_kb = 1 WHERE id = $1")
.bind(trigger_id)
.execute(&state.db)
.await
.expect("Failed to set payload size limit");
// Create a large payload (> 1 KB)
let large_data = "x".repeat(2000);
let webhook_payload = json!({
"payload": {
"large_field": large_data
}
});
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/webhooks/{}", webhook_info.webhook_key))
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&webhook_payload).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
#[ignore]
async fn test_webhook_payload_size_within_limit() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
let app = server.router();
let pack_id = create_test_pack(&state, "size_ok_test").await;
let trigger_id =
create_test_trigger(&state, pack_id, "size_ok_test", "size_ok_test.trigger").await;
let webhook_info = TriggerRepository::enable_webhook(&state.db, trigger_id)
.await
.expect("Failed to enable webhook");
// Set payload limit: 10 KB
sqlx::query("UPDATE attune.trigger SET webhook_payload_size_limit_kb = 10 WHERE id = $1")
.bind(trigger_id)
.execute(&state.db)
.await
.expect("Failed to set payload size limit");
// Create a small payload (< 10 KB)
let webhook_payload = json!({
"payload": {
"message": "This is a small payload"
}
});
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/webhooks/{}", webhook_info.webhook_key))
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&webhook_payload).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
// ============================================================================
// EVENT LOGGING TESTS
// ============================================================================
#[tokio::test]
#[ignore]
async fn test_webhook_event_logging_success() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
let app = server.router();
let pack_id = create_test_pack(&state, "event_log_test").await;
let trigger_id =
create_test_trigger(&state, pack_id, "event_log_test", "event_log_test.trigger").await;
let webhook_info = TriggerRepository::enable_webhook(&state.db, trigger_id)
.await
.expect("Failed to enable webhook");
let webhook_payload = json!({
"payload": {"message": "test"}
});
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/webhooks/{}", webhook_info.webhook_key))
.header("content-type", "application/json")
.header("x-forwarded-for", "192.168.1.1")
.header("user-agent", "TestAgent/1.0")
.body(Body::from(serde_json::to_string(&webhook_payload).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// Verify event was logged
let log_count: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM attune.webhook_event_log WHERE trigger_id = $1")
.bind(trigger_id)
.fetch_one(&state.db)
.await
.expect("Failed to check event log");
assert!(log_count.0 > 0, "Event should be logged");
// Check log details
let log: (i32, Option<String>, Option<String>) = sqlx::query_as(
"SELECT status_code, source_ip, user_agent FROM attune.webhook_event_log
WHERE trigger_id = $1 ORDER BY created DESC LIMIT 1",
)
.bind(trigger_id)
.fetch_one(&state.db)
.await
.expect("Failed to fetch log details");
assert_eq!(log.0, 200);
assert_eq!(log.1.as_deref(), Some("192.168.1.1"));
assert_eq!(log.2.as_deref(), Some("TestAgent/1.0"));
}
#[tokio::test]
#[ignore]
async fn test_webhook_event_logging_failure() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
let app = server.router();
let pack_id = create_test_pack(&state, "event_log_fail_test").await;
let trigger_id = create_test_trigger(
&state,
pack_id,
"event_log_fail_test",
"event_log_fail_test.trigger",
)
.await;
let webhook_info = TriggerRepository::enable_webhook(&state.db, trigger_id)
.await
.expect("Failed to enable webhook");
// Configure HMAC to force failure
sqlx::query(
"UPDATE attune.trigger SET
webhook_hmac_enabled = true,
webhook_hmac_algorithm = 'sha256',
webhook_hmac_secret = 'secret'
WHERE id = $1",
)
.bind(trigger_id)
.execute(&state.db)
.await
.expect("Failed to configure HMAC");
let webhook_payload = json!({
"payload": {"message": "test"}
});
// Send without signature (should fail)
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/webhooks/{}", webhook_info.webhook_key))
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&webhook_payload).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
// Verify failure was logged
let log: (i32, Option<String>, Option<bool>) = sqlx::query_as(
"SELECT status_code, error_message, hmac_verified FROM attune.webhook_event_log
WHERE trigger_id = $1 ORDER BY created DESC LIMIT 1",
)
.bind(trigger_id)
.fetch_one(&state.db)
.await
.expect("Failed to fetch log details");
assert_eq!(log.0, 401);
assert!(log.1.is_some());
assert_eq!(log.2, Some(false));
}
// ============================================================================
// COMBINED SECURITY FEATURES TESTS
// ============================================================================
#[tokio::test]
#[ignore]
async fn test_webhook_all_security_features_pass() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
let app = server.router();
let pack_id = create_test_pack(&state, "all_features_test").await;
let trigger_id = create_test_trigger(
&state,
pack_id,
"all_features_test",
"all_features_test.trigger",
)
.await;
let webhook_info = TriggerRepository::enable_webhook(&state.db, trigger_id)
.await
.expect("Failed to enable webhook");
let hmac_secret = "all-features-secret";
// Enable all security features
sqlx::query(
"UPDATE attune.trigger SET
webhook_hmac_enabled = true,
webhook_hmac_algorithm = 'sha256',
webhook_hmac_secret = $1,
webhook_rate_limit_enabled = true,
webhook_rate_limit_requests = 10,
webhook_rate_limit_window_seconds = 60,
webhook_ip_whitelist_enabled = true,
webhook_ip_whitelist = ARRAY['192.168.1.0/24'],
webhook_payload_size_limit_kb = 10
WHERE id = $2",
)
.bind(hmac_secret)
.bind(trigger_id)
.execute(&state.db)
.await
.expect("Failed to configure all features");
let webhook_payload = json!({
"payload": {"message": "test with all features"}
});
let payload_bytes = serde_json::to_vec(&webhook_payload).unwrap();
let signature = generate_hmac_signature(&payload_bytes, hmac_secret, "sha256");
// Send webhook that passes all checks
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/webhooks/{}", webhook_info.webhook_key))
.header("content-type", "application/json")
.header("x-webhook-signature", signature)
.header("x-forwarded-for", "192.168.1.50")
.header("user-agent", "TestClient/1.0")
.body(Body::from(payload_bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// Verify event log shows all checks passed
let log: (Option<bool>, bool, Option<bool>) = sqlx::query_as(
"SELECT hmac_verified, rate_limited, ip_allowed FROM attune.webhook_event_log
WHERE trigger_id = $1 ORDER BY created DESC LIMIT 1",
)
.bind(trigger_id)
.fetch_one(&state.db)
.await
.expect("Failed to fetch log details");
assert_eq!(log.0, Some(true)); // HMAC verified
assert!(!log.1); // Not rate limited
assert_eq!(log.2, Some(true)); // IP allowed
}
#[tokio::test]
#[ignore]
async fn test_webhook_multiple_security_failures() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
let app = server.router();
let pack_id = create_test_pack(&state, "multi_fail_test").await;
let trigger_id = create_test_trigger(
&state,
pack_id,
"multi_fail_test",
"multi_fail_test.trigger",
)
.await;
let webhook_info = TriggerRepository::enable_webhook(&state.db, trigger_id)
.await
.expect("Failed to enable webhook");
// Enable multiple security features
sqlx::query(
"UPDATE attune.trigger SET
webhook_hmac_enabled = true,
webhook_hmac_algorithm = 'sha256',
webhook_hmac_secret = 'secret',
webhook_ip_whitelist_enabled = true,
webhook_ip_whitelist = ARRAY['10.0.0.0/8']
WHERE id = $1",
)
.bind(trigger_id)
.execute(&state.db)
.await
.expect("Failed to configure features");
let webhook_payload = json!({
"payload": {"message": "test"}
});
// Send webhook that fails multiple checks (wrong IP, missing signature)
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/webhooks/{}", webhook_info.webhook_key))
.header("content-type", "application/json")
.header("x-forwarded-for", "8.8.8.8") // Wrong IP
// Missing signature header
.body(Body::from(serde_json::to_string(&webhook_payload).unwrap()))
.unwrap(),
)
.await
.unwrap();
// Should fail on IP check first
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
// ============================================================================
// EDGE CASES AND ERROR SCENARIOS
// ============================================================================
#[tokio::test]
#[ignore]
async fn test_webhook_malformed_json() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
let app = server.router();
let pack_id = create_test_pack(&state, "malformed_json_test").await;
let trigger_id = create_test_trigger(
&state,
pack_id,
"malformed_json_test",
"malformed_json_test.trigger",
)
.await;
let webhook_info = TriggerRepository::enable_webhook(&state.db, trigger_id)
.await
.expect("Failed to enable webhook");
// Send malformed JSON
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/webhooks/{}", webhook_info.webhook_key))
.header("content-type", "application/json")
.body(Body::from("{invalid json here"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
#[ignore]
async fn test_webhook_empty_payload() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
let app = server.router();
let pack_id = create_test_pack(&state, "empty_payload_test").await;
let trigger_id = create_test_trigger(
&state,
pack_id,
"empty_payload_test",
"empty_payload_test.trigger",
)
.await;
let webhook_info = TriggerRepository::enable_webhook(&state.db, trigger_id)
.await
.expect("Failed to enable webhook");
// Send empty body
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/webhooks/{}", webhook_info.webhook_key))
.header("content-type", "application/json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}