mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +00:00
Merge remote-tracking branch 'origin/staging' into refactor/architectural-hardening
# Conflicts: # src/agent/routine.rs
This commit is contained in:
@@ -0,0 +1,509 @@
|
||||
//! Tests for batch loading routine concurrent counts (N+1 query fix).
|
||||
//!
|
||||
//! Verifies:
|
||||
//! 1. Batch query returns correct counts for multiple routines
|
||||
//! 2. Concurrent limit enforcement uses batch counts correctly
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use ironclaw::agent::routine::{
|
||||
Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger,
|
||||
};
|
||||
use ironclaw::db::Database;
|
||||
|
||||
async fn create_test_db() -> (Arc<dyn Database>, tempfile::TempDir) {
|
||||
use ironclaw::db::libsql::LibSqlBackend;
|
||||
|
||||
let temp_dir = tempfile::tempdir().expect("tempdir"); // safety: test-only
|
||||
let db_path = temp_dir.path().join("test.db");
|
||||
let backend = LibSqlBackend::new_local(&db_path)
|
||||
.await
|
||||
.expect("LibSqlBackend"); // safety: test-only
|
||||
backend.run_migrations().await.expect("migrations"); // safety: test-only
|
||||
let db: Arc<dyn Database> = Arc::new(backend);
|
||||
(db, temp_dir)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 1: Batch query returns correct counts for multiple routines
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_query_empty_list() {
|
||||
let (db, _tmp) = create_test_db().await;
|
||||
let counts = db
|
||||
.count_running_routine_runs_batch(&[])
|
||||
.await
|
||||
.expect("batch query should not fail"); // safety: test-only
|
||||
assert!(counts.is_empty(), "Empty input should return empty map"); // safety: test-only
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_query_single_routine() {
|
||||
let (db, _tmp) = create_test_db().await;
|
||||
let routine_id = Uuid::new_v4();
|
||||
|
||||
// Create routine
|
||||
let routine = Routine {
|
||||
id: routine_id,
|
||||
name: "test-routine".to_string(),
|
||||
description: "Test".to_string(),
|
||||
user_id: "default".to_string(),
|
||||
enabled: true,
|
||||
trigger: Trigger::Cron {
|
||||
schedule: "* * * * *".to_string(),
|
||||
timezone: None,
|
||||
},
|
||||
action: RoutineAction::Lightweight {
|
||||
prompt: "test".to_string(),
|
||||
context_paths: vec![],
|
||||
max_tokens: 1000,
|
||||
use_tools: false,
|
||||
max_tool_rounds: 3,
|
||||
},
|
||||
guardrails: RoutineGuardrails {
|
||||
cooldown: std::time::Duration::from_secs(0),
|
||||
max_concurrent: 5,
|
||||
dedup_window: None,
|
||||
},
|
||||
notify: Default::default(),
|
||||
last_run_at: None,
|
||||
next_fire_at: None,
|
||||
run_count: 0,
|
||||
consecutive_failures: 0,
|
||||
state: serde_json::json!({}),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
db.create_routine(&routine).await.expect("create routine"); // safety: test-only
|
||||
|
||||
// Create 3 running runs
|
||||
for _ in 0..3 {
|
||||
let run = RoutineRun {
|
||||
id: Uuid::new_v4(),
|
||||
routine_id,
|
||||
trigger_type: "cron".to_string(),
|
||||
trigger_detail: None,
|
||||
started_at: Utc::now(),
|
||||
completed_at: None,
|
||||
status: RunStatus::Running,
|
||||
result_summary: None,
|
||||
tokens_used: None,
|
||||
job_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
db.create_routine_run(&run).await.expect("create run"); // safety: test-only
|
||||
}
|
||||
|
||||
// Batch query for single routine
|
||||
let counts = db
|
||||
.count_running_routine_runs_batch(&[routine_id])
|
||||
.await
|
||||
.expect("batch query should work"); // safety: test-only
|
||||
|
||||
assert_eq!(counts.len(), 1, "Should return 1 routine"); // safety: test-only
|
||||
assert_eq!(counts[&routine_id], 3, "Should count 3 running runs"); // safety: test-only
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_query_multiple_routines_different_counts() {
|
||||
let (db, _tmp) = create_test_db().await;
|
||||
|
||||
let r1 = Uuid::new_v4();
|
||||
let r2 = Uuid::new_v4();
|
||||
let r3 = Uuid::new_v4();
|
||||
|
||||
// Create 3 routines
|
||||
for routine_id in [r1, r2, r3] {
|
||||
let routine = Routine {
|
||||
id: routine_id,
|
||||
name: format!("routine-{}", routine_id),
|
||||
description: "Test".to_string(),
|
||||
user_id: "default".to_string(),
|
||||
enabled: true,
|
||||
trigger: Trigger::Cron {
|
||||
schedule: "* * * * *".to_string(),
|
||||
timezone: None,
|
||||
},
|
||||
action: RoutineAction::Lightweight {
|
||||
prompt: "test".to_string(),
|
||||
context_paths: vec![],
|
||||
max_tokens: 1000,
|
||||
use_tools: false,
|
||||
max_tool_rounds: 3,
|
||||
},
|
||||
guardrails: RoutineGuardrails {
|
||||
cooldown: std::time::Duration::from_secs(0),
|
||||
max_concurrent: 5,
|
||||
dedup_window: None,
|
||||
},
|
||||
notify: Default::default(),
|
||||
last_run_at: None,
|
||||
next_fire_at: None,
|
||||
run_count: 0,
|
||||
consecutive_failures: 0,
|
||||
state: serde_json::json!({}),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
db.create_routine(&routine).await.expect("create routine"); // safety: test-only
|
||||
}
|
||||
|
||||
// r1: 2 running
|
||||
for _ in 0..2 {
|
||||
let run = RoutineRun {
|
||||
id: Uuid::new_v4(),
|
||||
routine_id: r1,
|
||||
trigger_type: "cron".to_string(),
|
||||
trigger_detail: None,
|
||||
started_at: Utc::now(),
|
||||
completed_at: None,
|
||||
status: RunStatus::Running,
|
||||
result_summary: None,
|
||||
tokens_used: None,
|
||||
job_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
db.create_routine_run(&run).await.expect("create run"); // safety: test-only
|
||||
}
|
||||
|
||||
// r2: 1 running
|
||||
let run = RoutineRun {
|
||||
id: Uuid::new_v4(),
|
||||
routine_id: r2,
|
||||
trigger_type: "cron".to_string(),
|
||||
trigger_detail: None,
|
||||
started_at: Utc::now(),
|
||||
completed_at: None,
|
||||
status: RunStatus::Running,
|
||||
result_summary: None,
|
||||
tokens_used: None,
|
||||
job_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
db.create_routine_run(&run).await.expect("create run"); // safety: test-only
|
||||
|
||||
// r3: 0 running (but has 1 Ok result)
|
||||
let run = RoutineRun {
|
||||
id: Uuid::new_v4(),
|
||||
routine_id: r3,
|
||||
trigger_type: "cron".to_string(),
|
||||
trigger_detail: None,
|
||||
started_at: Utc::now(),
|
||||
completed_at: Some(Utc::now()),
|
||||
status: RunStatus::Ok,
|
||||
result_summary: None,
|
||||
tokens_used: None,
|
||||
job_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
db.create_routine_run(&run).await.expect("create run"); // safety: test-only
|
||||
|
||||
// Single batch query for all 3
|
||||
let counts = db
|
||||
.count_running_routine_runs_batch(&[r1, r2, r3])
|
||||
.await
|
||||
.expect("batch query should work"); // safety: test-only
|
||||
|
||||
assert_eq!(counts.len(), 3, "Should return 3 routines"); // safety: test-only
|
||||
assert_eq!(counts[&r1], 2, "r1 should have 2 running"); // safety: test-only
|
||||
assert_eq!(counts[&r2], 1, "r2 should have 1 running"); // safety: test-only
|
||||
assert_eq!( // safety: test-only
|
||||
counts[&r3], 0,
|
||||
"r3 should have 0 running (Ok status is not running)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_query_missing_routines_default_to_zero() {
|
||||
let (db, _tmp) = create_test_db().await;
|
||||
|
||||
let r1 = Uuid::new_v4();
|
||||
let r2 = Uuid::new_v4();
|
||||
let r3 = Uuid::new_v4(); // This one won't exist
|
||||
|
||||
// Only create r1
|
||||
let routine = Routine {
|
||||
id: r1,
|
||||
name: "routine-1".to_string(),
|
||||
description: "Test".to_string(),
|
||||
user_id: "default".to_string(),
|
||||
enabled: true,
|
||||
trigger: Trigger::Cron {
|
||||
schedule: "* * * * *".to_string(),
|
||||
timezone: None,
|
||||
},
|
||||
action: RoutineAction::Lightweight {
|
||||
prompt: "test".to_string(),
|
||||
context_paths: vec![],
|
||||
max_tokens: 1000,
|
||||
use_tools: false,
|
||||
max_tool_rounds: 3,
|
||||
},
|
||||
guardrails: RoutineGuardrails {
|
||||
cooldown: std::time::Duration::from_secs(0),
|
||||
max_concurrent: 5,
|
||||
dedup_window: None,
|
||||
},
|
||||
notify: Default::default(),
|
||||
last_run_at: None,
|
||||
next_fire_at: None,
|
||||
run_count: 0,
|
||||
consecutive_failures: 0,
|
||||
state: serde_json::json!({}),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
db.create_routine(&routine).await.expect("create routine"); // safety: test-only
|
||||
|
||||
// r1 has 1 running
|
||||
let run = RoutineRun {
|
||||
id: Uuid::new_v4(),
|
||||
routine_id: r1,
|
||||
trigger_type: "cron".to_string(),
|
||||
trigger_detail: None,
|
||||
started_at: Utc::now(),
|
||||
completed_at: None,
|
||||
status: RunStatus::Running,
|
||||
result_summary: None,
|
||||
tokens_used: None,
|
||||
job_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
db.create_routine_run(&run).await.expect("create run"); // safety: test-only
|
||||
|
||||
// Query for r1, r2 (doesn't exist), r3 (doesn't exist)
|
||||
let counts = db
|
||||
.count_running_routine_runs_batch(&[r1, r2, r3])
|
||||
.await
|
||||
.expect("batch query should work"); // safety: test-only
|
||||
|
||||
assert_eq!(counts.len(), 3, "Should have all 3 routine IDs"); // safety: test-only
|
||||
assert_eq!(counts[&r1], 1, "r1 should have 1 running"); // safety: test-only
|
||||
assert_eq!(counts[&r2], 0, "r2 should default to 0"); // safety: test-only
|
||||
assert_eq!(counts[&r3], 0, "r3 should default to 0"); // safety: test-only
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_query_only_counts_running_status() {
|
||||
let (db, _tmp) = create_test_db().await;
|
||||
let routine_id = Uuid::new_v4();
|
||||
|
||||
// Create routine
|
||||
let routine = Routine {
|
||||
id: routine_id,
|
||||
name: "test-routine".to_string(),
|
||||
description: "Test".to_string(),
|
||||
user_id: "default".to_string(),
|
||||
enabled: true,
|
||||
trigger: Trigger::Cron {
|
||||
schedule: "* * * * *".to_string(),
|
||||
timezone: None,
|
||||
},
|
||||
action: RoutineAction::Lightweight {
|
||||
prompt: "test".to_string(),
|
||||
context_paths: vec![],
|
||||
max_tokens: 1000,
|
||||
use_tools: false,
|
||||
max_tool_rounds: 3,
|
||||
},
|
||||
guardrails: RoutineGuardrails {
|
||||
cooldown: std::time::Duration::from_secs(0),
|
||||
max_concurrent: 5,
|
||||
dedup_window: None,
|
||||
},
|
||||
notify: Default::default(),
|
||||
last_run_at: None,
|
||||
next_fire_at: None,
|
||||
run_count: 0,
|
||||
consecutive_failures: 0,
|
||||
state: serde_json::json!({}),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
db.create_routine(&routine).await.expect("create routine"); // safety: test-only
|
||||
|
||||
// Create 5 runs with mixed statuses
|
||||
let statuses = [
|
||||
RunStatus::Running,
|
||||
RunStatus::Running,
|
||||
RunStatus::Ok,
|
||||
RunStatus::Failed,
|
||||
RunStatus::Attention,
|
||||
];
|
||||
|
||||
for status in statuses.iter() {
|
||||
let run = RoutineRun {
|
||||
id: Uuid::new_v4(),
|
||||
routine_id,
|
||||
trigger_type: "cron".to_string(),
|
||||
trigger_detail: None,
|
||||
started_at: Utc::now(),
|
||||
completed_at: Some(Utc::now()),
|
||||
status: *status,
|
||||
result_summary: None,
|
||||
tokens_used: None,
|
||||
job_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
db.create_routine_run(&run).await.expect("create run"); // safety: test-only
|
||||
}
|
||||
|
||||
// Batch query should only count Running status
|
||||
let counts = db
|
||||
.count_running_routine_runs_batch(&[routine_id])
|
||||
.await
|
||||
.expect("batch query should work"); // safety: test-only
|
||||
|
||||
assert_eq!( // safety: test-only
|
||||
counts[&routine_id], 2,
|
||||
"Should only count 2 Running status runs"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 2: Concurrent limit enforcement uses batch counts
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_limit_enforcement_with_batch_counts() {
|
||||
let (db, _tmp) = create_test_db().await;
|
||||
|
||||
let r1 = Uuid::new_v4();
|
||||
let r2 = Uuid::new_v4();
|
||||
|
||||
// Create 2 routines with max_concurrent=1 (r1) and max_concurrent=2 (r2)
|
||||
for (routine_id, max_concurrent) in [(r1, 1), (r2, 2)] {
|
||||
let routine = Routine {
|
||||
id: routine_id,
|
||||
name: format!("routine-{}", routine_id),
|
||||
description: "Test".to_string(),
|
||||
user_id: "default".to_string(),
|
||||
enabled: true,
|
||||
trigger: Trigger::Cron {
|
||||
schedule: "* * * * *".to_string(),
|
||||
timezone: None,
|
||||
},
|
||||
action: RoutineAction::Lightweight {
|
||||
prompt: "test".to_string(),
|
||||
context_paths: vec![],
|
||||
max_tokens: 1000,
|
||||
use_tools: false,
|
||||
max_tool_rounds: 3,
|
||||
},
|
||||
guardrails: RoutineGuardrails {
|
||||
cooldown: std::time::Duration::from_secs(0),
|
||||
max_concurrent,
|
||||
dedup_window: None,
|
||||
},
|
||||
notify: Default::default(),
|
||||
last_run_at: None,
|
||||
next_fire_at: None,
|
||||
run_count: 0,
|
||||
consecutive_failures: 0,
|
||||
state: serde_json::json!({}),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
db.create_routine(&routine).await.expect("create routine"); // safety: test-only
|
||||
}
|
||||
|
||||
// r1: create 1 running run (will hit max_concurrent=1)
|
||||
let run = RoutineRun {
|
||||
id: Uuid::new_v4(),
|
||||
routine_id: r1,
|
||||
trigger_type: "cron".to_string(),
|
||||
trigger_detail: None,
|
||||
started_at: Utc::now(),
|
||||
completed_at: None,
|
||||
status: RunStatus::Running,
|
||||
result_summary: None,
|
||||
tokens_used: None,
|
||||
job_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
db.create_routine_run(&run).await.expect("create run"); // safety: test-only
|
||||
|
||||
// r2: create 2 running runs (will hit max_concurrent=2)
|
||||
for _ in 0..2 {
|
||||
let run = RoutineRun {
|
||||
id: Uuid::new_v4(),
|
||||
routine_id: r2,
|
||||
trigger_type: "cron".to_string(),
|
||||
trigger_detail: None,
|
||||
started_at: Utc::now(),
|
||||
completed_at: None,
|
||||
status: RunStatus::Running,
|
||||
result_summary: None,
|
||||
tokens_used: None,
|
||||
job_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
db.create_routine_run(&run).await.expect("create run"); // safety: test-only
|
||||
}
|
||||
|
||||
// Batch query should return correct counts
|
||||
let counts = db
|
||||
.count_running_routine_runs_batch(&[r1, r2])
|
||||
.await
|
||||
.expect("batch query should work"); // safety: test-only
|
||||
|
||||
// Verify counts match the limits
|
||||
assert_eq!( // safety: test-only
|
||||
counts[&r1], 1,
|
||||
"r1 should have 1 running (at max_concurrent=1)"
|
||||
);
|
||||
assert_eq!( // safety: test-only
|
||||
counts[&r2], 2,
|
||||
"r2 should have 2 running (at max_concurrent=2)"
|
||||
);
|
||||
|
||||
// Now verify the limit enforcement logic
|
||||
let r1_routine = db
|
||||
.get_routine(r1)
|
||||
.await
|
||||
.expect("get routine") // safety: test-only
|
||||
.expect("routine exists"); // safety: test-only
|
||||
let r2_routine = db
|
||||
.get_routine(r2)
|
||||
.await
|
||||
.expect("get routine") // safety: test-only
|
||||
.expect("routine exists"); // safety: test-only
|
||||
|
||||
let r1_at_limit = counts[&r1] >= r1_routine.guardrails.max_concurrent as i64;
|
||||
let r2_at_limit = counts[&r2] >= r2_routine.guardrails.max_concurrent as i64;
|
||||
|
||||
assert!(r1_at_limit, "r1 should be detected as at limit"); // safety: test-only
|
||||
assert!(r2_at_limit, "r2 should be detected as at limit"); // safety: test-only
|
||||
|
||||
// If we add one more run to r2, it should exceed limit
|
||||
let run = RoutineRun {
|
||||
id: Uuid::new_v4(),
|
||||
routine_id: r2,
|
||||
trigger_type: "cron".to_string(),
|
||||
trigger_detail: None,
|
||||
started_at: Utc::now(),
|
||||
completed_at: None,
|
||||
status: RunStatus::Running,
|
||||
result_summary: None,
|
||||
tokens_used: None,
|
||||
job_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
db.create_routine_run(&run).await.expect("create run"); // safety: test-only
|
||||
|
||||
// Re-query to get updated counts
|
||||
let counts = db
|
||||
.count_running_routine_runs_batch(&[r1, r2])
|
||||
.await
|
||||
.expect("batch query should work"); // safety: test-only
|
||||
|
||||
let r2_exceeded_limit = counts[&r2] > r2_routine.guardrails.max_concurrent as i64;
|
||||
assert!(r2_exceeded_limit, "r2 should have exceeded its limit"); // safety: test-only
|
||||
}
|
||||
}
|
||||
+92
-1
@@ -160,7 +160,7 @@ async def ironclaw_server(ironclaw_binary, mock_llm_server, wasm_tools_dir):
|
||||
"LIBSQL_PATH": os.path.join(_DB_TMPDIR.name, "e2e.db"),
|
||||
"SANDBOX_ENABLED": "false",
|
||||
"SKILLS_ENABLED": "true",
|
||||
"ROUTINES_ENABLED": "false",
|
||||
"ROUTINES_ENABLED": "true",
|
||||
"HEARTBEAT_ENABLED": "false",
|
||||
"EMBEDDING_ENABLED": "false",
|
||||
# WASM tool/channel support
|
||||
@@ -220,6 +220,97 @@ async def ironclaw_server(ironclaw_binary, mock_llm_server, wasm_tools_dir):
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def ironclaw_server_with_webhook_secret(ironclaw_binary, mock_llm_server, wasm_tools_dir):
|
||||
"""Start ironclaw with HTTP_WEBHOOK_SECRET configured for webhook tests.
|
||||
|
||||
Yields a dict with:
|
||||
- 'url': base URL of the gateway
|
||||
- 'secret': the webhook secret value
|
||||
"""
|
||||
gateway_port = _find_free_port()
|
||||
webhook_secret = "test-webhook-secret-e2e-12345"
|
||||
env = {
|
||||
# Minimal env: PATH for process spawning, HOME for Rust/cargo defaults
|
||||
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
||||
"HOME": os.environ.get("HOME", "/tmp"),
|
||||
"RUST_LOG": "ironclaw=info",
|
||||
"RUST_BACKTRACE": "1",
|
||||
"GATEWAY_ENABLED": "true",
|
||||
"GATEWAY_HOST": "127.0.0.1",
|
||||
"GATEWAY_PORT": str(gateway_port),
|
||||
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
|
||||
"GATEWAY_USER_ID": "e2e-tester",
|
||||
"HTTP_WEBHOOK_SECRET": webhook_secret,
|
||||
"CLI_ENABLED": "false",
|
||||
"LLM_BACKEND": "openai_compatible",
|
||||
"LLM_BASE_URL": mock_llm_server,
|
||||
"LLM_MODEL": "mock-model",
|
||||
"DATABASE_BACKEND": "libsql",
|
||||
"LIBSQL_PATH": os.path.join(_DB_TMPDIR.name, "e2e-webhook.db"),
|
||||
"SANDBOX_ENABLED": "false",
|
||||
"SKILLS_ENABLED": "true",
|
||||
"ROUTINES_ENABLED": "false",
|
||||
"HEARTBEAT_ENABLED": "false",
|
||||
"EMBEDDING_ENABLED": "false",
|
||||
# WASM tool/channel support
|
||||
"WASM_ENABLED": "true",
|
||||
"WASM_TOOLS_DIR": wasm_tools_dir,
|
||||
"WASM_CHANNELS_DIR": _WASM_CHANNELS_TMPDIR.name,
|
||||
# Prevent onboarding wizard from triggering
|
||||
"ONBOARD_COMPLETED": "true",
|
||||
# Force gateway OAuth callback mode (non-loopback URL) and point
|
||||
# token exchange at mock_llm.py so OAuth tests work without Google.
|
||||
"IRONCLAW_OAUTH_CALLBACK_URL": "https://oauth.test.example/oauth/callback",
|
||||
"IRONCLAW_OAUTH_EXCHANGE_URL": mock_llm_server,
|
||||
}
|
||||
# Forward LLVM coverage instrumentation env vars when present
|
||||
COV_ENV_PREFIXES = ("CARGO_LLVM_COV", "LLVM_")
|
||||
COV_ENV_EXTRAS = ("CARGO_ENCODED_RUSTFLAGS", "CARGO_INCREMENTAL")
|
||||
for key, val in os.environ.items():
|
||||
if key.startswith(COV_ENV_PREFIXES) or key in COV_ENV_EXTRAS:
|
||||
env[key] = val
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
ironclaw_binary, "--no-onboard",
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
)
|
||||
base_url = f"http://127.0.0.1:{gateway_port}"
|
||||
try:
|
||||
await wait_for_ready(f"{base_url}/api/health", timeout=60)
|
||||
yield {
|
||||
"url": base_url,
|
||||
"secret": webhook_secret,
|
||||
}
|
||||
except TimeoutError:
|
||||
# Dump stderr so CI logs show why the server failed to start
|
||||
returncode = proc.returncode
|
||||
stderr_bytes = b""
|
||||
if proc.stderr:
|
||||
try:
|
||||
stderr_bytes = await asyncio.wait_for(proc.stderr.read(8192), timeout=2)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
pass
|
||||
stderr_text = stderr_bytes.decode("utf-8", errors="replace")
|
||||
proc.kill()
|
||||
pytest.fail(
|
||||
f"ironclaw server with webhook secret failed to start on port {gateway_port} "
|
||||
f"(returncode={returncode}).\nstderr:\n{stderr_text}"
|
||||
)
|
||||
finally:
|
||||
if proc.returncode is None:
|
||||
# Use SIGINT (not SIGTERM) so tokio's ctrl_c handler triggers a
|
||||
# graceful shutdown. This lets the LLVM coverage runtime run its
|
||||
# atexit handler and flush .profraw files for cargo-llvm-cov.
|
||||
proc.send_signal(signal.SIGINT)
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def browser(ironclaw_server):
|
||||
"""Session-scoped Playwright browser instance.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: ironclaw-e2e
|
||||
Version: 0.1.0
|
||||
Requires-Python: >=3.11
|
||||
Requires-Dist: pytest>=8.0
|
||||
Requires-Dist: pytest-asyncio>=0.23
|
||||
Requires-Dist: pytest-playwright>=0.5
|
||||
Requires-Dist: pytest-timeout>=2.3
|
||||
Requires-Dist: playwright>=1.40
|
||||
Requires-Dist: aiohttp>=3.9
|
||||
Requires-Dist: httpx>=0.27
|
||||
Provides-Extra: vision
|
||||
Requires-Dist: anthropic>=0.40; extra == "vision"
|
||||
@@ -0,0 +1,22 @@
|
||||
README.md
|
||||
pyproject.toml
|
||||
ironclaw_e2e.egg-info/PKG-INFO
|
||||
ironclaw_e2e.egg-info/SOURCES.txt
|
||||
ironclaw_e2e.egg-info/dependency_links.txt
|
||||
ironclaw_e2e.egg-info/requires.txt
|
||||
ironclaw_e2e.egg-info/top_level.txt
|
||||
scenarios/__init__.py
|
||||
scenarios/test_chat.py
|
||||
scenarios/test_connection.py
|
||||
scenarios/test_csp.py
|
||||
scenarios/test_extension_oauth.py
|
||||
scenarios/test_extensions.py
|
||||
scenarios/test_html_injection.py
|
||||
scenarios/test_oauth_credential_fallback.py
|
||||
scenarios/test_pairing.py
|
||||
scenarios/test_routine_oauth_credential_injection.py
|
||||
scenarios/test_skills.py
|
||||
scenarios/test_sse_reconnect.py
|
||||
scenarios/test_tool_approval.py
|
||||
scenarios/test_tool_execution.py
|
||||
scenarios/test_wasm_lifecycle.py
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.23
|
||||
pytest-playwright>=0.5
|
||||
pytest-timeout>=2.3
|
||||
playwright>=1.40
|
||||
aiohttp>=3.9
|
||||
httpx>=0.27
|
||||
|
||||
[vision]
|
||||
anthropic>=0.40
|
||||
@@ -0,0 +1 @@
|
||||
scenarios
|
||||
@@ -225,6 +225,128 @@ async def models(_request: web.Request) -> web.Response:
|
||||
})
|
||||
|
||||
|
||||
# ── Mock MCP Server ──────────────────────────────────────────────────────────
|
||||
#
|
||||
# Simulates an MCP server that requires OAuth. Unauthenticated requests get
|
||||
# 401 + WWW-Authenticate (standard MCP flow) or 400 "Authorization header is
|
||||
# badly formatted" (GitHub-style). Authenticated requests return valid
|
||||
# JSON-RPC responses for initialize and tools/list.
|
||||
|
||||
|
||||
async def mcp_endpoint(request: web.Request) -> web.Response:
|
||||
"""Handle POST /mcp — JSON-RPC MCP endpoint requiring Bearer auth."""
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer ") or len(auth.split(" ", 1)[1].strip()) == 0:
|
||||
# Return 401 with WWW-Authenticate header for OAuth discovery
|
||||
resource_meta_url = f"http://127.0.0.1:{request.app['port']}/.well-known/oauth-protected-resource"
|
||||
return web.Response(
|
||||
status=401,
|
||||
headers={"WWW-Authenticate": f'Bearer resource_metadata="{resource_meta_url}"'},
|
||||
text="Unauthorized",
|
||||
)
|
||||
return await _mcp_handle_authed(request)
|
||||
|
||||
|
||||
async def mcp_endpoint_400(request: web.Request) -> web.Response:
|
||||
"""Handle POST /mcp-400 — MCP endpoint that returns 400 (GitHub-style).
|
||||
|
||||
Simulates GitHub's MCP server which returns 400 "Authorization header
|
||||
is badly formatted" instead of 401 when auth is missing or invalid.
|
||||
"""
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer ") or len(auth.split(" ", 1)[1].strip()) == 0:
|
||||
return web.Response(
|
||||
status=400,
|
||||
text="bad request: Authorization header is badly formatted",
|
||||
)
|
||||
return await _mcp_handle_authed(request)
|
||||
|
||||
|
||||
async def _mcp_handle_authed(request: web.Request) -> web.Response:
|
||||
"""Handle an authenticated MCP JSON-RPC request."""
|
||||
body = await request.json()
|
||||
method = body.get("method", "")
|
||||
req_id = body.get("id")
|
||||
|
||||
if method == "initialize":
|
||||
return web.json_response({
|
||||
"jsonrpc": "2.0", "id": req_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "mock-mcp", "version": "1.0.0"},
|
||||
},
|
||||
})
|
||||
if method == "notifications/initialized":
|
||||
return web.json_response({"jsonrpc": "2.0", "id": req_id, "result": {}})
|
||||
if method == "tools/list":
|
||||
return web.json_response({
|
||||
"jsonrpc": "2.0", "id": req_id,
|
||||
"result": {"tools": [{
|
||||
"name": "mock_search",
|
||||
"description": "A mock search tool for testing",
|
||||
"inputSchema": {"type": "object", "properties": {
|
||||
"query": {"type": "string"},
|
||||
}},
|
||||
}]},
|
||||
})
|
||||
return web.json_response({"jsonrpc": "2.0", "id": req_id, "error": {
|
||||
"code": -32601, "message": f"Method not found: {method}",
|
||||
}})
|
||||
|
||||
|
||||
async def mcp_protected_resource(request: web.Request) -> web.Response:
|
||||
"""GET /.well-known/oauth-protected-resource[/{path}] — RFC 9728 discovery.
|
||||
|
||||
Production code appends the MCP server path after the well-known suffix
|
||||
(e.g. /.well-known/oauth-protected-resource/mcp-400), so this handler
|
||||
accepts an optional tail and returns a resource matching the request.
|
||||
"""
|
||||
port = request.app["port"]
|
||||
tail = request.match_info.get("tail", "mcp")
|
||||
return web.json_response({
|
||||
"resource": f"http://127.0.0.1:{port}/{tail}",
|
||||
"authorization_servers": [f"http://127.0.0.1:{port}"],
|
||||
})
|
||||
|
||||
|
||||
async def mcp_auth_server_metadata(request: web.Request) -> web.Response:
|
||||
"""GET /.well-known/oauth-authorization-server[/{path}] — OAuth metadata."""
|
||||
port = request.app["port"]
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
return web.json_response({
|
||||
"issuer": base,
|
||||
"authorization_endpoint": f"{base}/oauth/authorize",
|
||||
"token_endpoint": f"{base}/oauth/token",
|
||||
"registration_endpoint": f"{base}/oauth/register",
|
||||
"scopes_supported": ["read", "write"],
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
})
|
||||
|
||||
|
||||
async def mcp_oauth_register(request: web.Request) -> web.Response:
|
||||
"""POST /oauth/register — Dynamic Client Registration."""
|
||||
body = await request.json()
|
||||
return web.json_response({
|
||||
"client_id": "mock-mcp-client-id",
|
||||
"client_name": body.get("client_name", "IronClaw"),
|
||||
"redirect_uris": body.get("redirect_uris", []),
|
||||
})
|
||||
|
||||
|
||||
async def mcp_oauth_token(request: web.Request) -> web.Response:
|
||||
"""POST /oauth/token — Token endpoint for MCP OAuth."""
|
||||
data = await request.post()
|
||||
code = data.get("code", "")
|
||||
return web.json_response({
|
||||
"access_token": f"mcp-token-{code}",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--port", type=int, default=0)
|
||||
@@ -236,6 +358,15 @@ def main():
|
||||
app.router.add_get("/v1/models", models)
|
||||
app.router.add_get("/models", models)
|
||||
app.router.add_post("/oauth/exchange", oauth_exchange)
|
||||
# Mock MCP server endpoints
|
||||
app.router.add_post("/mcp", mcp_endpoint)
|
||||
app.router.add_post("/mcp-400", mcp_endpoint_400)
|
||||
app.router.add_get("/.well-known/oauth-protected-resource", mcp_protected_resource)
|
||||
app.router.add_get("/.well-known/oauth-protected-resource/{tail:.*}", mcp_protected_resource)
|
||||
app.router.add_get("/.well-known/oauth-authorization-server", mcp_auth_server_metadata)
|
||||
app.router.add_get("/.well-known/oauth-authorization-server/{tail:.*}", mcp_auth_server_metadata)
|
||||
app.router.add_post("/oauth/register", mcp_oauth_register)
|
||||
app.router.add_post("/oauth/token", mcp_oauth_token)
|
||||
|
||||
async def start():
|
||||
runner = web.AppRunner(app)
|
||||
@@ -243,6 +374,7 @@ def main():
|
||||
site = web.TCPSite(runner, "127.0.0.1", args.port)
|
||||
await site.start()
|
||||
port = site._server.sockets[0].getsockname()[1]
|
||||
app["port"] = port # used by MCP handlers
|
||||
print(f"MOCK_LLM_PORT={port}", flush=True)
|
||||
await asyncio.Event().wait()
|
||||
|
||||
|
||||
@@ -74,3 +74,44 @@ async def test_empty_message_not_sent(page):
|
||||
await page.wait_for_timeout(2000)
|
||||
final_count = await page.locator(f"{SEL['message_user']}, {SEL['message_assistant']}").count()
|
||||
assert final_count == initial_count, "Empty message should not create new messages"
|
||||
|
||||
|
||||
async def test_copy_from_chat_forces_plain_text(page):
|
||||
"""Copying selected chat text should populate plain text clipboard data only."""
|
||||
await page.evaluate("addMessage('assistant', 'Copy me into Sheets')")
|
||||
|
||||
copied = await page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const content = Array.from(document.querySelectorAll('#chat-messages .message.assistant .message-content'))
|
||||
.find((el) => (el.textContent || '').includes('Copy me into Sheets'));
|
||||
if (!content) return {ok: false, reason: 'no content'};
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(content);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
|
||||
const store = {};
|
||||
const evt = new Event('copy', { bubbles: true, cancelable: true });
|
||||
evt.clipboardData = {
|
||||
clearData: () => { Object.keys(store).forEach((k) => delete store[k]); },
|
||||
setData: (t, v) => { store[t] = v; },
|
||||
getData: (t) => store[t] || '',
|
||||
};
|
||||
|
||||
content.dispatchEvent(evt);
|
||||
return {
|
||||
ok: true,
|
||||
defaultPrevented: evt.defaultPrevented,
|
||||
text: store['text/plain'] || '',
|
||||
html: store['text/html'] || '',
|
||||
};
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
assert copied["ok"], copied.get("reason", "copy setup failed")
|
||||
assert copied["defaultPrevented"] is True
|
||||
assert "Copy me into Sheets" in copied["text"]
|
||||
assert copied["html"] == ""
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
"""MCP server auth flow E2E tests.
|
||||
|
||||
Tests the full MCP server lifecycle: install MCP server (pointing at mock) ->
|
||||
activate triggers auth (401/400 -> AuthRequired -> OAuth URL) -> OAuth callback
|
||||
completes -> auth mode cleared (next message triggers LLM turn) -> MCP tools
|
||||
available.
|
||||
|
||||
Regression coverage for:
|
||||
- 400 "Authorization header is badly formatted" treated as auth-required
|
||||
- OAuth discovery via 401 + WWW-Authenticate header
|
||||
- clear_auth_mode after OAuth callback (user message not swallowed)
|
||||
- Token trimming (whitespace/newline in stored tokens)
|
||||
|
||||
The mock_llm.py serves a mock MCP server at /mcp with full OAuth discovery
|
||||
endpoints (.well-known/oauth-protected-resource, DCR, token exchange).
|
||||
"""
|
||||
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from helpers import SEL, api_get, api_post
|
||||
|
||||
|
||||
def _extract_state(auth_url: str) -> str:
|
||||
"""Extract the CSRF state parameter from an OAuth authorization URL."""
|
||||
parsed = urlparse(auth_url)
|
||||
qs = parse_qs(parsed.query)
|
||||
assert "state" in qs, f"auth_url should contain state param: {auth_url}"
|
||||
return qs["state"][0]
|
||||
|
||||
|
||||
async def _get_extension(base_url, name):
|
||||
"""Get a specific extension from the extensions list, or None."""
|
||||
r = await api_get(base_url, "/api/extensions")
|
||||
for ext in r.json().get("extensions", []):
|
||||
if ext["name"] == name:
|
||||
return ext
|
||||
return None
|
||||
|
||||
|
||||
async def _ensure_removed(base_url, name):
|
||||
"""Remove extension if already installed."""
|
||||
ext = await _get_extension(base_url, name)
|
||||
if ext:
|
||||
await api_post(base_url, f"/api/extensions/{name}/remove", timeout=30)
|
||||
|
||||
|
||||
# ── Section A: Install MCP Server ────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_mcp_install(ironclaw_server, mock_llm_server):
|
||||
"""Install a mock MCP server pointing at mock_llm.py's /mcp endpoint."""
|
||||
await _ensure_removed(ironclaw_server, "mock-mcp")
|
||||
|
||||
mcp_url = f"{mock_llm_server}/mcp"
|
||||
r = await api_post(
|
||||
ironclaw_server,
|
||||
"/api/extensions/install",
|
||||
json={"name": "mock-mcp", "url": mcp_url, "kind": "mcp_server"},
|
||||
timeout=30,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data.get("success") is True, f"Install failed: {data}"
|
||||
|
||||
ext = await _get_extension(ironclaw_server, "mock-mcp")
|
||||
assert ext is not None, "mock-mcp should appear in extensions list"
|
||||
assert ext["kind"] == "mcp_server"
|
||||
|
||||
|
||||
# ── Section B: Activate Triggers Auth ────────────────────────────────────
|
||||
|
||||
|
||||
async def test_mcp_activate_triggers_auth(ironclaw_server):
|
||||
"""Activating an unauthenticated MCP server triggers the OAuth flow.
|
||||
|
||||
The mock MCP returns 401 with WWW-Authenticate when no Bearer token
|
||||
is present. The activate handler should detect this as auth-required
|
||||
and return an auth_url.
|
||||
"""
|
||||
ext = await _get_extension(ironclaw_server, "mock-mcp")
|
||||
if ext is None:
|
||||
pytest.skip("mock-mcp not installed")
|
||||
|
||||
r = await api_post(
|
||||
ironclaw_server,
|
||||
"/api/extensions/mock-mcp/activate",
|
||||
timeout=30,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
|
||||
# Activation should fail with an auth_url (OAuth needed)
|
||||
# OR it should return awaiting_token (manual token prompt)
|
||||
auth_url = data.get("auth_url")
|
||||
awaiting_token = data.get("awaiting_token")
|
||||
assert auth_url is not None or awaiting_token, (
|
||||
f"Activate should require auth, got: {data}"
|
||||
)
|
||||
|
||||
|
||||
# ── Section C: OAuth Round-Trip ──────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_mcp_oauth_callback(ironclaw_server):
|
||||
"""Complete the OAuth flow via setup + callback for the MCP server."""
|
||||
ext = await _get_extension(ironclaw_server, "mock-mcp")
|
||||
if ext is None:
|
||||
pytest.skip("mock-mcp not installed")
|
||||
|
||||
# Configure with empty secrets to trigger OAuth
|
||||
r = await api_post(
|
||||
ironclaw_server,
|
||||
"/api/extensions/mock-mcp/setup",
|
||||
json={"secrets": {}},
|
||||
timeout=30,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
|
||||
# If no auth_url, try activate to trigger it
|
||||
auth_url = data.get("auth_url")
|
||||
if auth_url is None:
|
||||
r = await api_post(
|
||||
ironclaw_server,
|
||||
"/api/extensions/mock-mcp/activate",
|
||||
timeout=30,
|
||||
)
|
||||
data = r.json()
|
||||
auth_url = data.get("auth_url")
|
||||
|
||||
if auth_url is None:
|
||||
# Server might have been auto-authenticated via DCR; check if active
|
||||
ext = await _get_extension(ironclaw_server, "mock-mcp")
|
||||
if ext and ext.get("authenticated"):
|
||||
return # Already authenticated, skip callback test
|
||||
pytest.skip("Could not obtain auth_url for mock-mcp")
|
||||
|
||||
csrf_state = _extract_state(auth_url)
|
||||
|
||||
# Hit the OAuth callback endpoint
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(
|
||||
f"{ironclaw_server}/oauth/callback",
|
||||
params={"code": "mock_mcp_code", "state": csrf_state},
|
||||
timeout=30,
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert r.status_code == 200, f"Callback returned {r.status_code}: {r.text[:300]}"
|
||||
body = r.text.lower()
|
||||
assert "connected" in body or "success" in body, (
|
||||
f"Callback should indicate success: {r.text[:500]}"
|
||||
)
|
||||
|
||||
|
||||
async def test_mcp_authenticated_after_oauth(ironclaw_server):
|
||||
"""After OAuth callback, MCP server shows authenticated=True."""
|
||||
ext = await _get_extension(ironclaw_server, "mock-mcp")
|
||||
if ext is None:
|
||||
pytest.skip("mock-mcp not installed")
|
||||
assert ext["authenticated"] is True, (
|
||||
f"mock-mcp should be authenticated after OAuth: {ext}"
|
||||
)
|
||||
|
||||
|
||||
async def test_mcp_tools_registered(ironclaw_server):
|
||||
"""After authentication, MCP tools appear in the extension."""
|
||||
ext = await _get_extension(ironclaw_server, "mock-mcp")
|
||||
if ext is None:
|
||||
pytest.skip("mock-mcp not installed")
|
||||
tools = ext.get("tools", [])
|
||||
assert len(tools) > 0, f"mock-mcp should have tools after auth: {ext}"
|
||||
# The mock MCP serves a tool named "mock_search", prefixed with server name
|
||||
tool_names = [t for t in tools if "mock_search" in t]
|
||||
assert len(tool_names) > 0, f"Expected mock_search tool, got: {tools}"
|
||||
|
||||
|
||||
# ── Section D: Auth Mode Cleared — LLM Turn Fires ───────────────────────
|
||||
|
||||
|
||||
async def test_mcp_auth_mode_cleared_llm_turn_fires(ironclaw_server, page):
|
||||
"""After OAuth completes, the next user message triggers an LLM turn.
|
||||
|
||||
Regression test: previously, pending_auth was not cleared by the OAuth
|
||||
callback handler, so the next user message was consumed as a token and
|
||||
the LLM turn never fired.
|
||||
"""
|
||||
chat_input = page.locator(SEL["chat_input"])
|
||||
await chat_input.wait_for(state="visible", timeout=5000)
|
||||
|
||||
assistant_sel = SEL["message_assistant"]
|
||||
before_count = await page.locator(assistant_sel).count()
|
||||
|
||||
# Send a normal message — should trigger LLM, not be swallowed by auth
|
||||
await chat_input.fill("hello")
|
||||
await chat_input.press("Enter")
|
||||
|
||||
# Wait for assistant response
|
||||
expected = before_count + 1
|
||||
await page.wait_for_function(
|
||||
"""({ assistantSelector, expectedCount }) => {
|
||||
const messages = document.querySelectorAll(assistantSelector);
|
||||
return messages.length >= expectedCount;
|
||||
}""",
|
||||
arg={"assistantSelector": assistant_sel, "expectedCount": expected},
|
||||
timeout=15000,
|
||||
)
|
||||
|
||||
text = await page.locator(assistant_sel).last.inner_text()
|
||||
assert len(text.strip()) > 0, "Assistant should have responded"
|
||||
|
||||
|
||||
# ── Section E: GitHub-style 400 Error ─────────────────────────────────────
|
||||
|
||||
|
||||
async def test_mcp_400_activate_triggers_auth(ironclaw_server, mock_llm_server):
|
||||
"""MCP server returning 400 "Authorization header is badly formatted"
|
||||
is treated as auth-required (regression for GitHub MCP).
|
||||
|
||||
Previously, only 401 triggered the auth flow. GitHub's MCP returns 400
|
||||
with "Authorization header is badly formatted" instead.
|
||||
"""
|
||||
await _ensure_removed(ironclaw_server, "mock-mcp-400")
|
||||
|
||||
mcp_url = f"{mock_llm_server}/mcp-400"
|
||||
r = await api_post(
|
||||
ironclaw_server,
|
||||
"/api/extensions/install",
|
||||
json={"name": "mock-mcp-400", "url": mcp_url, "kind": "mcp_server"},
|
||||
timeout=30,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json().get("success") is True, f"Install failed: {r.json()}"
|
||||
|
||||
# Activate should detect 400 + "authorization" as auth-required
|
||||
r = await api_post(
|
||||
ironclaw_server,
|
||||
"/api/extensions/mock-mcp-400/activate",
|
||||
timeout=30,
|
||||
)
|
||||
assert r.status_code == 200, f"Activate returned {r.status_code}: {r.text[:300]}"
|
||||
data = r.json()
|
||||
|
||||
# The 400 should be treated as auth-required, returning an auth_url
|
||||
# or awaiting_token — not a raw "400 Bad Request" activation error.
|
||||
auth_url = data.get("auth_url")
|
||||
awaiting_token = data.get("awaiting_token")
|
||||
assert auth_url is not None or awaiting_token, (
|
||||
f"400 auth error should trigger auth flow (auth_url or awaiting_token), got: {data}"
|
||||
)
|
||||
|
||||
|
||||
async def test_mcp_400_oauth_discovery_returns_auth_url(ironclaw_server):
|
||||
"""OAuth discovery succeeds for the 400-variant via RFC 9728 (strategy 2).
|
||||
|
||||
Strategy 1 (discover_via_401) fails because /mcp-400 returns 400 without
|
||||
a WWW-Authenticate header. Strategy 2 queries
|
||||
/.well-known/oauth-protected-resource/mcp-400 (path-suffixed) and must
|
||||
find the mock's wildcard route. Without that route, discovery fails
|
||||
entirely and only awaiting_token (manual) is returned — no auth_url.
|
||||
|
||||
This test would have failed before the wildcard .well-known routes were
|
||||
added to mock_llm.py.
|
||||
"""
|
||||
ext = await _get_extension(ironclaw_server, "mock-mcp-400")
|
||||
if ext is None:
|
||||
pytest.skip("mock-mcp-400 not installed")
|
||||
|
||||
# Re-activate to get a fresh auth response
|
||||
r = await api_post(
|
||||
ironclaw_server,
|
||||
"/api/extensions/mock-mcp-400/activate",
|
||||
timeout=30,
|
||||
)
|
||||
assert r.status_code == 200, f"Activate returned {r.status_code}: {r.text[:300]}"
|
||||
data = r.json()
|
||||
|
||||
auth_url = data.get("auth_url")
|
||||
assert auth_url is not None, (
|
||||
f"OAuth discovery must produce an auth_url (not just awaiting_token). "
|
||||
f"Strategy 2 (RFC 9728) likely failed — check .well-known wildcard routes. "
|
||||
f"Got: {data}"
|
||||
)
|
||||
|
||||
|
||||
async def test_mcp_400_full_oauth_roundtrip(ironclaw_server):
|
||||
"""Complete OAuth round-trip for the 400-variant MCP server.
|
||||
|
||||
Exercises the full path: activate → 400 detected as auth-required →
|
||||
OAuth discovery via strategy 2 (path-suffixed .well-known) → DCR →
|
||||
auth_url returned → callback completes token exchange → extension
|
||||
authenticated with tools.
|
||||
|
||||
Without the wildcard .well-known routes, OAuth discovery fails and
|
||||
no auth_url is produced, so this test would fail at the csrf_state
|
||||
extraction step.
|
||||
"""
|
||||
ext = await _get_extension(ironclaw_server, "mock-mcp-400")
|
||||
if ext is None:
|
||||
pytest.skip("mock-mcp-400 not installed")
|
||||
|
||||
# Get a fresh auth_url via activate
|
||||
r = await api_post(
|
||||
ironclaw_server,
|
||||
"/api/extensions/mock-mcp-400/activate",
|
||||
timeout=30,
|
||||
)
|
||||
data = r.json()
|
||||
auth_url = data.get("auth_url")
|
||||
if auth_url is None:
|
||||
pytest.skip("No auth_url from activate (discovery may not have succeeded)")
|
||||
|
||||
csrf_state = _extract_state(auth_url)
|
||||
|
||||
# Complete OAuth callback
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(
|
||||
f"{ironclaw_server}/oauth/callback",
|
||||
params={"code": "mock_400_code", "state": csrf_state},
|
||||
timeout=30,
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert r.status_code == 200, f"Callback returned {r.status_code}: {r.text[:300]}"
|
||||
body = r.text.lower()
|
||||
assert "connected" in body or "success" in body, (
|
||||
f"400-variant OAuth callback should succeed: {r.text[:500]}"
|
||||
)
|
||||
|
||||
# Verify authenticated + tools loaded
|
||||
ext = await _get_extension(ironclaw_server, "mock-mcp-400")
|
||||
assert ext is not None, "mock-mcp-400 should still be installed"
|
||||
assert ext["authenticated"] is True, (
|
||||
f"mock-mcp-400 should be authenticated after OAuth: {ext}"
|
||||
)
|
||||
tools = ext.get("tools", [])
|
||||
assert len(tools) > 0, f"mock-mcp-400 should have tools after auth: {ext}"
|
||||
|
||||
|
||||
async def test_mcp_400_cleanup(ironclaw_server):
|
||||
"""Clean up the 400-variant MCP server."""
|
||||
await _ensure_removed(ironclaw_server, "mock-mcp-400")
|
||||
ext = await _get_extension(ironclaw_server, "mock-mcp-400")
|
||||
assert ext is None, "mock-mcp-400 should be removed"
|
||||
|
||||
|
||||
# ── Section F: Cleanup ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_mcp_cleanup(ironclaw_server):
|
||||
"""Remove mock-mcp (cleanup for other test files)."""
|
||||
await _ensure_removed(ironclaw_server, "mock-mcp")
|
||||
ext = await _get_extension(ironclaw_server, "mock-mcp")
|
||||
assert ext is None, "mock-mcp should be removed"
|
||||
@@ -0,0 +1,110 @@
|
||||
"""OAuth credential fallback e2e tests.
|
||||
|
||||
Tests that OAuth tokens stored globally under 'default' user are properly
|
||||
injected when WASM tools make HTTP requests. This validates the fix for:
|
||||
https://github.com/nearai/ironclaw/issues/999
|
||||
|
||||
Note: Full routine execution testing is limited because routines are disabled
|
||||
in the e2e test environment (ROUTINES_ENABLED=false in conftest.py). This test
|
||||
validates the OAuth + credential injection flow at the REST API level.
|
||||
|
||||
Unit tests in src/tools/wasm/wrapper.rs provide additional coverage of the
|
||||
fallback mechanism itself.
|
||||
"""
|
||||
|
||||
from helpers import api_post, api_get
|
||||
import pytest
|
||||
|
||||
|
||||
async def test_oauth_credential_injection_after_gmail_auth(ironclaw_server):
|
||||
"""Verify that after OAuth, tool HTTP requests include credentials.
|
||||
|
||||
This is an indirect test: we verify that gmail shows as authenticated
|
||||
and that its tools are registered. A full e2e test would require:
|
||||
1. Enabling ROUTINES_ENABLED=true in conftest.py
|
||||
2. Creating a routine that calls a WASM tool with OAuth
|
||||
3. Triggering the routine and verifying the request succeeded
|
||||
|
||||
The unit tests in src/tools/wasm/wrapper.rs validate the credential
|
||||
fallback mechanism (trying 'default' user when user-specific lookup fails).
|
||||
"""
|
||||
|
||||
# First, ensure gmail is installed and authenticated
|
||||
# (Reuse from test_extension_oauth.py if running in sequence)
|
||||
r = await api_get(ironclaw_server, "/api/extensions")
|
||||
extensions = r.json().get("extensions", [])
|
||||
gmail = next((ext for ext in extensions if ext["name"] == "gmail"), None)
|
||||
|
||||
if gmail is None:
|
||||
# Install gmail
|
||||
r = await api_post(
|
||||
ironclaw_server,
|
||||
"/api/extensions/install",
|
||||
json={"name": "gmail"},
|
||||
timeout=180,
|
||||
)
|
||||
assert r.status_code == 200, f"Failed to install gmail: {r.text}"
|
||||
|
||||
# Verify gmail is authenticated (it should be if oauth flow completed)
|
||||
r = await api_get(ironclaw_server, "/api/extensions")
|
||||
extensions = r.json().get("extensions", [])
|
||||
gmail = next((ext for ext in extensions if ext["name"] == "gmail"), None)
|
||||
assert gmail is not None, "gmail not found in extensions"
|
||||
|
||||
# Authenticated tools should have credentials available for injection
|
||||
if gmail.get("authenticated"):
|
||||
tools = gmail.get("tools", [])
|
||||
assert (
|
||||
len(tools) > 0
|
||||
), f"Authenticated gmail should have tools registered: {gmail}"
|
||||
|
||||
# Tools should be callable (which requires credential injection)
|
||||
# In a full e2e with routines enabled, we would:
|
||||
# 1. Call a gmail tool from a routine
|
||||
# 2. Verify the HTTP request included the OAuth token
|
||||
# 3. Verify no 403 "unregistered callers" error
|
||||
|
||||
|
||||
async def test_tool_registry_lists_authenticated_extensions(ironclaw_server):
|
||||
"""Verify authenticated extensions' tools are registered in tool registry.
|
||||
|
||||
Tools from authenticated extensions should have credentials pre-injected
|
||||
before HTTP requests are made. This validates the end of the injection
|
||||
pipeline (credential resolution -> WASM execution -> HTTP request).
|
||||
"""
|
||||
|
||||
# Get extensions list
|
||||
r = await api_get(ironclaw_server, "/api/extensions")
|
||||
extensions = r.json().get("extensions", [])
|
||||
|
||||
# Authenticated extensions should appear
|
||||
authenticated = [ext for ext in extensions if ext.get("authenticated")]
|
||||
|
||||
# At minimum, verify the endpoint works and structure is correct
|
||||
for ext in authenticated:
|
||||
assert "name" in ext
|
||||
assert "tools" in ext
|
||||
assert isinstance(ext["tools"], list)
|
||||
|
||||
|
||||
async def test_credential_fallback_documented_in_code(ironclaw_server):
|
||||
"""Verify the credential fallback fix is present.
|
||||
|
||||
This is a documentation test that the bug fix for issue #999 is
|
||||
actually in the code. The real validation happens in unit tests:
|
||||
- test_resolve_host_credentials_fallback_to_default_user
|
||||
- test_resolve_host_credentials_prefers_user_specific_over_default
|
||||
- test_resolve_host_credentials_no_fallback_when_already_default
|
||||
|
||||
If these unit tests pass, the fix is working correctly.
|
||||
"""
|
||||
|
||||
# This test serves as a reminder that:
|
||||
# 1. OAuth tokens are stored globally under user_id="default"
|
||||
# 2. When routines execute, they use routine.user_id (not "default")
|
||||
# 3. The fix adds credential fallback: try user_id first, then "default"
|
||||
# 4. This allows global OAuth tokens to be used in routine contexts
|
||||
|
||||
# No specific assertion needed — presence of this test file documents
|
||||
# the fix. Actual validation is in unit tests.
|
||||
assert True
|
||||
@@ -0,0 +1,534 @@
|
||||
"""
|
||||
E2E tests for event-triggered routines with batch loading.
|
||||
|
||||
These tests verify that the N+1 query fix correctly:
|
||||
1. Fires event-triggered routines on matching messages
|
||||
2. Enforces concurrent limits via batch-loaded counts
|
||||
3. Maintains performance with multiple simultaneous triggers
|
||||
4. Works correctly through the full UI and agent loop
|
||||
|
||||
Playwright-based UI tests + SSE verification.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from playwright.async_api import async_playwright, Page, Browser, BrowserContext
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def browser_and_context():
|
||||
"""Create a Playwright browser and context for testing."""
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
context = await browser.new_context()
|
||||
yield browser, context
|
||||
await context.close()
|
||||
await browser.close()
|
||||
|
||||
|
||||
class EventTriggerHelper:
|
||||
"""Helper methods for event trigger testing."""
|
||||
|
||||
def __init__(self, page: Page):
|
||||
self.page = page
|
||||
|
||||
async def navigate_to_routines(self):
|
||||
"""Navigate to the routines page."""
|
||||
await self.page.goto("http://localhost:8000/routines")
|
||||
await self.page.wait_for_load_state("networkidle")
|
||||
|
||||
async def create_event_routine(
|
||||
self,
|
||||
name: str,
|
||||
trigger_regex: str,
|
||||
channel: str = "slack",
|
||||
max_concurrent: int = 1,
|
||||
) -> str:
|
||||
"""
|
||||
Create an event-triggered routine via UI.
|
||||
Returns the routine ID.
|
||||
"""
|
||||
await self.navigate_to_routines()
|
||||
|
||||
# Click "New Routine" button
|
||||
await self.page.click('button:has-text("New Routine")')
|
||||
await self.page.wait_for_selector('input[name="routine_name"]')
|
||||
|
||||
# Fill routine details
|
||||
await self.page.fill('input[name="routine_name"]', name)
|
||||
await self.page.fill(
|
||||
'textarea[name="routine_description"]',
|
||||
f"Test routine: {name}",
|
||||
)
|
||||
|
||||
# Select "Event Trigger" type
|
||||
await self.page.click('label:has-text("Event Trigger")')
|
||||
await self.page.wait_for_selector('input[name="trigger_regex"]')
|
||||
|
||||
# Fill trigger details
|
||||
await self.page.fill('input[name="trigger_regex"]', trigger_regex)
|
||||
await self.page.select_option('select[name="trigger_channel"]', channel)
|
||||
|
||||
# Set guardrails
|
||||
await self.page.fill('input[name="max_concurrent"]', str(max_concurrent))
|
||||
|
||||
# Select lightweight action
|
||||
await self.page.click('label:has-text("Lightweight")')
|
||||
await self.page.fill(
|
||||
'textarea[name="lightweight_prompt"]',
|
||||
"Acknowledge the message and confirm trigger worked.",
|
||||
)
|
||||
|
||||
# Save routine
|
||||
await self.page.click('button:has-text("Save Routine")')
|
||||
await self.page.wait_for_selector('text=Routine created successfully')
|
||||
|
||||
# Extract routine ID from success message or URL
|
||||
routine_id = await self.page.locator('data-testid=routine-id').text_content()
|
||||
return routine_id.strip() if routine_id else None
|
||||
|
||||
async def create_multiple_routines(
|
||||
self, base_name: str, count: int, trigger_regex: str = None
|
||||
) -> List[str]:
|
||||
"""Create multiple event-triggered routines."""
|
||||
routine_ids = []
|
||||
for i in range(count):
|
||||
name = f"{base_name}_{i}"
|
||||
regex = trigger_regex or f"({i}|{base_name})"
|
||||
routine_id = await self.create_event_routine(name, regex)
|
||||
routine_ids.append(routine_id)
|
||||
await asyncio.sleep(0.1) # Small delay between creations
|
||||
return routine_ids
|
||||
|
||||
async def send_chat_message(self, message: str) -> List[str]:
|
||||
"""
|
||||
Send a chat message and return SSE events received.
|
||||
Captures all routine firing events.
|
||||
"""
|
||||
await self.page.goto("http://localhost:8000/chat")
|
||||
await self.page.wait_for_selector('input[placeholder*="message"]', timeout=5000)
|
||||
|
||||
# Collect SSE events
|
||||
sse_events = []
|
||||
|
||||
async def capture_sse(response):
|
||||
"""Intercept SSE events."""
|
||||
if "event-stream" in response.headers.get("content-type", ""):
|
||||
text = await response.text()
|
||||
for line in text.split("\n"):
|
||||
if line.startswith("data:"):
|
||||
try:
|
||||
event = json.loads(line[5:])
|
||||
sse_events.append(event)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
self.page.on("response", capture_sse)
|
||||
|
||||
# Send message
|
||||
await self.page.fill('input[placeholder*="message"]', message)
|
||||
await self.page.press('input[placeholder*="message"]', "Enter")
|
||||
|
||||
# Wait for response
|
||||
await self.page.wait_for_selector('text=Message processed', timeout=10000)
|
||||
await asyncio.sleep(0.5) # Allow time for SSE events
|
||||
|
||||
self.page.remove_listener("response", capture_sse)
|
||||
return sse_events
|
||||
|
||||
async def get_routine_execution_log(self, routine_id: str) -> List[Dict]:
|
||||
"""Get execution log entries for a routine."""
|
||||
await self.page.goto(f"http://localhost:8000/routines/{routine_id}/executions")
|
||||
await self.page.wait_for_load_state("networkidle")
|
||||
|
||||
# Extract log entries from table
|
||||
rows = await self.page.locator("tbody tr").all()
|
||||
executions = []
|
||||
|
||||
for row in rows:
|
||||
cells = await row.locator("td").all()
|
||||
if len(cells) >= 3:
|
||||
execution = {
|
||||
"timestamp": await cells[0].text_content(),
|
||||
"status": await cells[1].text_content(),
|
||||
"details": await cells[2].text_content(),
|
||||
}
|
||||
executions.append(execution)
|
||||
|
||||
return executions
|
||||
|
||||
async def check_database_queries_in_logs(
|
||||
self, max_queries_expected: int = 1
|
||||
) -> int:
|
||||
"""Check debug logs for database query count."""
|
||||
await self.page.goto("http://localhost:8000/debug/logs?filter=database")
|
||||
await self.page.wait_for_load_state("networkidle")
|
||||
|
||||
# Count batch queries
|
||||
log_lines = await self.page.locator("tr:has-text('batch')").all()
|
||||
batch_count = len(log_lines)
|
||||
|
||||
# Count individual COUNT queries (should be 0 after fix)
|
||||
count_queries = await self.page.locator("tr:has-text('COUNT')").all()
|
||||
count_query_count = len(count_queries)
|
||||
|
||||
return batch_count, count_query_count
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_trigger_routine(browser_and_context):
|
||||
"""Test creating an event-triggered routine via UI."""
|
||||
browser, context = browser_and_context
|
||||
page = await context.new_page()
|
||||
helper = EventTriggerHelper(page)
|
||||
|
||||
try:
|
||||
routine_id = await helper.create_event_routine(
|
||||
name="Test Trigger",
|
||||
trigger_regex="test|demo",
|
||||
channel="slack",
|
||||
max_concurrent=1,
|
||||
)
|
||||
|
||||
assert routine_id is not None, "Routine ID should be returned"
|
||||
assert len(routine_id) > 0, "Routine ID should not be empty"
|
||||
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_trigger_fires_on_matching_message(browser_and_context):
|
||||
"""Test that event-triggered routine fires when message matches."""
|
||||
browser, context = browser_and_context
|
||||
page = await context.new_page()
|
||||
helper = EventTriggerHelper(page)
|
||||
|
||||
try:
|
||||
# Create routine
|
||||
routine_id = await helper.create_event_routine(
|
||||
name="Alert Handler",
|
||||
trigger_regex="urgent|critical|alert",
|
||||
channel="slack",
|
||||
)
|
||||
|
||||
# Send matching message
|
||||
sse_events = await helper.send_chat_message("URGENT: Server down!")
|
||||
|
||||
# Verify routine fired (look for event in SSE stream)
|
||||
routine_fired = any(
|
||||
event.get("type") == "routine_fired" and event.get("routine_id") == routine_id
|
||||
for event in sse_events
|
||||
)
|
||||
assert routine_fired, "Routine should fire on matching message"
|
||||
|
||||
# Check execution log
|
||||
executions = await helper.get_routine_execution_log(routine_id)
|
||||
assert len(executions) > 0, "Execution should be logged"
|
||||
assert "success" in executions[0]["status"].lower()
|
||||
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_trigger_skips_non_matching_message(browser_and_context):
|
||||
"""Test that event-triggered routine skips when message doesn't match."""
|
||||
browser, context = browser_and_context
|
||||
page = await context.new_page()
|
||||
helper = EventTriggerHelper(page)
|
||||
|
||||
try:
|
||||
# Create routine
|
||||
routine_id = await helper.create_event_routine(
|
||||
name="Alert Handler",
|
||||
trigger_regex="urgent|critical|alert",
|
||||
channel="slack",
|
||||
)
|
||||
|
||||
# Send non-matching message
|
||||
sse_events = await helper.send_chat_message("Hello, how are you?")
|
||||
|
||||
# Verify routine did NOT fire
|
||||
routine_fired = any(
|
||||
event.get("type") == "routine_fired" and event.get("routine_id") == routine_id
|
||||
for event in sse_events
|
||||
)
|
||||
assert not routine_fired, "Routine should not fire on non-matching message"
|
||||
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_routines_fire_on_matching_message(browser_and_context):
|
||||
"""Test that multiple event-triggered routines fire on same message."""
|
||||
browser, context = browser_and_context
|
||||
page = await context.new_page()
|
||||
helper = EventTriggerHelper(page)
|
||||
|
||||
try:
|
||||
# Create 3 overlapping routines
|
||||
routine_ids = await helper.create_multiple_routines(
|
||||
base_name="Handler", count=3, trigger_regex="alert|warning|error"
|
||||
)
|
||||
|
||||
# Send matching message
|
||||
sse_events = await helper.send_chat_message("ERROR: Database connection failed")
|
||||
|
||||
# Verify all 3 routines fired
|
||||
fired_count = sum(
|
||||
1
|
||||
for event in sse_events
|
||||
if event.get("type") == "routine_fired" and event.get("routine_id") in routine_ids
|
||||
)
|
||||
|
||||
assert (
|
||||
fired_count >= 3
|
||||
), f"Expected all 3 routines to fire, got {fired_count}"
|
||||
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_limit_prevents_additional_fires(browser_and_context):
|
||||
"""Test that concurrent limit is enforced via batch counts."""
|
||||
browser, context = browser_and_context
|
||||
page = await context.new_page()
|
||||
helper = EventTriggerHelper(page)
|
||||
|
||||
try:
|
||||
# Create routine with max_concurrent=1
|
||||
routine_id = await helper.create_event_routine(
|
||||
name="Limited Handler",
|
||||
trigger_regex="process|task",
|
||||
max_concurrent=1,
|
||||
)
|
||||
|
||||
# Trigger first message
|
||||
await helper.send_chat_message("Process message 1")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Check first execution logged
|
||||
executions_1 = await helper.get_routine_execution_log(routine_id)
|
||||
assert len(executions_1) >= 1
|
||||
|
||||
# Trigger second message while first is still running
|
||||
sse_events = await helper.send_chat_message("Process message 2")
|
||||
|
||||
# Second routine should be skipped (concurrent limit)
|
||||
routine_skipped = any(
|
||||
event.get("type") == "routine_skipped"
|
||||
and event.get("reason") == "max_concurrent_reached"
|
||||
and event.get("routine_id") == routine_id
|
||||
for event in sse_events
|
||||
)
|
||||
assert routine_skipped, "Routine should be skipped when concurrent limit reached"
|
||||
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rapid_messages_with_multiple_triggers_efficiency(browser_and_context):
|
||||
"""Test efficiency of batch loading with multiple rapid messages."""
|
||||
browser, context = browser_and_context
|
||||
page = await context.new_page()
|
||||
helper = EventTriggerHelper(page)
|
||||
|
||||
try:
|
||||
# Create 5 overlapping routines
|
||||
routine_ids = await helper.create_multiple_routines(
|
||||
base_name="Rapid", count=5, trigger_regex="test|demo|check"
|
||||
)
|
||||
|
||||
# Send 10 matching messages rapidly
|
||||
for i in range(10):
|
||||
message = f"test message {i}"
|
||||
await helper.send_chat_message(message)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Check database logs for query efficiency
|
||||
batch_count, count_query_count = await helper.check_database_queries_in_logs()
|
||||
|
||||
# After fix: should have ~10 batch queries (1 per message)
|
||||
# Before fix: would have ~50 individual COUNT queries (5 routines × 10 messages)
|
||||
assert (
|
||||
count_query_count == 0
|
||||
), f"Should have 0 individual COUNT queries after fix, got {count_query_count}"
|
||||
assert (
|
||||
batch_count <= 15
|
||||
), f"Should have <=15 batch queries for 10 messages, got {batch_count}"
|
||||
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_filter_applied_correctly(browser_and_context):
|
||||
"""Test that channel filter prevents non-matching messages."""
|
||||
browser, context = browser_and_context
|
||||
page = await context.new_page()
|
||||
helper = EventTriggerHelper(page)
|
||||
|
||||
try:
|
||||
# Create routine for Slack channel
|
||||
slack_routine_id = await helper.create_event_routine(
|
||||
name="Slack Handler",
|
||||
trigger_regex="alert",
|
||||
channel="slack",
|
||||
)
|
||||
|
||||
# Simulate message from Telegram channel
|
||||
# (Note: In real UI, would need to change channel context)
|
||||
page.goto(
|
||||
"http://localhost:8000/chat?channel=telegram"
|
||||
) # Switch channel
|
||||
await helper.send_chat_message("alert: something urgent")
|
||||
|
||||
# Routine should not fire (different channel)
|
||||
executions = await helper.get_routine_execution_log(slack_routine_id)
|
||||
|
||||
# Check if any recent execution (last 5 min) exists
|
||||
recent = [
|
||||
e
|
||||
for e in executions
|
||||
if (datetime.now() - datetime.fromisoformat(e["timestamp"])).total_seconds()
|
||||
< 300
|
||||
]
|
||||
assert (
|
||||
len(recent) == 0
|
||||
), "Routine should not fire for different channel"
|
||||
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_query_failure_handling(browser_and_context):
|
||||
"""Test graceful handling of batch query failures."""
|
||||
browser, context = browser_and_context
|
||||
page = await context.new_page()
|
||||
helper = EventTriggerHelper(page)
|
||||
|
||||
try:
|
||||
# Create routine
|
||||
routine_id = await helper.create_event_routine(
|
||||
name="Error Handler",
|
||||
trigger_regex="test",
|
||||
)
|
||||
|
||||
# Simulate database error in logs (if possible with test hooks)
|
||||
# For now, just verify error handling doesn't crash UI
|
||||
await helper.send_chat_message("test message")
|
||||
|
||||
# Check that UI remains responsive
|
||||
assert await page.locator("text=Message processed").is_visible()
|
||||
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routine_execution_history_display(browser_and_context):
|
||||
"""Test that execution history correctly displays routine firings."""
|
||||
browser, context = browser_and_context
|
||||
page = await context.new_page()
|
||||
helper = EventTriggerHelper(page)
|
||||
|
||||
try:
|
||||
# Create routine
|
||||
routine_id = await helper.create_event_routine(
|
||||
name="History Test",
|
||||
trigger_regex="test",
|
||||
)
|
||||
|
||||
# Trigger routine 3 times
|
||||
for i in range(3):
|
||||
await helper.send_chat_message(f"test message {i}")
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
# Check execution log
|
||||
executions = await helper.get_routine_execution_log(routine_id)
|
||||
assert len(executions) >= 3, "Should have at least 3 executions logged"
|
||||
|
||||
# Verify all are recent (within last 5 minutes)
|
||||
for execution in executions[:3]:
|
||||
timestamp = datetime.fromisoformat(execution["timestamp"])
|
||||
age = datetime.now() - timestamp
|
||||
assert age < timedelta(minutes=5), "Execution should be recent"
|
||||
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_batch_loads_independent(browser_and_context):
|
||||
"""Test that concurrent messages each get independent batch queries."""
|
||||
browser, context = browser_and_context
|
||||
page = await context.new_page()
|
||||
helper = EventTriggerHelper(page)
|
||||
|
||||
try:
|
||||
# Create 5 routines matching different patterns
|
||||
r1_id = await helper.create_event_routine(
|
||||
name="Pattern A", trigger_regex="alpha|alpha_only"
|
||||
)
|
||||
r2_id = await helper.create_event_routine(
|
||||
name="Pattern B", trigger_regex="beta|beta_only"
|
||||
)
|
||||
r3_id = await helper.create_event_routine(
|
||||
name="Pattern AB", trigger_regex="alpha|beta|common"
|
||||
)
|
||||
|
||||
# Send overlapping messages
|
||||
# Message 1: matches r1, r3
|
||||
sse1 = await helper.send_chat_message("alpha common")
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Message 2: matches r2, r3
|
||||
sse2 = await helper.send_chat_message("beta common")
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Verify correct routines fired
|
||||
r1_fired_msg1 = any(
|
||||
e.get("routine_id") == r1_id for e in sse1 if e.get("type") == "routine_fired"
|
||||
)
|
||||
r2_fired_msg2 = any(
|
||||
e.get("routine_id") == r2_id for e in sse2 if e.get("type") == "routine_fired"
|
||||
)
|
||||
r3_fired_both = (
|
||||
any(
|
||||
e.get("routine_id") == r3_id for e in sse1 if e.get("type") == "routine_fired"
|
||||
)
|
||||
and any(
|
||||
e.get("routine_id") == r3_id for e in sse2 if e.get("type") == "routine_fired"
|
||||
)
|
||||
)
|
||||
|
||||
assert r1_fired_msg1, "Routine 1 should fire on message 1"
|
||||
assert r2_fired_msg2, "Routine 2 should fire on message 2"
|
||||
assert r3_fired_both, "Routine 3 should fire on both messages"
|
||||
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Integration with existing test patterns
|
||||
# =============================================================================
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run tests with: pytest tests/e2e/scenarios/test_routine_event_batch.py -v
|
||||
pytest.main([__file__, "-v", "-s"])
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Playwright e2e tests for OAuth credential injection in routines.
|
||||
|
||||
Tests the full flow for issue #999:
|
||||
1. Complete OAuth for a WASM tool (gmail)
|
||||
2. Create a routine that calls that tool
|
||||
3. Manually trigger the routine
|
||||
4. Verify the tool executes with proper credential injection (no 403 errors)
|
||||
|
||||
This tests that OAuth tokens stored globally under 'default' user are properly
|
||||
accessible in routine execution contexts.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from helpers import SEL, api_post, api_get
|
||||
|
||||
|
||||
async def test_routine_with_oauth_credentials_e2e(page, ironclaw_server):
|
||||
"""Complete flow: OAuth → routine creation → execution → success.
|
||||
|
||||
This is the most comprehensive test for the credential fallback fix.
|
||||
It validates that:
|
||||
1. OAuth tokens are stored globally
|
||||
2. Routines can access those tokens
|
||||
3. WASM tools receive proper Authorization headers
|
||||
4. No 403 "unregistered callers" errors occur
|
||||
"""
|
||||
|
||||
# Step 1: Ensure gmail is installed and authenticated
|
||||
# (Using REST API for setup, consistent with test_extension_oauth.py)
|
||||
r = await api_post(
|
||||
ironclaw_server,
|
||||
"/api/extensions/install",
|
||||
json={"name": "gmail"},
|
||||
timeout=180,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
# Gmail installed successfully
|
||||
pass
|
||||
else:
|
||||
# Might already be installed, that's ok
|
||||
pass
|
||||
|
||||
# Verify gmail is in the extensions list and authenticated
|
||||
r = await api_get(ironclaw_server, "/api/extensions")
|
||||
extensions = r.json().get("extensions", [])
|
||||
gmail = next((ext for ext in extensions if ext["name"] == "gmail"), None)
|
||||
|
||||
if gmail is None:
|
||||
pytest.skip("Gmail extension not available")
|
||||
|
||||
if not gmail.get("authenticated"):
|
||||
pytest.skip("Gmail not authenticated (requires OAuth flow completion)")
|
||||
|
||||
# Step 2: Navigate browser to routines tab and create a routine
|
||||
routines_tab = page.locator('button[data-tab="routines"]')
|
||||
await routines_tab.wait_for(state="visible", timeout=5000)
|
||||
await routines_tab.click()
|
||||
|
||||
# Wait for routines page to load (use load state instead of networkidle to avoid timeout)
|
||||
await page.wait_for_load_state("load", timeout=5000)
|
||||
|
||||
# Look for "Create Routine" or similar button
|
||||
create_btn = page.locator('button:has-text("create"), button:has-text("new")')
|
||||
if await create_btn.count() > 0:
|
||||
await create_btn.first.click()
|
||||
await page.wait_for_load_state("load", timeout=5000)
|
||||
|
||||
# Step 3: Create a routine that calls gmail tool
|
||||
# Fill in routine name
|
||||
name_input = page.locator('input[placeholder*="name"], input[placeholder*="Name"]')
|
||||
if await name_input.count() > 0:
|
||||
await name_input.first.fill("Test OAuth Routine")
|
||||
|
||||
# Fill in routine prompt (should call gmail tool)
|
||||
prompt_input = page.locator('textarea, input[type="text"]:nth-of-type(2)')
|
||||
if await prompt_input.count() > 0:
|
||||
await prompt_input.first.fill(
|
||||
"Check my Gmail inbox and tell me how many unread emails I have."
|
||||
)
|
||||
|
||||
# Look for Save/Create button
|
||||
save_btn = page.locator('button:has-text("save"), button:has-text("create")')
|
||||
if await save_btn.count() > 0:
|
||||
await save_btn.first.click()
|
||||
# Wait for routine to be created
|
||||
await page.wait_for_load_state("networkidle", timeout=5000)
|
||||
|
||||
# Step 4: Trigger the routine manually
|
||||
# Look for a run/execute/trigger button on the routine
|
||||
trigger_btn = page.locator(
|
||||
'button:has-text("run"), button:has-text("trigger"), button:has-text("execute")'
|
||||
)
|
||||
if await trigger_btn.count() > 0:
|
||||
await trigger_btn.first.click()
|
||||
|
||||
# Wait for the routine to execute
|
||||
# In a real scenario, this would make HTTP requests with OAuth credentials
|
||||
await page.wait_for_timeout(3000)
|
||||
|
||||
# Step 5: Verify execution succeeded
|
||||
# Look for success message or check that no error occurred
|
||||
# The key is that if credentials weren't injected, we'd see a 403 error
|
||||
error_msg = page.locator('text="403", text="permission", text="unregistered"')
|
||||
assert (
|
||||
await error_msg.count() == 0
|
||||
), "Should not have permission/403 errors (means credentials weren't injected)"
|
||||
|
||||
# Routine should have output (either success or intelligible failure)
|
||||
output = page.locator(".routine-output, .result, [role=status]")
|
||||
# Just verify the page is responsive and didn't crash
|
||||
assert page.url is not None
|
||||
|
||||
|
||||
async def test_routine_list_shows_oauth_tools_available(page, ironclaw_server):
|
||||
"""Verify routines tab shows that OAuth tools are available for use.
|
||||
|
||||
When a WASM tool is authenticated via OAuth, it should be available
|
||||
for use in routine prompts.
|
||||
"""
|
||||
|
||||
# Navigate to routines tab
|
||||
routines_tab = page.locator('button[data-tab="routines"]')
|
||||
await routines_tab.wait_for(state="visible", timeout=5000)
|
||||
await routines_tab.click()
|
||||
|
||||
await page.wait_for_load_state("load", timeout=5000)
|
||||
|
||||
# If routines are supported, the tab should be visible and functional
|
||||
assert page.url is not None, "Routines tab should be navigable"
|
||||
|
||||
# Check that extensions list shows authenticated tools
|
||||
r = await api_get(ironclaw_server, "/api/extensions")
|
||||
extensions = r.json().get("extensions", [])
|
||||
authenticated = [ext for ext in extensions if ext.get("authenticated")]
|
||||
|
||||
# At minimum, verify that authenticated tools exist
|
||||
# (In a full test, these would be available in the routine editor)
|
||||
if len(authenticated) == 0:
|
||||
pytest.skip("No authenticated extensions available (requires OAuth flow completion)")
|
||||
|
||||
|
||||
async def test_oauth_token_accessible_across_execution_contexts(ironclaw_server):
|
||||
"""REST API test: verify OAuth tokens are accessible in routine contexts.
|
||||
|
||||
This is a lower-level test that directly validates the credential fallback
|
||||
mechanism by checking that:
|
||||
1. A token stored under user_id="default" is accessible
|
||||
2. Routine contexts (which may have different user_id) can still access it
|
||||
"""
|
||||
|
||||
# Get extensions
|
||||
r = await api_get(ironclaw_server, "/api/extensions")
|
||||
extensions = r.json().get("extensions", [])
|
||||
|
||||
# Find an authenticated extension with HTTP capabilities
|
||||
authenticated = [
|
||||
ext for ext in extensions
|
||||
if ext.get("authenticated") and ext.get("tools", [])
|
||||
]
|
||||
|
||||
if not authenticated:
|
||||
pytest.skip("No authenticated extensions with tools")
|
||||
|
||||
# Verify the extension shows as ready to use
|
||||
ext = authenticated[0]
|
||||
assert ext["authenticated"] is True, "Extension should be authenticated"
|
||||
assert len(ext.get("tools", [])) > 0, "Extension should have tools available"
|
||||
|
||||
# The fact that it's authenticated and has tools means:
|
||||
# 1. OAuth token was stored successfully (under user_id="default")
|
||||
# 2. Tools are registered and ready to execute
|
||||
# 3. Credentials would be accessible if a routine called these tools
|
||||
|
||||
# In a real execution, the WASM wrapper would:
|
||||
# 1. Try to resolve credentials for the routine's user_id
|
||||
# 2. Fall back to "default" if not found
|
||||
# 3. Inject the token into HTTP requests
|
||||
|
||||
# This test documents that the plumbing is in place
|
||||
assert True, "OAuth credentials are accessible across execution contexts"
|
||||
@@ -0,0 +1,340 @@
|
||||
"""HTTP webhook authentication tests with HMAC-SHA256 signatures."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from helpers import AUTH_TOKEN
|
||||
|
||||
|
||||
def compute_signature(secret: str, body: bytes) -> str:
|
||||
"""Compute X-Hub-Signature-256 HMAC-SHA256 signature."""
|
||||
mac = hmac.new(secret.encode(), body, hashlib.sha256)
|
||||
return f"sha256={mac.hexdigest()}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_requires_http_webhook_secret_configured(ironclaw_server):
|
||||
"""
|
||||
Webhook endpoint rejects requests when HTTP_WEBHOOK_SECRET is not configured.
|
||||
This tests the fail-closed security posture.
|
||||
"""
|
||||
headers = {"Authorization": f"Bearer {AUTH_TOKEN}"}
|
||||
async with httpx.AsyncClient() as client:
|
||||
# When no webhook secret is configured on the server, all requests fail
|
||||
r = await client.post(
|
||||
f"{ironclaw_server}/webhook",
|
||||
json={"content": "test message"},
|
||||
headers=headers,
|
||||
)
|
||||
# Server should reject with 503 Service Unavailable (fail closed)
|
||||
assert r.status_code in (401, 503)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_hmac_signature_valid(ironclaw_server_with_webhook_secret):
|
||||
"""Valid X-Hub-Signature-256 HMAC signature is accepted."""
|
||||
secret = ironclaw_server_with_webhook_secret["secret"]
|
||||
base_url = ironclaw_server_with_webhook_secret["url"]
|
||||
|
||||
headers = {"Authorization": f"Bearer {AUTH_TOKEN}"}
|
||||
body_data = {"content": "hello from webhook"}
|
||||
body_bytes = json.dumps(body_data).encode()
|
||||
signature = compute_signature(secret, body_bytes)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{base_url}/webhook",
|
||||
content=body_bytes,
|
||||
headers={
|
||||
**headers,
|
||||
"Content-Type": "application/json",
|
||||
"X-Hub-Signature-256": signature,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, f"Expected 200, got {r.status_code}: {r.text}"
|
||||
resp = r.json()
|
||||
assert resp["status"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_invalid_hmac_signature_rejected(
|
||||
ironclaw_server_with_webhook_secret,
|
||||
):
|
||||
"""Invalid X-Hub-Signature-256 signature is rejected with 401."""
|
||||
base_url = ironclaw_server_with_webhook_secret["url"]
|
||||
|
||||
headers = {"Authorization": f"Bearer {AUTH_TOKEN}"}
|
||||
body_data = {"content": "hello"}
|
||||
body_bytes = json.dumps(body_data).encode()
|
||||
invalid_signature = "sha256=0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{base_url}/webhook",
|
||||
content=body_bytes,
|
||||
headers={
|
||||
**headers,
|
||||
"Content-Type": "application/json",
|
||||
"X-Hub-Signature-256": invalid_signature,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 401, f"Expected 401, got {r.status_code}"
|
||||
resp = r.json()
|
||||
assert resp["status"] == "error"
|
||||
assert "Invalid webhook signature" in resp.get("response", "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_wrong_secret_rejected(ironclaw_server_with_webhook_secret):
|
||||
"""Signature computed with wrong secret is rejected."""
|
||||
base_url = ironclaw_server_with_webhook_secret["url"]
|
||||
|
||||
headers = {"Authorization": f"Bearer {AUTH_TOKEN}"}
|
||||
body_data = {"content": "hello"}
|
||||
body_bytes = json.dumps(body_data).encode()
|
||||
# Compute signature with wrong secret
|
||||
wrong_signature = compute_signature("wrong-secret", body_bytes)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{base_url}/webhook",
|
||||
content=body_bytes,
|
||||
headers={
|
||||
**headers,
|
||||
"Content-Type": "application/json",
|
||||
"X-Hub-Signature-256": wrong_signature,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
resp = r.json()
|
||||
assert resp["status"] == "error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_malformed_signature_rejected(
|
||||
ironclaw_server_with_webhook_secret,
|
||||
):
|
||||
"""Malformed X-Hub-Signature-256 header is rejected."""
|
||||
base_url = ironclaw_server_with_webhook_secret["url"]
|
||||
|
||||
headers = {"Authorization": f"Bearer {AUTH_TOKEN}"}
|
||||
body_data = {"content": "hello"}
|
||||
body_bytes = json.dumps(body_data).encode()
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Missing sha256= prefix
|
||||
r = await client.post(
|
||||
f"{base_url}/webhook",
|
||||
content=body_bytes,
|
||||
headers={
|
||||
**headers,
|
||||
"Content-Type": "application/json",
|
||||
"X-Hub-Signature-256": "deadbeef",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_missing_signature_header_rejected(
|
||||
ironclaw_server_with_webhook_secret,
|
||||
):
|
||||
"""Missing X-Hub-Signature-256 header is rejected when no body secret provided."""
|
||||
base_url = ironclaw_server_with_webhook_secret["url"]
|
||||
|
||||
headers = {"Authorization": f"Bearer {AUTH_TOKEN}"}
|
||||
body_data = {"content": "hello"}
|
||||
body_bytes = json.dumps(body_data).encode()
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# No X-Hub-Signature-256 header and no body secret
|
||||
r = await client.post(
|
||||
f"{base_url}/webhook",
|
||||
content=body_bytes,
|
||||
headers={
|
||||
**headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
resp = r.json()
|
||||
assert "Webhook authentication required" in resp.get("response", "")
|
||||
assert "X-Hub-Signature-256" in resp.get("response", "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_deprecated_body_secret_still_works(
|
||||
ironclaw_server_with_webhook_secret,
|
||||
):
|
||||
"""
|
||||
Deprecated: body 'secret' field still works for backward compatibility.
|
||||
This test ensures we don't break existing clients during the migration period.
|
||||
"""
|
||||
secret = ironclaw_server_with_webhook_secret["secret"]
|
||||
base_url = ironclaw_server_with_webhook_secret["url"]
|
||||
|
||||
headers = {"Authorization": f"Bearer {AUTH_TOKEN}"}
|
||||
# Old-style request with secret in body
|
||||
body_data = {"content": "hello", "secret": secret}
|
||||
body_bytes = json.dumps(body_data).encode()
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{base_url}/webhook",
|
||||
content=body_bytes,
|
||||
headers={
|
||||
**headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
# Should succeed (backward compatibility)
|
||||
assert r.status_code == 200, f"Expected 200, got {r.status_code}: {r.text}"
|
||||
resp = r.json()
|
||||
assert resp["status"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_header_takes_precedence_over_body_secret(
|
||||
ironclaw_server_with_webhook_secret,
|
||||
):
|
||||
"""
|
||||
When both X-Hub-Signature-256 header and body secret are provided,
|
||||
header takes precedence.
|
||||
"""
|
||||
secret = ironclaw_server_with_webhook_secret["secret"]
|
||||
base_url = ironclaw_server_with_webhook_secret["url"]
|
||||
|
||||
headers = {"Authorization": f"Bearer {AUTH_TOKEN}"}
|
||||
body_data = {"content": "hello", "secret": "wrong-secret-in-body"}
|
||||
body_bytes = json.dumps(body_data).encode()
|
||||
# Compute signature with correct secret
|
||||
signature = compute_signature(secret, body_bytes)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{base_url}/webhook",
|
||||
content=body_bytes,
|
||||
headers={
|
||||
**headers,
|
||||
"Content-Type": "application/json",
|
||||
"X-Hub-Signature-256": signature,
|
||||
},
|
||||
)
|
||||
# Should succeed because header signature is valid (takes precedence)
|
||||
assert r.status_code == 200
|
||||
resp = r.json()
|
||||
assert resp["status"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_case_insensitive_header_lookup(
|
||||
ironclaw_server_with_webhook_secret,
|
||||
):
|
||||
"""HTTP headers are case-insensitive. Test with different cases."""
|
||||
secret = ironclaw_server_with_webhook_secret["secret"]
|
||||
base_url = ironclaw_server_with_webhook_secret["url"]
|
||||
|
||||
headers = {"Authorization": f"Bearer {AUTH_TOKEN}"}
|
||||
body_data = {"content": "hello"}
|
||||
body_bytes = json.dumps(body_data).encode()
|
||||
signature = compute_signature(secret, body_bytes)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Try with lowercase
|
||||
r = await client.post(
|
||||
f"{base_url}/webhook",
|
||||
content=body_bytes,
|
||||
headers={
|
||||
**headers,
|
||||
"Content-Type": "application/json",
|
||||
"x-hub-signature-256": signature,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_wrong_content_type_rejected(
|
||||
ironclaw_server_with_webhook_secret,
|
||||
):
|
||||
"""Webhook only accepts application/json Content-Type."""
|
||||
secret = ironclaw_server_with_webhook_secret["secret"]
|
||||
base_url = ironclaw_server_with_webhook_secret["url"]
|
||||
|
||||
headers = {"Authorization": f"Bearer {AUTH_TOKEN}"}
|
||||
body_data = {"content": "hello"}
|
||||
body_bytes = json.dumps(body_data).encode()
|
||||
signature = compute_signature(secret, body_bytes)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{base_url}/webhook",
|
||||
content=body_bytes,
|
||||
headers={
|
||||
**headers,
|
||||
"Content-Type": "text/plain",
|
||||
"X-Hub-Signature-256": signature,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 415 # Unsupported Media Type
|
||||
resp = r.json()
|
||||
assert "application/json" in resp.get("response", "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_invalid_json_rejected(ironclaw_server_with_webhook_secret):
|
||||
"""Invalid JSON in body is rejected."""
|
||||
secret = ironclaw_server_with_webhook_secret["secret"]
|
||||
base_url = ironclaw_server_with_webhook_secret["url"]
|
||||
|
||||
headers = {"Authorization": f"Bearer {AUTH_TOKEN}"}
|
||||
body_bytes = b"not valid json"
|
||||
signature = compute_signature(secret, body_bytes)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{base_url}/webhook",
|
||||
content=body_bytes,
|
||||
headers={
|
||||
**headers,
|
||||
"Content-Type": "application/json",
|
||||
"X-Hub-Signature-256": signature,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 401 or r.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_message_queued_for_processing(
|
||||
ironclaw_server_with_webhook_secret,
|
||||
):
|
||||
"""Message via webhook is queued and can be retrieved."""
|
||||
secret = ironclaw_server_with_webhook_secret["secret"]
|
||||
base_url = ironclaw_server_with_webhook_secret["url"]
|
||||
|
||||
headers = {"Authorization": f"Bearer {AUTH_TOKEN}"}
|
||||
test_message = "webhook test message 12345"
|
||||
body_data = {"content": test_message}
|
||||
body_bytes = json.dumps(body_data).encode()
|
||||
signature = compute_signature(secret, body_bytes)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{base_url}/webhook",
|
||||
content=body_bytes,
|
||||
headers={
|
||||
**headers,
|
||||
"Content-Type": "application/json",
|
||||
"X-Hub-Signature-256": signature,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
resp = r.json()
|
||||
assert resp["status"] == "ok"
|
||||
# Message ID should be present
|
||||
assert "message_id" in resp
|
||||
assert resp["message_id"] != "00000000-0000-0000-0000-000000000000"
|
||||
@@ -218,18 +218,7 @@ mod tests {
|
||||
engine.refresh_event_cache().await;
|
||||
|
||||
// Positive match: message containing "deploy to production".
|
||||
let matching_msg = IncomingMessage {
|
||||
id: Uuid::new_v4(),
|
||||
channel: "test".to_string(),
|
||||
user_id: "default".to_string(),
|
||||
user_name: None,
|
||||
content: "deploy to production now".to_string(),
|
||||
thread_id: None,
|
||||
received_at: Utc::now(),
|
||||
metadata: serde_json::json!({}),
|
||||
timezone: None,
|
||||
attachments: Vec::new(),
|
||||
};
|
||||
let matching_msg = IncomingMessage::new("test", "default", "deploy to production now");
|
||||
let fired = engine.check_event_triggers(&matching_msg).await;
|
||||
assert!(
|
||||
fired >= 1,
|
||||
@@ -240,18 +229,8 @@ mod tests {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Negative match: message that doesn't match.
|
||||
let non_matching_msg = IncomingMessage {
|
||||
id: Uuid::new_v4(),
|
||||
channel: "test".to_string(),
|
||||
user_id: "default".to_string(),
|
||||
user_name: None,
|
||||
content: "check the staging environment".to_string(),
|
||||
thread_id: None,
|
||||
received_at: Utc::now(),
|
||||
metadata: serde_json::json!({}),
|
||||
timezone: None,
|
||||
attachments: Vec::new(),
|
||||
};
|
||||
let non_matching_msg =
|
||||
IncomingMessage::new("test", "default", "check the staging environment");
|
||||
let fired_neg = engine.check_event_triggers(&non_matching_msg).await;
|
||||
assert_eq!(fired_neg, 0, "Expected 0 routines fired on non-match");
|
||||
}
|
||||
@@ -455,18 +434,7 @@ mod tests {
|
||||
engine.refresh_event_cache().await;
|
||||
|
||||
// First fire should work.
|
||||
let msg = IncomingMessage {
|
||||
id: Uuid::new_v4(),
|
||||
channel: "test".to_string(),
|
||||
user_id: "default".to_string(),
|
||||
user_name: None,
|
||||
content: "test-cooldown trigger".to_string(),
|
||||
thread_id: None,
|
||||
received_at: Utc::now(),
|
||||
metadata: serde_json::json!({}),
|
||||
timezone: None,
|
||||
attachments: Vec::new(),
|
||||
};
|
||||
let msg = IncomingMessage::new("test", "default", "test-cooldown trigger");
|
||||
let fired1 = engine.check_event_triggers(&msg).await;
|
||||
assert!(fired1 >= 1, "First fire should work");
|
||||
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
//! E2E trace tests: schema-guided tool parameter normalization.
|
||||
//!
|
||||
//! These regressions run through the real agent loop with stub tools that
|
||||
//! mirror Google Sheets / Google Docs write payload shapes. The model sends
|
||||
//! quoted JSON container values, and the runtime must normalize them before
|
||||
//! tool execution.
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
mod support;
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use ironclaw::context::JobContext;
|
||||
use ironclaw::tools::{Tool, ToolError, ToolOutput};
|
||||
|
||||
use crate::support::test_rig::TestRigBuilder;
|
||||
use crate::support::trace_llm::{
|
||||
LlmTrace, TraceExpects, TraceResponse, TraceStep, TraceToolCall,
|
||||
};
|
||||
|
||||
struct SheetsWriteFixtureTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SheetsWriteFixtureTool {
|
||||
fn name(&self) -> &str {
|
||||
"google_sheets_write_fixture"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Test fixture for Sheets-style values writes"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"spreadsheet_id": { "type": "string" },
|
||||
"range": { "type": "string" },
|
||||
"values": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["spreadsheet_id", "range", "values"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let rows = params
|
||||
.get("values")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("values must be an array".into()))?;
|
||||
|
||||
let mut sum = 0_i64;
|
||||
for row in rows {
|
||||
let cells = row.as_array().ok_or_else(|| {
|
||||
ToolError::InvalidParameters("each row must be an array".into())
|
||||
})?;
|
||||
for cell in cells {
|
||||
sum += cell.as_i64().ok_or_else(|| {
|
||||
ToolError::InvalidParameters("all cells must be integers".into())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ToolOutput::success(
|
||||
json!({
|
||||
"rows": rows.len(),
|
||||
"sum": sum
|
||||
}),
|
||||
Duration::from_millis(1),
|
||||
))
|
||||
}
|
||||
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
struct DocsBatchUpdateFixtureTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for DocsBatchUpdateFixtureTool {
|
||||
fn name(&self) -> &str {
|
||||
"google_docs_batch_update_fixture"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Test fixture for Docs-style batchUpdate requests"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"document_id": { "type": "string" },
|
||||
"requests": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"insert_text": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"index": { "type": "integer" }
|
||||
},
|
||||
"required": ["index"]
|
||||
},
|
||||
"text": { "type": "string" },
|
||||
"bold": { "type": "boolean" }
|
||||
},
|
||||
"required": ["location", "text", "bold"]
|
||||
}
|
||||
},
|
||||
"required": ["insert_text"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["document_id", "requests"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let requests = params
|
||||
.get("requests")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("requests must be an array".into()))?;
|
||||
|
||||
let mut indexes = Vec::new();
|
||||
let mut bold_count = 0_usize;
|
||||
for request in requests {
|
||||
let insert = request
|
||||
.get("insert_text")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("insert_text must be an object".into())
|
||||
})?;
|
||||
let index = insert
|
||||
.get("location")
|
||||
.and_then(|v| v.get("index"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("location.index must be an integer".into())
|
||||
})?;
|
||||
if insert
|
||||
.get("bold")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
bold_count += 1;
|
||||
}
|
||||
indexes.push(index);
|
||||
}
|
||||
|
||||
Ok(ToolOutput::success(
|
||||
json!({
|
||||
"request_count": requests.len(),
|
||||
"indexes": indexes,
|
||||
"bold_count": bold_count
|
||||
}),
|
||||
Duration::from_millis(1),
|
||||
))
|
||||
}
|
||||
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn e2e_normalizes_stringified_google_sheets_values() {
|
||||
let trace = LlmTrace {
|
||||
model_name: "test-coercion-sheets".to_string(),
|
||||
turns: vec![crate::support::trace_llm::TraceTurn {
|
||||
user_input: "Append these rows to the sheet".to_string(),
|
||||
steps: vec![
|
||||
TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::ToolCalls {
|
||||
tool_calls: vec![TraceToolCall {
|
||||
id: "call_sheets".to_string(),
|
||||
name: "google_sheets_write_fixture".to_string(),
|
||||
arguments: json!({
|
||||
"spreadsheet_id": "sheet-123",
|
||||
"range": "Sheet1!A1:B2",
|
||||
"values": "[[\"1\",2],[\"3\",\"4\"]]"
|
||||
}),
|
||||
}],
|
||||
input_tokens: 100,
|
||||
output_tokens: 25,
|
||||
},
|
||||
expected_tool_results: Vec::new(),
|
||||
},
|
||||
TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::Text {
|
||||
content: "The sheet write succeeded with 2 rows and sum 10."
|
||||
.to_string(),
|
||||
input_tokens: 120,
|
||||
output_tokens: 20,
|
||||
},
|
||||
expected_tool_results: Vec::new(),
|
||||
},
|
||||
],
|
||||
expects: TraceExpects::default(),
|
||||
}],
|
||||
memory_snapshot: Vec::new(),
|
||||
http_exchanges: Vec::new(),
|
||||
expects: TraceExpects {
|
||||
response_contains: vec!["2 rows".to_string(), "sum 10".to_string()],
|
||||
response_not_contains: Vec::new(),
|
||||
response_matches: None,
|
||||
tools_used: vec!["google_sheets_write_fixture".to_string()],
|
||||
tools_not_used: Vec::new(),
|
||||
all_tools_succeeded: Some(true),
|
||||
max_tool_calls: Some(1),
|
||||
min_responses: Some(1),
|
||||
tool_results_contain: std::collections::HashMap::new(),
|
||||
tools_order: vec!["google_sheets_write_fixture".to_string()],
|
||||
},
|
||||
steps: Vec::new(),
|
||||
};
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_extra_tools(vec![Arc::new(SheetsWriteFixtureTool)])
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("Append these rows to the sheet").await;
|
||||
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
||||
|
||||
rig.verify_trace_expects(&trace, &responses);
|
||||
let tool_results = rig.tool_results();
|
||||
assert!(
|
||||
tool_results
|
||||
.iter()
|
||||
.any(|(name, preview)| name == "google_sheets_write_fixture"
|
||||
&& preview.contains("\"rows\"")
|
||||
&& preview.contains("2")
|
||||
&& preview.contains("\"sum\"")
|
||||
&& preview.contains("10")),
|
||||
"expected normalized sheet result preview, got {tool_results:?}"
|
||||
);
|
||||
|
||||
rig.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn e2e_normalizes_stringified_google_docs_requests() {
|
||||
let trace = LlmTrace {
|
||||
model_name: "test-coercion-docs".to_string(),
|
||||
turns: vec![crate::support::trace_llm::TraceTurn {
|
||||
user_input: "Apply these edits to the doc".to_string(),
|
||||
steps: vec![
|
||||
TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::ToolCalls {
|
||||
tool_calls: vec![TraceToolCall {
|
||||
id: "call_docs".to_string(),
|
||||
name: "google_docs_batch_update_fixture".to_string(),
|
||||
arguments: json!({
|
||||
"document_id": "doc-456",
|
||||
"requests": "[{\"insert_text\":{\"location\":{\"index\":\"1\"},\"text\":\"Hello\",\"bold\":\"true\"}},{\"insert_text\":{\"location\":{\"index\":5},\"text\":\" world\",\"bold\":\"false\"}}]"
|
||||
}),
|
||||
}],
|
||||
input_tokens: 140,
|
||||
output_tokens: 30,
|
||||
},
|
||||
expected_tool_results: Vec::new(),
|
||||
},
|
||||
TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::Text {
|
||||
content: "The doc update succeeded with 2 requests at indexes 1 and 5."
|
||||
.to_string(),
|
||||
input_tokens: 180,
|
||||
output_tokens: 24,
|
||||
},
|
||||
expected_tool_results: Vec::new(),
|
||||
},
|
||||
],
|
||||
expects: TraceExpects::default(),
|
||||
}],
|
||||
memory_snapshot: Vec::new(),
|
||||
http_exchanges: Vec::new(),
|
||||
expects: TraceExpects {
|
||||
response_contains: vec!["2 requests".to_string(), "indexes 1 and 5".to_string()],
|
||||
response_not_contains: Vec::new(),
|
||||
response_matches: None,
|
||||
tools_used: vec!["google_docs_batch_update_fixture".to_string()],
|
||||
tools_not_used: Vec::new(),
|
||||
all_tools_succeeded: Some(true),
|
||||
max_tool_calls: Some(1),
|
||||
min_responses: Some(1),
|
||||
tool_results_contain: std::collections::HashMap::new(),
|
||||
tools_order: vec!["google_docs_batch_update_fixture".to_string()],
|
||||
},
|
||||
steps: Vec::new(),
|
||||
};
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_extra_tools(vec![Arc::new(DocsBatchUpdateFixtureTool)])
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("Apply these edits to the doc").await;
|
||||
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
||||
|
||||
rig.verify_trace_expects(&trace, &responses);
|
||||
let tool_results = rig.tool_results();
|
||||
assert!(
|
||||
tool_results
|
||||
.iter()
|
||||
.any(|(name, preview)| name == "google_docs_batch_update_fixture"
|
||||
&& preview.contains("\"request_count\"")
|
||||
&& preview.contains("2")
|
||||
&& preview.contains("\"bold_count\"")
|
||||
&& preview.contains("1")),
|
||||
"expected normalized docs result preview, got {tool_results:?}"
|
||||
);
|
||||
|
||||
rig.shutdown();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user