Files
optimclaw/tests/wasm_channel_integration.rs
T
Henry ParkandGitHub 878a67cdb6 Refactor owner scope across channels and fix default routing fallback (#1151)
* refactor: add explicit owner scope across channels

* fix: tighten routine owner target routing

* fix: address owner scope review feedback

* Fix owner-scope onboarding and event trigger isolation

* Tighten routing fallback and wizard owner validation

* fix: address owner-scope follow-up review

* fix: tighten owner-scope follow-up details

* fix: import Channel trait in telegram test

* fix: normalize http webhook sender ids

* fix: address remaining owner-scope review issues

* fix: reconcile config rebase fallout

* fix: reconcile extension manager rebase drift

* fix: address current copilot review regressions

* fix: restore clippy matrix after rebase
2026-03-16 13:31:03 -07:00

458 lines
14 KiB
Rust

//! 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<WasmChannelRuntime> {
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<WasmChannelRuntime>,
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);
}
}