Some checks failed
CI / Rustfmt (push) Successful in 23s
CI / Cargo Audit & Deny (push) Successful in 30s
CI / Web Blocking Checks (push) Successful in 48s
CI / Security Blocking Checks (push) Successful in 8s
CI / Clippy (push) Failing after 1m55s
CI / Web Advisory Checks (push) Successful in 35s
CI / Security Advisory Checks (push) Successful in 37s
CI / Tests (push) Successful in 8m5s
1122 lines
34 KiB
Rust
1122 lines
34 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 = "integration test — requires database"]
|
|
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 = "integration test — requires database"]
|
|
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 = "integration test — requires database"]
|
|
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 = "integration test — requires database"]
|
|
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 = "integration test — requires database"]
|
|
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 = "integration test — requires database"]
|
|
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 = "integration test — requires database"]
|
|
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 = "integration test — requires database"]
|
|
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 = "integration test — requires database"]
|
|
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 = "integration test — requires database"]
|
|
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 = "integration test — requires database"]
|
|
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);
|
|
}
|