mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat: add channel-relay integration for Slack (#790)
* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787) GitHub Actions step-level `if:` doesn't have access to `secrets` context. Replace `if: secrets.X != ''` with `continue-on-error: true` and let the Set token step handle the fallback. Co-authored-by: Claude Sonnet 4.6 <[email protected]> * feat: add channel-relay integration for Slack via external relay service - Add RelayChannel and RelayClient for connecting to channel-relay SSE streams - Add RelayConfig with env-based configuration (CHANNEL_RELAY_URL, CHANNEL_RELAY_API_KEY) - Add channel-relay extension lifecycle: install, OAuth auth, activate with hot-add - Add proxy message sending through channel-relay for Slack chat.postMessage - Add extension registry entry for Slack relay with OAuth auth hint - Add relay integration test with mock SSE server - Wire relay channel into app startup with reconnect on stored credentials - Add AuthRequired extension error variant for cleaner auth flow detection [skip-regression-check] * chore: apply cargo fmt * fix: remove remaining Telegram test references in relay channel * fix: address PR #790 review feedback — parser handle leak, CSRF, circuit breaker - Fix parser handle leak on reconnect by sharing Arc<RwLock> instead of creating a local copy in start() (shutdown now aborts the correct task) - Add CSRF state nonce to OAuth flow: generate in auth_channel_relay, validate in slack_relay_oauth_callback_handler, one-time use - Remove dead proxy_slack method, update integration test to use proxy_provider - Add reconnect circuit breaker (max_consecutive_failures, default 50) - Fix stale docs (Telegram refs), extract event_types constants --------- Co-authored-by: Henry Park <[email protected]> Co-authored-by: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
co-authored by
Henry Park
Claude Sonnet 4.6
parent
3a841b30d8
commit
b0214fef41
@@ -0,0 +1,323 @@
|
||||
//! Integration tests for the channel-relay client and channel.
|
||||
//!
|
||||
//! Uses real HTTP servers on random ports (no mock framework).
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::Query,
|
||||
http::StatusCode,
|
||||
response::sse::{Event, KeepAlive, Sse},
|
||||
routing::{get, post},
|
||||
};
|
||||
use futures::stream;
|
||||
use ironclaw::channels::relay::client::{RelayClient, RelayError};
|
||||
use secrecy::SecretString;
|
||||
use serde::Deserialize;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
/// Start an axum server on a random port, returning the base URL.
|
||||
async fn start_server(app: Router) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
format!("http://{}", addr)
|
||||
}
|
||||
|
||||
fn test_client(base_url: &str) -> RelayClient {
|
||||
RelayClient::new(
|
||||
base_url.to_string(),
|
||||
SecretString::from("test-api-key".to_string()),
|
||||
5,
|
||||
)
|
||||
.expect("client build")
|
||||
}
|
||||
|
||||
// ── SSE stream mock ─────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sse_stream_receives_events() {
|
||||
let app = Router::new().route(
|
||||
"/stream",
|
||||
get(
|
||||
|Query(params): Query<std::collections::HashMap<String, String>>| async move {
|
||||
// Verify token is passed
|
||||
assert!(params.contains_key("token"));
|
||||
|
||||
let events = vec![
|
||||
Ok::<_, Infallible>(
|
||||
Event::default().event("message").data(
|
||||
serde_json::json!({
|
||||
"event_type": "message",
|
||||
"provider": "slack",
|
||||
"provider_scope": "T123",
|
||||
"channel_id": "C456",
|
||||
"sender_id": "U789",
|
||||
"content": "hello world"
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
),
|
||||
Ok(Event::default().event("message").data(
|
||||
serde_json::json!({
|
||||
"event_type": "direct_message",
|
||||
"provider": "slack",
|
||||
"provider_scope": "T123",
|
||||
"channel_id": "D001",
|
||||
"sender_id": "U789",
|
||||
"content": "dm text"
|
||||
})
|
||||
.to_string(),
|
||||
)),
|
||||
];
|
||||
|
||||
Sse::new(stream::iter(events)).keep_alive(KeepAlive::default())
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
let base_url = start_server(app).await;
|
||||
let client = test_client(&base_url);
|
||||
|
||||
let (mut event_stream, handle) = client.connect_stream("test-token", 30).await.unwrap();
|
||||
|
||||
use futures::StreamExt;
|
||||
let first = event_stream.next().await.expect("first event");
|
||||
assert_eq!(first.event_type, "message");
|
||||
assert_eq!(first.text(), "hello world");
|
||||
assert_eq!(first.team_id(), "T123");
|
||||
|
||||
let second = event_stream.next().await.expect("second event");
|
||||
assert_eq!(second.event_type, "direct_message");
|
||||
assert_eq!(second.text(), "dm text");
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
// ── Token renewal flow ──────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_token_expired_returns_error() {
|
||||
let app = Router::new().route("/stream", get(|| async { StatusCode::UNAUTHORIZED }));
|
||||
|
||||
let base_url = start_server(app).await;
|
||||
let client = test_client(&base_url);
|
||||
|
||||
match client.connect_stream("expired-token", 30).await {
|
||||
Err(RelayError::TokenExpired) => {} // expected
|
||||
Err(other) => panic!("expected TokenExpired, got: {other}"),
|
||||
Ok(_) => panic!("expected error, got Ok"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_token_renewal() {
|
||||
let call_count = std::sync::Arc::new(AtomicUsize::new(0));
|
||||
let call_count_clone = call_count.clone();
|
||||
|
||||
let app = Router::new().route(
|
||||
"/stream/renew",
|
||||
post(move |Json(body): Json<serde_json::Value>| {
|
||||
let count = call_count_clone.clone();
|
||||
async move {
|
||||
count.fetch_add(1, Ordering::SeqCst);
|
||||
assert!(body.get("instance_id").is_some());
|
||||
assert!(body.get("user_id").is_some());
|
||||
Json(serde_json::json!({
|
||||
"stream_token": "renewed-token-123"
|
||||
}))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let base_url = start_server(app).await;
|
||||
let client = test_client(&base_url);
|
||||
|
||||
let new_token = client.renew_token("inst-1", "user-1").await.unwrap();
|
||||
assert_eq!(new_token, "renewed-token-123");
|
||||
assert_eq!(call_count.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
// ── Proxy call ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProxyQuery {
|
||||
team_id: String,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_proxy_provider_sends_correct_payload() {
|
||||
let app = Router::new().route(
|
||||
"/proxy/slack/chat.postMessage",
|
||||
post(
|
||||
|Query(q): Query<ProxyQuery>, Json(body): Json<serde_json::Value>| async move {
|
||||
assert_eq!(q.team_id, "T123");
|
||||
assert_eq!(body["channel"], "C456");
|
||||
assert_eq!(body["text"], "Hello from test");
|
||||
Json(serde_json::json!({"ok": true}))
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
let base_url = start_server(app).await;
|
||||
let client = test_client(&base_url);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"channel": "C456",
|
||||
"text": "Hello from test",
|
||||
});
|
||||
let resp = client
|
||||
.proxy_provider("slack", "T123", "chat.postMessage", body, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp["ok"], true);
|
||||
}
|
||||
|
||||
// ── List connections ────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_connections() {
|
||||
let app = Router::new().route(
|
||||
"/connections",
|
||||
get(|| async {
|
||||
Json(serde_json::json!([
|
||||
{"provider": "slack", "team_id": "T123", "team_name": "Test Team", "connected": true},
|
||||
{"provider": "slack", "team_id": "T456", "team_name": "Other", "connected": false},
|
||||
]))
|
||||
}),
|
||||
);
|
||||
|
||||
let base_url = start_server(app).await;
|
||||
let client = test_client(&base_url);
|
||||
|
||||
let conns = client.list_connections("inst-1").await.unwrap();
|
||||
assert_eq!(conns.len(), 2);
|
||||
assert!(conns[0].connected);
|
||||
assert!(!conns[1].connected);
|
||||
}
|
||||
|
||||
// ── API key header ──────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_api_key_sent_in_header() {
|
||||
let app = Router::new().route(
|
||||
"/connections",
|
||||
get(|headers: axum::http::HeaderMap| async move {
|
||||
let key = headers
|
||||
.get("X-API-Key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
assert_eq!(key, "test-api-key");
|
||||
Json(serde_json::json!([]))
|
||||
}),
|
||||
);
|
||||
|
||||
let base_url = start_server(app).await;
|
||||
let client = test_client(&base_url);
|
||||
let _ = client.list_connections("inst-1").await.unwrap();
|
||||
}
|
||||
|
||||
// ── Client builder error propagation ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_relay_client_new_succeeds() {
|
||||
let client = RelayClient::new(
|
||||
"http://localhost:9999".to_string(),
|
||||
SecretString::from("key".to_string()),
|
||||
30,
|
||||
);
|
||||
assert!(client.is_ok());
|
||||
}
|
||||
|
||||
// ── SSE UTF-8 chunk boundary ────────────────────────────────────────────
|
||||
|
||||
/// Verify that multi-byte UTF-8 characters split across SSE chunks are
|
||||
/// not corrupted (no U+FFFD replacement characters).
|
||||
#[tokio::test]
|
||||
async fn test_sse_stream_preserves_multibyte_utf8_across_chunks() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
let sent = std::sync::Arc::new(AtomicBool::new(false));
|
||||
let sent_clone = sent.clone();
|
||||
|
||||
let app = Router::new().route(
|
||||
"/stream",
|
||||
get(move |_: Query<std::collections::HashMap<String, String>>| {
|
||||
let sent = sent_clone.clone();
|
||||
async move {
|
||||
// Build SSE payload with emoji that will be split mid-character
|
||||
let event_data = serde_json::json!({
|
||||
"event_type": "message",
|
||||
"provider": "slack",
|
||||
"provider_scope": "T1",
|
||||
"channel_id": "C1",
|
||||
"sender_id": "U1",
|
||||
"content": "hello 🦀 world"
|
||||
});
|
||||
let payload = format!("event: message\ndata: {}\n\n", event_data);
|
||||
let bytes = payload.into_bytes();
|
||||
|
||||
// Split in the middle of the 4-byte crab emoji
|
||||
let crab_pos = bytes
|
||||
.windows(4)
|
||||
.position(|w| w == [0xF0, 0x9F, 0xA6, 0x80])
|
||||
.unwrap();
|
||||
let split_at = crab_pos + 2;
|
||||
|
||||
let chunk1 = bytes[..split_at].to_vec();
|
||||
let chunk2 = bytes[split_at..].to_vec();
|
||||
|
||||
sent.store(true, Ordering::SeqCst);
|
||||
|
||||
let events = vec![
|
||||
Ok::<_, Infallible>(axum::body::Bytes::from(chunk1)),
|
||||
Ok(axum::body::Bytes::from(chunk2)),
|
||||
];
|
||||
|
||||
axum::response::Response::builder()
|
||||
.header("content-type", "text/event-stream")
|
||||
.body(axum::body::Body::from_stream(stream::iter(events)))
|
||||
.unwrap()
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let base_url = start_server(app).await;
|
||||
let client = test_client(&base_url);
|
||||
|
||||
let (mut event_stream, handle) = client.connect_stream("tok", 30).await.unwrap();
|
||||
|
||||
use futures::StreamExt;
|
||||
let event = event_stream.next().await.expect("should get event");
|
||||
assert_eq!(
|
||||
event.text(),
|
||||
"hello 🦀 world",
|
||||
"emoji should not be corrupted"
|
||||
);
|
||||
assert!(sent.load(Ordering::SeqCst));
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
// ── Channel event field validation ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_channel_event_missing_fields_detected() {
|
||||
use ironclaw::channels::relay::client::ChannelEvent;
|
||||
|
||||
// Event with empty sender_id should be detectable
|
||||
let json = r#"{"event_type": "message", "provider_scope": "T1", "channel_id": "C1", "sender_id": "", "content": "test"}"#;
|
||||
let event: ChannelEvent = serde_json::from_str(json).unwrap();
|
||||
assert!(event.sender_id.is_empty());
|
||||
|
||||
// Event with all fields present
|
||||
let json = r#"{"event_type": "message", "provider_scope": "T1", "channel_id": "C1", "sender_id": "U1", "content": "test"}"#;
|
||||
let event: ChannelEvent = serde_json::from_str(json).unwrap();
|
||||
assert!(!event.sender_id.is_empty());
|
||||
assert!(!event.channel_id.is_empty());
|
||||
assert!(!event.provider_scope.is_empty());
|
||||
}
|
||||
Reference in New Issue
Block a user