Files
optimclaw/tests/relay_integration.rs
52ca9d6588 feat: receive relay events via webhook callbacks (#1254)
* 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]>
2026-03-19 11:53:46 -07:00

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());
}