//! Integration tests for the WASM channel system. //! //! These tests verify the full flow of WASM channel operations: //! - Channel loading from filesystem //! - HTTP webhook routing //! - Message emission and delivery //! - Response handling use std::collections::HashMap; use std::sync::Arc; use ironclaw::channels::Channel; use ironclaw::channels::wasm::{ ChannelCapabilities, EmitRateLimitConfig, PreparedChannelModule, RegisteredEndpoint, WasmChannel, WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig, }; use ironclaw::pairing::PairingStore; use tempfile::TempDir; /// Create a test runtime for WASM channel operations. fn create_test_runtime() -> Arc { let config = WasmChannelRuntimeConfig::for_testing(); Arc::new(WasmChannelRuntime::new(config).expect("Failed to create runtime")) } /// Create a test channel with minimal configuration. fn create_test_channel( runtime: Arc, name: &str, paths: Vec<&str>, ) -> WasmChannel { let prepared = Arc::new(PreparedChannelModule::for_testing( name, format!("Test channel: {}", name), )); let mut capabilities = ChannelCapabilities::for_channel(name); for path in paths { capabilities = capabilities.with_path(path.to_string()); } WasmChannel::new( runtime, prepared, capabilities, "default", "{}".to_string(), Arc::new(PairingStore::new()), None, ) } mod router_tests { use super::*; #[tokio::test] async fn test_register_and_route_channel() { let router = WasmChannelRouter::new(); let runtime = create_test_runtime(); let channel = Arc::new(create_test_channel( runtime, "test-channel", vec!["/webhook/test"], )); let endpoints = vec![RegisteredEndpoint { channel_name: "test-channel".to_string(), path: "/webhook/test".to_string(), methods: vec!["POST".to_string()], require_secret: false, }]; router .register(channel.clone(), endpoints, None, None) .await; // Verify channel is found by path let found = router.get_channel_for_path("/webhook/test").await; assert!(found.is_some()); assert_eq!(found.unwrap().channel_name(), "test-channel"); // Verify non-existent path returns None let not_found = router.get_channel_for_path("/webhook/nonexistent").await; assert!(not_found.is_none()); } #[tokio::test] async fn test_secret_validation() { let router = WasmChannelRouter::new(); let runtime = create_test_runtime(); let channel = Arc::new(create_test_channel( runtime, "secure-channel", vec!["/webhook/secure"], )); router .register(channel, vec![], Some("my-secret-123".to_string()), None) .await; // Correct secret validates assert!( router .validate_secret("secure-channel", "my-secret-123") .await ); // Wrong secret fails assert!( !router .validate_secret("secure-channel", "wrong-secret") .await ); // Non-existent channel without secret always validates assert!(router.validate_secret("nonexistent", "anything").await); } #[tokio::test] async fn test_unregister_channel() { let router = WasmChannelRouter::new(); let runtime = create_test_runtime(); let channel = Arc::new(create_test_channel( runtime, "temp-channel", vec!["/webhook/temp"], )); let endpoints = vec![RegisteredEndpoint { channel_name: "temp-channel".to_string(), path: "/webhook/temp".to_string(), methods: vec!["POST".to_string()], require_secret: false, }]; router.register(channel, endpoints, None, None).await; // Channel exists assert!(router.get_channel_for_path("/webhook/temp").await.is_some()); // Unregister router.unregister("temp-channel").await; // Channel no longer exists assert!(router.get_channel_for_path("/webhook/temp").await.is_none()); } #[tokio::test] async fn test_multiple_channels() { let router = WasmChannelRouter::new(); let runtime = create_test_runtime(); // Register multiple channels for name in &["slack", "telegram", "discord"] { let channel = Arc::new(create_test_channel( Arc::clone(&runtime), name, vec![&format!("/webhook/{}", name)], )); let endpoints = vec![RegisteredEndpoint { channel_name: name.to_string(), path: format!("/webhook/{}", name), methods: vec!["POST".to_string()], require_secret: false, }]; router.register(channel, endpoints, None, None).await; } // Verify all channels are registered let channels = router.list_channels().await; assert_eq!(channels.len(), 3); assert!(channels.contains(&"slack".to_string())); assert!(channels.contains(&"telegram".to_string())); assert!(channels.contains(&"discord".to_string())); // Verify all paths work for name in &["slack", "telegram", "discord"] { let found = router .get_channel_for_path(&format!("/webhook/{}", name)) .await; assert!(found.is_some()); assert_eq!(found.unwrap().channel_name(), *name); } } } mod channel_lifecycle_tests { use super::*; #[tokio::test] async fn test_channel_start_and_shutdown() { let runtime = create_test_runtime(); let channel = create_test_channel(runtime, "lifecycle-test", vec!["/webhook/lifecycle"]); // Start channel let stream = channel.start().await; assert!(stream.is_ok()); // Health check should pass assert!(channel.health_check().await.is_ok()); // Shutdown assert!(channel.shutdown().await.is_ok()); // Health check should fail after shutdown assert!(channel.health_check().await.is_err()); } #[tokio::test] async fn test_channel_http_callback() { let runtime = create_test_runtime(); let channel = create_test_channel(runtime, "http-test", vec!["/webhook/http"]); // Start channel let _stream = channel.start().await.expect("Failed to start channel"); // Call HTTP callback (stub implementation returns 200 OK) let response = channel .call_on_http_request( "POST", "/webhook/http", &HashMap::new(), &HashMap::new(), b"{}", true, ) .await .expect("HTTP callback failed"); assert_eq!(response.status, 200); // Cleanup channel.shutdown().await.expect("Shutdown failed"); } } mod loader_tests { use super::*; use std::io::Write; #[tokio::test] async fn test_discover_channels_empty_dir() { let dir = TempDir::new().expect("Failed to create temp dir"); let channels = ironclaw::channels::wasm::discover_channels(dir.path()) .await .expect("Discovery failed"); assert!(channels.is_empty()); } #[tokio::test] async fn test_discover_channels_with_wasm_files() { let dir = TempDir::new().expect("Failed to create temp dir"); // Create fake WASM files std::fs::File::create(dir.path().join("slack.wasm")).expect("Failed to create file"); std::fs::File::create(dir.path().join("telegram.wasm")).expect("Failed to create file"); let channels = ironclaw::channels::wasm::discover_channels(dir.path()) .await .expect("Discovery failed"); assert_eq!(channels.len(), 2); assert!(channels.contains_key("slack")); assert!(channels.contains_key("telegram")); } #[tokio::test] async fn test_discover_channels_with_capabilities() { let dir = TempDir::new().expect("Failed to create temp dir"); // Create WASM and capabilities file std::fs::File::create(dir.path().join("custom.wasm")).expect("Failed to create wasm"); let mut cap_file = std::fs::File::create(dir.path().join("custom.capabilities.json")) .expect("Failed to create capabilities"); cap_file .write_all( br#"{ "name": "custom", "capabilities": { "channel": { "allowed_paths": ["/webhook/custom"] } } }"#, ) .expect("Failed to write capabilities"); let channels = ironclaw::channels::wasm::discover_channels(dir.path()) .await .expect("Discovery failed"); assert_eq!(channels.len(), 1); assert!(channels["custom"].capabilities_path.is_some()); } #[tokio::test] async fn test_discover_channels_ignores_non_wasm() { let dir = TempDir::new().expect("Failed to create temp dir"); // Create various non-WASM files std::fs::File::create(dir.path().join("readme.md")).expect("Failed to create file"); std::fs::File::create(dir.path().join("config.json")).expect("Failed to create file"); std::fs::File::create(dir.path().join("channel.wasm")).expect("Failed to create file"); let channels = ironclaw::channels::wasm::discover_channels(dir.path()) .await .expect("Discovery failed"); // Only the .wasm file should be discovered assert_eq!(channels.len(), 1); assert!(channels.contains_key("channel")); } } mod capabilities_tests { use super::*; #[test] fn test_capabilities_path_validation() { let caps = ChannelCapabilities::for_channel("test") .with_path("/webhook/test") .with_path("/api/events"); assert!(caps.is_path_allowed("/webhook/test")); assert!(caps.is_path_allowed("/api/events")); assert!(!caps.is_path_allowed("/other/path")); } #[test] fn test_capabilities_workspace_prefix() { let caps = ChannelCapabilities::for_channel("slack"); assert_eq!(caps.workspace_prefix, "channels/slack/"); // Validate path prefixing let prefixed = caps.prefix_workspace_path("state.json"); assert_eq!(prefixed, "channels/slack/state.json"); } #[test] fn test_capabilities_workspace_path_validation() { let caps = ChannelCapabilities::for_channel("test"); // Valid paths assert!(caps.validate_workspace_path("state.json").is_ok()); assert!(caps.validate_workspace_path("data/file.txt").is_ok()); // Invalid paths (traversal attempts) assert!(caps.validate_workspace_path("../escape.txt").is_err()); assert!(caps.validate_workspace_path("/absolute/path").is_err()); assert!(caps.validate_workspace_path("data/../escape").is_err()); } #[test] fn test_capabilities_poll_interval_validation() { let caps = ChannelCapabilities::for_channel("test").with_polling(30_000); // Valid interval (returns as-is) let result = caps.validate_poll_interval(60_000); assert!(result.is_ok()); assert_eq!(result.unwrap(), 60_000); // Too short interval is clamped to minimum (not rejected) let result = caps.validate_poll_interval(1_000); assert!(result.is_ok()); assert_eq!(result.unwrap(), 30_000); // Minimum interval passes as-is let result = caps.validate_poll_interval(30_000); assert!(result.is_ok()); assert_eq!(result.unwrap(), 30_000); // Polling disabled returns error let no_poll_caps = ChannelCapabilities::for_channel("no-poll"); assert!(no_poll_caps.validate_poll_interval(60_000).is_err()); } #[test] fn test_emit_rate_limit_config() { let config = EmitRateLimitConfig { messages_per_minute: 100, messages_per_hour: 5000, }; assert_eq!(config.messages_per_minute, 100); assert_eq!(config.messages_per_hour, 5000); } } mod message_emission_tests { use super::*; use ironclaw::channels::wasm::{ChannelHostState, EmittedMessage}; #[test] fn test_emit_message_basic() { let caps = ChannelCapabilities::for_channel("test"); let mut state = ChannelHostState::new("test", caps); let msg = EmittedMessage::new("user123", "Hello, world!"); state.emit_message(msg).expect("Emit should succeed"); assert_eq!(state.emitted_count(), 1); let messages = state.take_emitted_messages(); assert_eq!(messages.len(), 1); assert_eq!(messages[0].user_id, "user123"); assert_eq!(messages[0].content, "Hello, world!"); // Queue should be cleared assert_eq!(state.emitted_count(), 0); } #[test] fn test_emit_message_with_metadata() { let caps = ChannelCapabilities::for_channel("test"); let mut state = ChannelHostState::new("test", caps); let msg = EmittedMessage::new("user123", "Hello") .with_user_name("John Doe") .with_thread_id("thread-1") .with_metadata(r#"{"channel": "C123"}"#); state.emit_message(msg).expect("Emit should succeed"); let messages = state.take_emitted_messages(); assert_eq!(messages[0].user_name, Some("John Doe".to_string())); assert_eq!(messages[0].thread_id, Some("thread-1".to_string())); assert!(messages[0].metadata_json.contains("channel")); } #[test] fn test_emit_rate_limiting() { let caps = ChannelCapabilities::for_channel("test"); let mut state = ChannelHostState::new("test", caps); // Emit up to the per-execution limit for i in 0..100 { let msg = EmittedMessage::new("user", format!("Message {}", i)); state.emit_message(msg).expect("Emit should succeed"); } // Messages beyond the limit are silently dropped let msg = EmittedMessage::new("user", "Should be dropped"); state.emit_message(msg).expect("Emit should not fail"); assert_eq!(state.emitted_count(), 100); assert_eq!(state.emits_dropped(), 1); } }