mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat: receive relay events via webhook callbacks instead of SSE Replace the SSE pull model with push-based webhook callbacks from channel-relay. Eliminates the reconnect loop, stream token auth, and SSE parser — events arrive via HTTP POST to /relay/events. - Add webhook handler with HMAC signature verification - Simplify RelayChannel to use mpsc from webhook handler - Remove SSE connect/reconnect/parse logic from RelayClient - Add register_callback() to RelayClient for callback URL registration - Update activation flow to create event channel and register callback - Wire relay webhook endpoint into web gateway * fix: address review feedback on webhook callback PR - Return 503 when relay event channel is full/closed (enables retry) - Reject malformed timestamps with 400 instead of proceeding - Allow relay activation without settings store (no-store/ephemeral mode) - Check installed_relay_extensions set in is_relay_channel for no-db mode - Fix staging test constructors for new RelayChannel signature * security: adapt relay client to new channel-relay auth model Adapts the relay integration to the hardened channel-relay security model: - Switch from X-API-Key header to Authorization: Bearer sk-agent-* for all relay API calls (chat-api token verification) - Remove register_callback() — PUT /callbacks endpoint removed - Remove event_callback_url from initiate_oauth() — parameter removed - Make signing_secret a required field in RelayConfig (new env var: CHANNEL_RELAY_SIGNING_SECRET) - Update integration tests for Bearer auth and removed endpoints Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: use server-side approval tokens, remove caller-supplied routing - Approval flow now calls POST /approvals to register server-side record, then embeds only the opaque approval_token in button value - Remove instance_id parameter from proxy_provider() — channel-relay no longer accepts it (uses verified identity) - Remove instance_id and user_id from initiate_oauth() — channel-relay derives them from the Bearer token - Add create_approval() to RelayClient Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: pass webhook_url during OAuth so callback_url is set on connection The channel-relay OAuth flow now accepts webhook_url to set the callback_url during connection creation. IronClaw computes its webhook URL from callback_base + webhook_path and passes it during initiate_oauth. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: remove webhook_url from OAuth initiation Channel-relay now derives the callback URL from chat-api's instance_url. IronClaw no longer supplies webhook_url during OAuth — the relay is the authority on where events get delivered. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: cargo fmt Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: remove all URL params from OAuth initiation IronClaw no longer supplies any URLs to channel-relay. The relay derives all URLs from the trusted instance_url in chat-api. initiate_oauth() takes no parameters. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: restore CSRF nonce for OAuth callback validation Re-add nonce generation and secret storage in auth_channel_relay. The nonce is passed to channel-relay as state_nonce param (not a URL). Channel-relay embeds it in the signed state and appends it to the redirect URL so IronClaw's callback handler can validate and activate. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: per-instance callback signing secrets relay_signing_secret() now prefers OPENCLAW_GATEWAY_TOKEN (per-instance) over the shared CHANNEL_RELAY_SIGNING_SECRET. A compromised instance can no longer forge callbacks to other instances on the same relay. CHANNEL_RELAY_SIGNING_SECRET is now optional in RelayConfig. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: clean per-instance callback secrets, no shared secrets, no fallbacks Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: pass team_id to get_signing_secret for workspace-scoped lookup Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: remove sender_id from create_approval — relay derives it Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: remove stale relay sender_id validation * fix: harden relay webhook activation lifecycle --------- Co-authored-by: Pierre <[email protected]> Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
216 lines
7.1 KiB
Rust
216 lines
7.1 KiB
Rust
//! Integration tests for the channel-relay client and channel.
|
|
//!
|
|
//! Uses real HTTP servers on random ports (no mock framework).
|
|
|
|
use axum::{
|
|
Json, Router,
|
|
extract::Query,
|
|
routing::{get, post},
|
|
};
|
|
use ironclaw::channels::relay::client::{ChannelEvent, RelayClient};
|
|
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")
|
|
}
|
|
|
|
// ── Signing secret fetch ─────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn test_get_signing_secret_returns_decoded_bytes() {
|
|
let secret_hex = hex::encode([1u8; 32]);
|
|
let secret_hex_clone = secret_hex.clone();
|
|
let app = Router::new().route(
|
|
"/relay/signing-secret",
|
|
get(move || {
|
|
let s = secret_hex_clone.clone();
|
|
async move { Json(serde_json::json!({"signing_secret": s})) }
|
|
}),
|
|
);
|
|
|
|
let base_url = start_server(app).await;
|
|
let client = test_client(&base_url);
|
|
|
|
let secret = client.get_signing_secret("T123").await.unwrap();
|
|
assert_eq!(secret, vec![1u8; 32]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_signing_secret_404_returns_error() {
|
|
let app = Router::new().route(
|
|
"/relay/signing-secret",
|
|
get(|| async { (axum::http::StatusCode::NOT_FOUND, "not found") }),
|
|
);
|
|
|
|
let base_url = start_server(app).await;
|
|
let client = test_client(&base_url);
|
|
|
|
let result = client.get_signing_secret("T123").await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_signing_secret_invalid_hex_returns_protocol_error() {
|
|
let app = Router::new().route(
|
|
"/relay/signing-secret",
|
|
get(|| async { Json(serde_json::json!({"signing_secret": "not-hex"})) }),
|
|
);
|
|
|
|
let base_url = start_server(app).await;
|
|
let client = test_client(&base_url);
|
|
|
|
let err = client
|
|
.get_signing_secret("T123")
|
|
.await
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(err.contains("invalid signing_secret hex"), "got: {err}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_signing_secret_wrong_length_returns_protocol_error() {
|
|
let short_secret_hex = hex::encode([7u8; 31]);
|
|
let app = Router::new().route(
|
|
"/relay/signing-secret",
|
|
get(move || {
|
|
let s = short_secret_hex.clone();
|
|
async move { Json(serde_json::json!({"signing_secret": s})) }
|
|
}),
|
|
);
|
|
|
|
let base_url = start_server(app).await;
|
|
let client = test_client(&base_url);
|
|
|
|
let err = client
|
|
.get_signing_secret("T123")
|
|
.await
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(err.contains("expected 32 bytes"), "got: {err}");
|
|
}
|
|
|
|
// ── 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)
|
|
.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);
|
|
}
|
|
|
|
// ── Bearer token auth ────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn test_bearer_token_sent_in_header() {
|
|
let app = Router::new().route(
|
|
"/connections",
|
|
get(|headers: axum::http::HeaderMap| async move {
|
|
let auth = headers
|
|
.get("authorization")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
assert_eq!(auth, "Bearer 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());
|
|
}
|
|
|
|
// ── Channel event field validation ──────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_channel_event_missing_fields_detected() {
|
|
// 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());
|
|
}
|