From 0d828257c074f6cb3b526737498e4a1afd590604 Mon Sep 17 00:00:00 2001 From: github-ops Date: Wed, 24 Jun 2026 15:36:28 +0000 Subject: [PATCH] Split MCP approval integration tests --- .../tests/integration/catalog_access.rs | 456 +----------------- .../catalog_access/approval_access.rs | 455 +++++++++++++++++ 2 files changed, 457 insertions(+), 454 deletions(-) create mode 100644 apps/mcp-server/tests/integration/catalog_access/approval_access.rs diff --git a/apps/mcp-server/tests/integration/catalog_access.rs b/apps/mcp-server/tests/integration/catalog_access.rs index 9a88e84..e743c0b 100644 --- a/apps/mcp-server/tests/integration/catalog_access.rs +++ b/apps/mcp-server/tests/integration/catalog_access.rs @@ -1,5 +1,7 @@ #![allow(dead_code, unused_imports)] +mod approval_access; + use super::common::*; use std::{ @@ -512,460 +514,6 @@ async fn rejects_initialize_with_approval_platform_api_key() { assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); } -#[tokio::test] -async fn approval_key_lists_and_decides_pending_requests() { - let registry = test_registry().await; - let upstream_base_url = spawn_upstream_server().await; - let operation = test_operation(&upstream_base_url, "crm_human_approval"); - registry - .create_operation(&test_workspace_id(), &operation, Some("alice")) - .await - .unwrap(); - publish_agent_with_bindings( - ®istry, - "sales-human-approval", - vec![binding_for_operation(&operation)], - ) - .await; - let approval = ApprovalRequest { - id: ApprovalRequestId::new("approval_mcp_01"), - workspace_id: test_workspace_id(), - agent_id: test_agent_id("sales-human-approval"), - operation_id: operation.id.clone(), - operation_version: 1, - status: ApprovalRequestStatus::Pending, - risk_level: OperationApprovalRiskLevel::Dangerous, - confirmation_title: "Подтвердите создание лида".to_owned(), - confirmation_body: "Проверьте email перед отправкой в CRM.".to_owned(), - request_payload: json!({"email": "ada@example.com"}), - response_payload: None, - created_at: OffsetDateTime::now_utc(), - expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(5), - decided_at: None, - decided_by_key_id: None, - decision_note: None, - }; - registry - .create_approval_request(CreateApprovalRequest { - approval: &approval, - }) - .await - .unwrap(); - - let approval_key = - create_approval_platform_api_key(®istry, "sales-human-approval", "approval-http").await; - let mcp_key = create_platform_api_key( - ®istry, - "sales-human-approval", - "mcp-human-approval", - &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], - ) - .await; - let base_url = spawn_mcp_server(build_test_app( - registry.clone(), - Duration::from_millis(0), - Some("https://crank.example.com".to_owned()), - )) - .await; - let client = reqwest::Client::new(); - let approvals_url = format!( - "{}/approvals", - agent_mcp_url(&base_url, "sales-human-approval") - ); - - let rejected = client - .get(&approvals_url) - .header(header::AUTHORIZATION, format!("Bearer {mcp_key}")) - .send() - .await - .unwrap(); - assert_eq!(rejected.status(), reqwest::StatusCode::UNAUTHORIZED); - - let pending = client - .get(&approvals_url) - .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) - .send() - .await - .unwrap(); - assert_eq!(pending.status(), reqwest::StatusCode::OK); - let pending_body = pending.json::().await.unwrap(); - assert_eq!(pending_body["items"].as_array().unwrap().len(), 1); - assert_eq!( - pending_body["items"][0]["approval"]["confirmation_title"], - "Подтвердите создание лида" - ); - - let approve_url = format!( - "{}/approvals/{}/approve", - agent_mcp_url(&base_url, "sales-human-approval"), - approval.id - ); - let approved = client - .post(&approve_url) - .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) - .json(&json!({ "approve": "yes", "note": "confirmed by test" })) - .send() - .await - .unwrap(); - assert_eq!(approved.status(), reqwest::StatusCode::OK); - let approved_body = approved.json::().await.unwrap(); - assert_eq!( - approved_body["approval"]["status"], - Value::String("completed".to_owned()) - ); - assert_eq!( - approved_body["approval"]["response_payload"], - json!({ "id": "lead_123" }) - ); - - let status_url = format!( - "{}/approvals/{}", - agent_mcp_url(&base_url, "sales-human-approval"), - approval.id - ); - let current = client - .get(&status_url) - .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) - .send() - .await - .unwrap(); - assert_eq!(current.status(), reqwest::StatusCode::OK); - let current_body = current.json::().await.unwrap(); - assert_eq!( - current_body["approval"]["status"], - Value::String("completed".to_owned()) - ); - assert_eq!( - current_body["approval"]["response_payload"], - json!({ "id": "lead_123" }) - ); - - let repeated_approve = client - .post(&approve_url) - .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) - .json(&json!({ "approve": "yes", "note": "duplicate confirmation" })) - .send() - .await - .unwrap(); - assert_eq!(repeated_approve.status(), reqwest::StatusCode::OK); - let repeated_body = repeated_approve.json::().await.unwrap(); - assert_eq!( - repeated_body["approval"]["status"], - Value::String("completed".to_owned()) - ); - assert_eq!( - repeated_body["approval"]["response_payload"], - json!({ "id": "lead_123" }) - ); - - let pending_after = client - .get(&approvals_url) - .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - assert!(pending_after["items"].as_array().unwrap().is_empty()); - - let logs = registry - .list_invocation_logs(ListInvocationLogsQuery { - workspace_id: &test_workspace_id(), - level: None, - search_text: None, - source: Some(InvocationSource::AgentToolCall), - operation_id: Some(&operation.id), - agent_id: Some(&test_agent_id("sales-human-approval")), - created_after: None, - limit: 10, - }) - .await - .unwrap(); - assert_eq!(logs.len(), 1); -} - -#[tokio::test] -async fn approval_key_denies_without_executing_upstream() { - let registry = test_registry().await; - let upstream_base_url = spawn_upstream_server().await; - let operation = test_operation(&upstream_base_url, "crm_human_deny"); - registry - .create_operation(&test_workspace_id(), &operation, Some("alice")) - .await - .unwrap(); - publish_agent_with_bindings( - ®istry, - "sales-human-deny", - vec![binding_for_operation(&operation)], - ) - .await; - let approval = ApprovalRequest { - id: ApprovalRequestId::new("approval_mcp_deny_01"), - workspace_id: test_workspace_id(), - agent_id: test_agent_id("sales-human-deny"), - operation_id: operation.id.clone(), - operation_version: 1, - status: ApprovalRequestStatus::Pending, - risk_level: OperationApprovalRiskLevel::Dangerous, - confirmation_title: "Подтвердите создание лида".to_owned(), - confirmation_body: "Проверьте email перед отправкой в CRM.".to_owned(), - request_payload: json!({"email": "deny@example.com"}), - response_payload: None, - created_at: OffsetDateTime::now_utc(), - expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(5), - decided_at: None, - decided_by_key_id: None, - decision_note: None, - }; - registry - .create_approval_request(CreateApprovalRequest { - approval: &approval, - }) - .await - .unwrap(); - - let approval_key = - create_approval_platform_api_key(®istry, "sales-human-deny", "approval-deny-http").await; - let base_url = spawn_mcp_server(build_test_app( - registry.clone(), - Duration::from_millis(0), - Some("https://crank.example.com".to_owned()), - )) - .await; - let client = reqwest::Client::new(); - let deny_url = format!( - "{}/approvals/{}/deny", - agent_mcp_url(&base_url, "sales-human-deny"), - approval.id - ); - - let denied = client - .post(&deny_url) - .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) - .json(&json!({ "approve": "no", "note": "rejected by test" })) - .send() - .await - .unwrap(); - assert_eq!(denied.status(), reqwest::StatusCode::OK); - let denied_body = denied.json::().await.unwrap(); - assert_eq!( - denied_body["approval"]["status"], - Value::String("denied".to_owned()) - ); - - let repeated_deny = client - .post(&deny_url) - .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) - .json(&json!({ "approve": "no", "note": "duplicate rejection" })) - .send() - .await - .unwrap(); - assert_eq!(repeated_deny.status(), reqwest::StatusCode::OK); - let repeated_body = repeated_deny.json::().await.unwrap(); - assert_eq!( - repeated_body["approval"]["status"], - Value::String("denied".to_owned()) - ); - - let logs = registry - .list_invocation_logs(ListInvocationLogsQuery { - workspace_id: &test_workspace_id(), - level: None, - search_text: None, - source: Some(InvocationSource::AgentToolCall), - operation_id: Some(&operation.id), - agent_id: Some(&test_agent_id("sales-human-deny")), - created_after: None, - limit: 10, - }) - .await - .unwrap(); - assert!(logs.is_empty()); -} - -#[tokio::test] -async fn approval_key_expires_without_executing_upstream() { - let registry = test_registry().await; - let upstream_base_url = spawn_upstream_server().await; - let operation = test_operation(&upstream_base_url, "crm_human_expired"); - registry - .create_operation(&test_workspace_id(), &operation, Some("alice")) - .await - .unwrap(); - publish_agent_with_bindings( - ®istry, - "sales-human-expired", - vec![binding_for_operation(&operation)], - ) - .await; - let approval = ApprovalRequest { - id: ApprovalRequestId::new("approval_mcp_expired_01"), - workspace_id: test_workspace_id(), - agent_id: test_agent_id("sales-human-expired"), - operation_id: operation.id.clone(), - operation_version: 1, - status: ApprovalRequestStatus::Pending, - risk_level: OperationApprovalRiskLevel::Dangerous, - confirmation_title: "Подтвердите создание лида".to_owned(), - confirmation_body: "Проверьте email перед отправкой в CRM.".to_owned(), - request_payload: json!({"email": "expired@example.com"}), - response_payload: None, - created_at: OffsetDateTime::now_utc() - time::Duration::minutes(10), - expires_at: OffsetDateTime::now_utc() - time::Duration::minutes(5), - decided_at: None, - decided_by_key_id: None, - decision_note: None, - }; - registry - .create_approval_request(CreateApprovalRequest { - approval: &approval, - }) - .await - .unwrap(); - - let approval_key = - create_approval_platform_api_key(®istry, "sales-human-expired", "approval-expired-http") - .await; - let base_url = spawn_mcp_server(build_test_app( - registry.clone(), - Duration::from_millis(0), - Some("https://crank.example.com".to_owned()), - )) - .await; - let client = reqwest::Client::new(); - let approve_url = format!( - "{}/approvals/{}/approve", - agent_mcp_url(&base_url, "sales-human-expired"), - approval.id - ); - - let expired = client - .post(&approve_url) - .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) - .json(&json!({ "approve": "yes", "note": "too late" })) - .send() - .await - .unwrap(); - assert_eq!(expired.status(), reqwest::StatusCode::OK); - let expired_body = expired.json::().await.unwrap(); - assert_eq!( - expired_body["approval"]["status"], - Value::String("expired".to_owned()) - ); - - let logs = registry - .list_invocation_logs(ListInvocationLogsQuery { - workspace_id: &test_workspace_id(), - level: None, - search_text: None, - source: Some(InvocationSource::AgentToolCall), - operation_id: Some(&operation.id), - agent_id: Some(&test_agent_id("sales-human-expired")), - created_after: None, - limit: 10, - }) - .await - .unwrap(); - assert!(logs.is_empty()); -} - -#[tokio::test] -async fn tool_call_with_approval_policy_creates_pending_request() { - let registry = test_registry().await; - let upstream_base_url = spawn_upstream_server().await; - let mut operation = test_operation(&upstream_base_url, "crm_requires_human_approval"); - operation.execution_config.approval_policy = Some(OperationApprovalPolicy { - required: true, - risk_level: OperationApprovalRiskLevel::Dangerous, - confirmation_title: "Подтвердите создание лида".to_owned(), - confirmation_body_template: "Проверьте email перед отправкой в CRM.".to_owned(), - ttl_seconds: 300, - show_payload_preview: true, - payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson, - }); - registry - .create_operation(&test_workspace_id(), &operation, Some("alice")) - .await - .unwrap(); - registry - .publish_operation(PublishRequest { - workspace_id: &test_workspace_id(), - operation_id: &operation.id, - version: 1, - published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), - published_by: Some("alice"), - }) - .await - .unwrap(); - publish_agent_for_operation(®istry, &operation, "sales-gated").await; - let api_key = create_platform_api_key( - ®istry, - "sales-gated", - "mcp-gated", - &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], - ) - .await; - let approval_key = - create_approval_platform_api_key(®istry, "sales-gated", "approval-gated").await; - - let base_url = spawn_mcp_server(build_test_app( - registry, - Duration::from_millis(0), - Some("https://crank.example.com".to_owned()), - )) - .await; - let client = reqwest::Client::new(); - let mcp_url = agent_mcp_url(&base_url, "sales-gated"); - let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; - - let tool_result = post_jsonrpc( - &client, - &mcp_url, - &api_key, - Some(&initialized_session), - json!({ - "jsonrpc": "2.0", - "id": 9, - "method": "tools/call", - "params": { - "name": "crm_requires_human_approval", - "arguments": { - "email": "ada@example.com" - } - } - }), - ) - .await; - - assert_eq!( - tool_result["result"]["structuredContent"]["status"], - "approval_required" - ); - assert_eq!(tool_result["result"]["isError"], false); - let approval_id = tool_result["result"]["structuredContent"]["approval_id"] - .as_str() - .unwrap(); - assert!(approval_id.starts_with("approval_")); - - let approvals_url = format!("{}/approvals", agent_mcp_url(&base_url, "sales-gated")); - let pending = client - .get(&approvals_url) - .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - assert_eq!(pending["items"].as_array().unwrap().len(), 1); - assert_eq!(pending["items"][0]["approval"]["id"], approval_id); - assert_eq!( - pending["items"][0]["approval"]["request_payload"]["email"], - "ada@example.com" - ); -} - #[tokio::test] async fn rejects_tool_call_with_read_only_platform_api_key() { let registry = test_registry().await; diff --git a/apps/mcp-server/tests/integration/catalog_access/approval_access.rs b/apps/mcp-server/tests/integration/catalog_access/approval_access.rs new file mode 100644 index 0000000..22d5dd3 --- /dev/null +++ b/apps/mcp-server/tests/integration/catalog_access/approval_access.rs @@ -0,0 +1,455 @@ +use super::*; + +#[tokio::test] +async fn approval_key_lists_and_decides_pending_requests() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let operation = test_operation(&upstream_base_url, "crm_human_approval"); + registry + .create_operation(&test_workspace_id(), &operation, Some("alice")) + .await + .unwrap(); + publish_agent_with_bindings( + ®istry, + "sales-human-approval", + vec![binding_for_operation(&operation)], + ) + .await; + let approval = ApprovalRequest { + id: ApprovalRequestId::new("approval_mcp_01"), + workspace_id: test_workspace_id(), + agent_id: test_agent_id("sales-human-approval"), + operation_id: operation.id.clone(), + operation_version: 1, + status: ApprovalRequestStatus::Pending, + risk_level: OperationApprovalRiskLevel::Dangerous, + confirmation_title: "Подтвердите создание лида".to_owned(), + confirmation_body: "Проверьте email перед отправкой в CRM.".to_owned(), + request_payload: json!({"email": "ada@example.com"}), + response_payload: None, + created_at: OffsetDateTime::now_utc(), + expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(5), + decided_at: None, + decided_by_key_id: None, + decision_note: None, + }; + registry + .create_approval_request(CreateApprovalRequest { + approval: &approval, + }) + .await + .unwrap(); + + let approval_key = + create_approval_platform_api_key(®istry, "sales-human-approval", "approval-http").await; + let mcp_key = create_platform_api_key( + ®istry, + "sales-human-approval", + "mcp-human-approval", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + let base_url = spawn_mcp_server(build_test_app( + registry.clone(), + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let approvals_url = format!( + "{}/approvals", + agent_mcp_url(&base_url, "sales-human-approval") + ); + + let rejected = client + .get(&approvals_url) + .header(header::AUTHORIZATION, format!("Bearer {mcp_key}")) + .send() + .await + .unwrap(); + assert_eq!(rejected.status(), reqwest::StatusCode::UNAUTHORIZED); + + let pending = client + .get(&approvals_url) + .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) + .send() + .await + .unwrap(); + assert_eq!(pending.status(), reqwest::StatusCode::OK); + let pending_body = pending.json::().await.unwrap(); + assert_eq!(pending_body["items"].as_array().unwrap().len(), 1); + assert_eq!( + pending_body["items"][0]["approval"]["confirmation_title"], + "Подтвердите создание лида" + ); + + let approve_url = format!( + "{}/approvals/{}/approve", + agent_mcp_url(&base_url, "sales-human-approval"), + approval.id + ); + let approved = client + .post(&approve_url) + .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) + .json(&json!({ "approve": "yes", "note": "confirmed by test" })) + .send() + .await + .unwrap(); + assert_eq!(approved.status(), reqwest::StatusCode::OK); + let approved_body = approved.json::().await.unwrap(); + assert_eq!( + approved_body["approval"]["status"], + Value::String("completed".to_owned()) + ); + assert_eq!( + approved_body["approval"]["response_payload"], + json!({ "id": "lead_123" }) + ); + + let status_url = format!( + "{}/approvals/{}", + agent_mcp_url(&base_url, "sales-human-approval"), + approval.id + ); + let current = client + .get(&status_url) + .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) + .send() + .await + .unwrap(); + assert_eq!(current.status(), reqwest::StatusCode::OK); + let current_body = current.json::().await.unwrap(); + assert_eq!( + current_body["approval"]["status"], + Value::String("completed".to_owned()) + ); + assert_eq!( + current_body["approval"]["response_payload"], + json!({ "id": "lead_123" }) + ); + + let repeated_approve = client + .post(&approve_url) + .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) + .json(&json!({ "approve": "yes", "note": "duplicate confirmation" })) + .send() + .await + .unwrap(); + assert_eq!(repeated_approve.status(), reqwest::StatusCode::OK); + let repeated_body = repeated_approve.json::().await.unwrap(); + assert_eq!( + repeated_body["approval"]["status"], + Value::String("completed".to_owned()) + ); + assert_eq!( + repeated_body["approval"]["response_payload"], + json!({ "id": "lead_123" }) + ); + + let pending_after = client + .get(&approvals_url) + .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert!(pending_after["items"].as_array().unwrap().is_empty()); + + let logs = registry + .list_invocation_logs(ListInvocationLogsQuery { + workspace_id: &test_workspace_id(), + level: None, + search_text: None, + source: Some(InvocationSource::AgentToolCall), + operation_id: Some(&operation.id), + agent_id: Some(&test_agent_id("sales-human-approval")), + created_after: None, + limit: 10, + }) + .await + .unwrap(); + assert_eq!(logs.len(), 1); +} + +#[tokio::test] +async fn approval_key_denies_without_executing_upstream() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let operation = test_operation(&upstream_base_url, "crm_human_deny"); + registry + .create_operation(&test_workspace_id(), &operation, Some("alice")) + .await + .unwrap(); + publish_agent_with_bindings( + ®istry, + "sales-human-deny", + vec![binding_for_operation(&operation)], + ) + .await; + let approval = ApprovalRequest { + id: ApprovalRequestId::new("approval_mcp_deny_01"), + workspace_id: test_workspace_id(), + agent_id: test_agent_id("sales-human-deny"), + operation_id: operation.id.clone(), + operation_version: 1, + status: ApprovalRequestStatus::Pending, + risk_level: OperationApprovalRiskLevel::Dangerous, + confirmation_title: "Подтвердите создание лида".to_owned(), + confirmation_body: "Проверьте email перед отправкой в CRM.".to_owned(), + request_payload: json!({"email": "deny@example.com"}), + response_payload: None, + created_at: OffsetDateTime::now_utc(), + expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(5), + decided_at: None, + decided_by_key_id: None, + decision_note: None, + }; + registry + .create_approval_request(CreateApprovalRequest { + approval: &approval, + }) + .await + .unwrap(); + + let approval_key = + create_approval_platform_api_key(®istry, "sales-human-deny", "approval-deny-http").await; + let base_url = spawn_mcp_server(build_test_app( + registry.clone(), + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let deny_url = format!( + "{}/approvals/{}/deny", + agent_mcp_url(&base_url, "sales-human-deny"), + approval.id + ); + + let denied = client + .post(&deny_url) + .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) + .json(&json!({ "approve": "no", "note": "rejected by test" })) + .send() + .await + .unwrap(); + assert_eq!(denied.status(), reqwest::StatusCode::OK); + let denied_body = denied.json::().await.unwrap(); + assert_eq!( + denied_body["approval"]["status"], + Value::String("denied".to_owned()) + ); + + let repeated_deny = client + .post(&deny_url) + .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) + .json(&json!({ "approve": "no", "note": "duplicate rejection" })) + .send() + .await + .unwrap(); + assert_eq!(repeated_deny.status(), reqwest::StatusCode::OK); + let repeated_body = repeated_deny.json::().await.unwrap(); + assert_eq!( + repeated_body["approval"]["status"], + Value::String("denied".to_owned()) + ); + + let logs = registry + .list_invocation_logs(ListInvocationLogsQuery { + workspace_id: &test_workspace_id(), + level: None, + search_text: None, + source: Some(InvocationSource::AgentToolCall), + operation_id: Some(&operation.id), + agent_id: Some(&test_agent_id("sales-human-deny")), + created_after: None, + limit: 10, + }) + .await + .unwrap(); + assert!(logs.is_empty()); +} + +#[tokio::test] +async fn approval_key_expires_without_executing_upstream() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let operation = test_operation(&upstream_base_url, "crm_human_expired"); + registry + .create_operation(&test_workspace_id(), &operation, Some("alice")) + .await + .unwrap(); + publish_agent_with_bindings( + ®istry, + "sales-human-expired", + vec![binding_for_operation(&operation)], + ) + .await; + let approval = ApprovalRequest { + id: ApprovalRequestId::new("approval_mcp_expired_01"), + workspace_id: test_workspace_id(), + agent_id: test_agent_id("sales-human-expired"), + operation_id: operation.id.clone(), + operation_version: 1, + status: ApprovalRequestStatus::Pending, + risk_level: OperationApprovalRiskLevel::Dangerous, + confirmation_title: "Подтвердите создание лида".to_owned(), + confirmation_body: "Проверьте email перед отправкой в CRM.".to_owned(), + request_payload: json!({"email": "expired@example.com"}), + response_payload: None, + created_at: OffsetDateTime::now_utc() - time::Duration::minutes(10), + expires_at: OffsetDateTime::now_utc() - time::Duration::minutes(5), + decided_at: None, + decided_by_key_id: None, + decision_note: None, + }; + registry + .create_approval_request(CreateApprovalRequest { + approval: &approval, + }) + .await + .unwrap(); + + let approval_key = + create_approval_platform_api_key(®istry, "sales-human-expired", "approval-expired-http") + .await; + let base_url = spawn_mcp_server(build_test_app( + registry.clone(), + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let approve_url = format!( + "{}/approvals/{}/approve", + agent_mcp_url(&base_url, "sales-human-expired"), + approval.id + ); + + let expired = client + .post(&approve_url) + .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) + .json(&json!({ "approve": "yes", "note": "too late" })) + .send() + .await + .unwrap(); + assert_eq!(expired.status(), reqwest::StatusCode::OK); + let expired_body = expired.json::().await.unwrap(); + assert_eq!( + expired_body["approval"]["status"], + Value::String("expired".to_owned()) + ); + + let logs = registry + .list_invocation_logs(ListInvocationLogsQuery { + workspace_id: &test_workspace_id(), + level: None, + search_text: None, + source: Some(InvocationSource::AgentToolCall), + operation_id: Some(&operation.id), + agent_id: Some(&test_agent_id("sales-human-expired")), + created_after: None, + limit: 10, + }) + .await + .unwrap(); + assert!(logs.is_empty()); +} + +#[tokio::test] +async fn tool_call_with_approval_policy_creates_pending_request() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let mut operation = test_operation(&upstream_base_url, "crm_requires_human_approval"); + operation.execution_config.approval_policy = Some(OperationApprovalPolicy { + required: true, + risk_level: OperationApprovalRiskLevel::Dangerous, + confirmation_title: "Подтвердите создание лида".to_owned(), + confirmation_body_template: "Проверьте email перед отправкой в CRM.".to_owned(), + ttl_seconds: 300, + show_payload_preview: true, + payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson, + }); + registry + .create_operation(&test_workspace_id(), &operation, Some("alice")) + .await + .unwrap(); + registry + .publish_operation(PublishRequest { + workspace_id: &test_workspace_id(), + operation_id: &operation.id, + version: 1, + published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), + published_by: Some("alice"), + }) + .await + .unwrap(); + publish_agent_for_operation(®istry, &operation, "sales-gated").await; + let api_key = create_platform_api_key( + ®istry, + "sales-gated", + "mcp-gated", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + let approval_key = + create_approval_platform_api_key(®istry, "sales-gated", "approval-gated").await; + + let base_url = spawn_mcp_server(build_test_app( + registry, + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let mcp_url = agent_mcp_url(&base_url, "sales-gated"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; + + let tool_result = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 9, + "method": "tools/call", + "params": { + "name": "crm_requires_human_approval", + "arguments": { + "email": "ada@example.com" + } + } + }), + ) + .await; + + assert_eq!( + tool_result["result"]["structuredContent"]["status"], + "approval_required" + ); + assert_eq!(tool_result["result"]["isError"], false); + let approval_id = tool_result["result"]["structuredContent"]["approval_id"] + .as_str() + .unwrap(); + assert!(approval_id.starts_with("approval_")); + + let approvals_url = format!("{}/approvals", agent_mcp_url(&base_url, "sales-gated")); + let pending = client + .get(&approvals_url) + .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert_eq!(pending["items"].as_array().unwrap().len(), 1); + assert_eq!(pending["items"][0]["approval"]["id"], approval_id); + assert_eq!( + pending["items"][0]["approval"]["request_payload"]["email"], + "ada@example.com" + ); +}