mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* 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
458 lines
14 KiB
Rust
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);
|
|
}
|
|
}
|