mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat: Implement DM pairing for channels - Introduced a new pairing system to manage direct messages from unknown senders. - Added `PairingStore` to handle pending requests and allowlist management. - Implemented CLI commands for listing and approving pairing requests. - Updated Telegram channel to utilize the new pairing logic, including workspace paths for storing pairing data. - Enhanced WASM channel integration to support pairing functionality. This feature enhances security by requiring approval for unknown senders before they can interact with the agent. * Enhance Telegram channel support with media captioning and DM pairing features - Added support for media captions in Telegram messages, allowing for richer content handling. - Updated message processing to utilize either text or caption, improving message flexibility. - Enhanced DM pairing functionality to include approval and listing capabilities for direct messages. - Updated feature parity documentation to reflect new capabilities and improvements in Telegram integration. * Update README and BUILDING_CHANNELS documentation for Telegram channel integration - Enhanced README with instructions for building and running the Telegram channel, including a note on running `./scripts/build-all.sh` for full releases. - Added detailed steps in BUILDING_CHANNELS.md for building and deploying the Telegram channel, emphasizing the need to run `./channels-src/telegram/build.sh` before building the main crate to ensure updated WASM is included. - Updated CLI module to expose a new command for pairing with store functionality. * Implement build script for Telegram channel WASM and enhance pairing error handling - Added a new `build.rs` script to automate the compilation of the Telegram channel's WASM binary from source, ensuring reproducible builds and emphasizing supply chain security by preventing committed binaries. - Updated `BUILDING_CHANNELS.md` to reflect the new build process and the importance of not committing compiled binaries. - Enhanced error handling in the pairing approval process to include rate limiting for failed attempts, improving security and user feedback. * Remove Telegram channel WASM binary file as part of the build process cleanup, ensuring no committed binaries are present in the repository.
456 lines
14 KiB
Rust
456 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::pairing::PairingStore;
|
|
use ironclaw::channels::wasm::{
|
|
ChannelCapabilities, EmitRateLimitConfig, PreparedChannelModule, RegisteredEndpoint,
|
|
WasmChannel, WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
|
};
|
|
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,
|
|
"{}".to_string(),
|
|
Arc::new(PairingStore::new()),
|
|
)
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|