Files
crank/apps/admin-api/tests/integration/operations_agents.rs
T

947 lines
29 KiB
Rust

#![allow(dead_code, unused_imports)]
use super::common::*;
use std::{
collections::BTreeMap,
env, fmt,
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use async_trait::async_trait;
use axum::{Json, Router, extract::State, http::HeaderMap, routing::post};
use crank_core::{
ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol,
ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId,
};
use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome};
use crank_mapping::{MappingRule, MappingSet};
use crank_registry::PostgresRegistry;
use crank_runtime::SecretCrypto;
use crank_schema::{Schema, SchemaKind};
use serde_json::{Value, json};
use serial_test::serial;
use tokio::net::TcpListener;
use admin_api::{
app::build_app,
auth::{AuthSettings, BootstrapAdminConfig, hash_password},
service::{AdminService, AdminServiceBuilder, OperationPayload},
state::AppState,
};
const DEFAULT_WORKSPACE_ID: &str = "ws_default";
const TEST_AUTH_EMAIL: &str = "owner@crank.local";
const TEST_AUTH_PASSWORD: &str = "test-password";
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
const TEST_SESSION_SECRET: &str = "test-session-secret";
const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000";
struct TestServer {
base_url: String,
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
handle: Option<tokio::task::JoinHandle<()>>,
}
impl Drop for TestServer {
fn drop(&mut self) {
let shutdown = self.shutdown.take();
let handle = self.handle.take();
tokio::task::block_in_place(|| {
if let Some(shutdown) = shutdown {
let _ = shutdown.send(());
}
if let Some(handle) = handle {
let _ = tokio::runtime::Handle::current().block_on(handle);
}
});
}
}
impl fmt::Display for TestServer {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.base_url)
}
}
impl AsRef<str> for TestServer {
fn as_ref(&self) -> &str {
&self.base_url
}
}
struct RejectingIdentityProvider;
#[async_trait]
impl IdentityProvider for RejectingIdentityProvider {
fn id(&self) -> &str {
"rejecting-test-provider"
}
fn kind(&self) -> IdentityProviderKind {
IdentityProviderKind::Password
}
async fn login_password(
&self,
_payload: crank_core::LoginPayload,
) -> Result<LoginOutcome, IdentityError> {
Err(IdentityError::BadCredentials)
}
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn creates_publishes_and_tests_rest_operation() {
let registry = test_registry().await;
let storage_root = test_storage_root("lifecycle");
let (upstream_base_url, observed_upstream_headers) = spawn_correlation_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await;
let client = authorized_client(&base_url).await;
let created = client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload(
&upstream_base_url,
"crm_create_lead",
))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
let listed = client
.get(format!("{base_url}/operations"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let test_run_response = client
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
.header("x-request-id", "req_admin_test_run")
.header(
"traceparent",
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
)
.json(&json!({
"version": 1,
"input": { "email": "user@example.com" }
}))
.send()
.await
.unwrap();
assert_eq!(
test_run_response.headers()["x-trace-id"].to_str().unwrap(),
"0af7651916cd43dd8448eb211c80319c"
);
let test_run = test_run_response.json::<Value>().await.unwrap();
let published = client
.post(format!("{base_url}/operations/{operation_id}/publish"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(listed["items"][0]["name"], "crm_create_lead");
assert_eq!(
listed["items"][0]["target_url"],
format!("{upstream_base_url}/crm/leads")
);
assert_eq!(listed["items"][0]["target_action"], "POST");
assert_eq!(published["published_version"], 1);
assert_eq!(test_run["ok"], true);
assert_eq!(test_run["request_preview"]["body_configured"], true);
let safe_preview = test_run["request_preview"].to_string();
assert!(!safe_preview.contains("user@example.com"));
assert_eq!(test_run["response_preview"]["id"], "lead_123");
let logs = registry
.list_invocation_logs(crank_registry::ListInvocationLogsQuery {
workspace_id: &WorkspaceId::new(DEFAULT_WORKSPACE_ID),
level: None,
status: None,
outcome_group: None,
search_text: None,
source: Some(crank_core::InvocationSource::AdminTestRun),
operation_id: Some(&crank_core::OperationId::new(&operation_id)),
agent_id: None,
created_after: None,
created_before: None,
cursor_created_at: None,
cursor_id: None,
limit: 10,
})
.await
.unwrap();
assert_eq!(logs.len(), 1);
assert_eq!(
logs[0].log.request_id.as_deref(),
Some("req_admin_test_run")
);
assert_eq!(
logs[0].log.trace_id.as_deref(),
Some("0af7651916cd43dd8448eb211c80319c")
);
let upstream_headers = observed_upstream_headers.lock().await;
let upstream_headers = upstream_headers.as_ref().unwrap();
assert_eq!(
upstream_headers["x-request-id"].to_str().unwrap(),
"req_admin_test_run"
);
assert_eq!(
&upstream_headers["traceparent"].to_str().unwrap()[3..35],
"0af7651916cd43dd8448eb211c80319c"
);
}
async fn spawn_correlation_upstream_server() -> (String, Arc<tokio::sync::Mutex<Option<HeaderMap>>>)
{
let observed = Arc::new(tokio::sync::Mutex::new(None));
let app = Router::new()
.route("/crm/leads", post(capture_correlation_and_create_lead))
.with_state(Arc::clone(&observed));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(format!("http://{address}"), observed)
}
async fn capture_correlation_and_create_lead(
State(observed): State<Arc<tokio::sync::Mutex<Option<HeaderMap>>>>,
headers: HeaderMap,
Json(payload): Json<Value>,
) -> Json<Value> {
*observed.lock().await = Some(headers);
Json(json!({
"id": "lead_123",
"status": "created",
"input": payload,
}))
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn updates_archives_and_deletes_operation() {
let registry = test_registry().await;
let storage_root = test_storage_root("operation_mutations");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let created = client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload(
&upstream_base_url,
"crm_mutable_operation",
))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
let listed = client
.get(format!("{base_url}/operations"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(listed["total"], 1);
assert_eq!(listed["items"][0]["category"], "sales");
assert_eq!(
listed["items"][0]["target_url"],
format!("{upstream_base_url}/crm/leads")
);
assert_eq!(listed["items"][0]["target_action"], "POST");
let updated = client
.patch(format!("{base_url}/operations/{operation_id}"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.json(&json!({
"display_name": "Create Lead Updated",
"category": "marketing",
"target": {
"kind": "rest",
"base_url": upstream_base_url,
"method": "POST",
"path_template": "/crm/leads",
"static_headers": {}
},
"input_schema": {
"type": "object",
"required": true,
"nullable": false,
"fields": {
"email": {
"type": "string",
"required": true,
"nullable": false
}
}
},
"output_schema": {
"type": "object",
"required": true,
"nullable": false,
"fields": {
"id": {
"type": "string",
"required": true,
"nullable": false
}
}
},
"input_mapping": {
"rules": [
{
"source": "$.mcp.email",
"target": "$.request.body.email",
"required": true
}
]
},
"output_mapping": {
"rules": [
{
"source": "$.response.body.id",
"target": "$.output.id",
"required": true
}
]
},
"execution_config": {
"timeout_ms": 1000,
"headers": {}
},
"tool_description": {
"title": "Create Lead Updated",
"description": "Creates a CRM lead",
"tags": ["crm"]
}
}))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(updated["status"], "draft");
let detail = client
.get(format!("{base_url}/operations/{operation_id}"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(detail["display_name"], "Create Lead Updated");
assert_eq!(detail["category"], "marketing");
let archived = client
.post(format!("{base_url}/operations/{operation_id}/archive"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(archived["status"], "archived");
let deleted = client
.delete(format!("{base_url}/operations/{operation_id}"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.send()
.await
.unwrap();
assert_eq!(deleted.status(), reqwest::StatusCode::CONFLICT);
let deleted = deleted.json::<Value>().await.unwrap();
assert_eq!(
deleted["error"]["context"]["error_code"],
"operation_delete_forbidden"
);
let retained = client
.get(format!("{base_url}/operations/{operation_id}"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(retained["status"], "archived");
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn creates_binds_and_publishes_agent() {
let registry = test_registry().await;
let storage_root = test_storage_root("agent_lifecycle");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let operation = client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload(
&upstream_base_url,
"crm_create_lead_agent",
))
.send()
.await
.unwrap();
let operation = assert_success_json(operation).await;
let operation_id = operation["operation_id"].as_str().unwrap().to_owned();
client
.post(format!("{base_url}/operations/{operation_id}/publish"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap();
let agent = client
.post(format!("{base_url}/agents"))
.json(&json!({
"slug": "sales-assistant",
"display_name": "Sales Assistant",
"description": "Curated sales toolset",
"instructions": {},
"tool_selection_policy": {}
}))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let agent_id = agent["agent_id"].as_str().unwrap().to_owned();
let bindings = client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!([
{
"operation_id": operation_id,
"operation_version": 1,
"tool_name": "crm_create_lead_agent",
"tool_title": "Create Lead",
"enabled": true
}
]))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let published = client
.post(format!("{base_url}/agents/{agent_id}/publish"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(
bindings["bindings"][0]["tool_name"],
"crm_create_lead_agent"
);
assert_eq!(published["published_version"], 1);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn saves_and_previews_versioned_agent_tool_search_policy() {
let registry = test_registry().await;
let storage_root = test_storage_root("agent_tool_search");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let operation = assert_success_json(
client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload(
&upstream_base_url,
"finance_create_invoice",
))
.send()
.await
.unwrap(),
)
.await;
let operation_id = operation["operation_id"].as_str().unwrap().to_owned();
assert_success_json(
client
.post(format!("{base_url}/operations/{operation_id}/publish"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.json(&json!({"version": 1}))
.send()
.await
.unwrap(),
)
.await;
let agent = assert_success_json(
client
.post(format!("{base_url}/agents"))
.json(&json!({
"slug": "finance-agent",
"display_name": "Finance Agent",
"description": "Finance workflows",
"instructions": {},
"tool_selection_policy": {}
}))
.send()
.await
.unwrap(),
)
.await;
let agent_id = agent["agent_id"].as_str().unwrap().to_owned();
let catalog = json!({
"bindings": [{
"operation_id": operation_id,
"operation_version": 1,
"tool_name": "finance_create_invoice",
"tool_title": "Create Lead",
"tool_description_override": "Creates an invoice for a customer",
"enabled": true
}],
"tool_selection_policy": {
"mode": "search",
"groups": [{
"id": "finance",
"name": "Finance",
"description": "Invoices and payments",
"tool_names": ["finance_create_invoice"]
}],
"search": {"max_results": 5}
}
});
let saved = assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&catalog)
.send()
.await
.unwrap(),
)
.await;
assert_eq!(saved["snapshot"]["tool_selection_policy"]["mode"], "search");
let preview = assert_success_json(
client
.post(format!("{base_url}/agents/tool-search/preview"))
.json(&json!({
"query": "create invoice",
"group_ids": ["finance"],
"bindings": catalog["bindings"],
"tool_selection_policy": catalog["tool_selection_policy"]
}))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(
preview["items"][0]["tool"]["name"],
"finance_create_invoice"
);
let published = assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/publish"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!({"version": 1}))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(published["published_version"], 1);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn agent_binding_rejects_draft_operation_before_publish() {
let registry = test_registry().await;
let storage_root = test_storage_root("agent_publish_filters_drafts");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let published_operation = assert_success_json(
client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload(
&upstream_base_url,
"crm_published_tool",
))
.send()
.await
.unwrap(),
)
.await;
let published_operation_id = published_operation["operation_id"]
.as_str()
.unwrap()
.to_owned();
assert_success_json(
client
.post(format!(
"{base_url}/operations/{published_operation_id}/publish"
))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &published_operation_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap(),
)
.await;
let draft_operation = assert_success_json(
client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload(
&upstream_base_url,
"crm_draft_tool",
))
.send()
.await
.unwrap(),
)
.await;
let draft_operation_id = draft_operation["operation_id"].as_str().unwrap().to_owned();
let agent = assert_success_json(
client
.post(format!("{base_url}/agents"))
.json(&json!({
"slug": "collaborative-agent",
"display_name": "Collaborative Agent",
"description": "Agent with published and draft tools",
"instructions": {},
"tool_selection_policy": {}
}))
.send()
.await
.unwrap(),
)
.await;
let agent_id = agent["agent_id"].as_str().unwrap().to_owned();
let rejected = client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!([
{
"operation_id": published_operation_id,
"operation_version": 1,
"tool_name": "crm_published_tool",
"tool_title": "Published Tool",
"tool_description_override": null,
"enabled": true
},
{
"operation_id": draft_operation_id,
"operation_version": 1,
"tool_name": "crm_draft_tool",
"tool_title": "Draft Tool",
"tool_description_override": null,
"enabled": true
}
]))
.send()
.await
.unwrap();
assert_eq!(rejected.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
let rejected_body = rejected.json::<Value>().await.unwrap();
assert_eq!(
rejected_body["error"]["code"],
"agent_binding_not_published"
);
assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!([
{
"operation_id": published_operation_id,
"operation_version": 1,
"tool_name": "crm_published_tool",
"tool_title": "Published Tool",
"tool_description_override": null,
"enabled": true
}
]))
.send()
.await
.unwrap(),
)
.await;
let published = assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/publish"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap(),
)
.await;
let agent_detail = assert_success_json(
client
.get(format!("{base_url}/agents/{agent_id}"))
.send()
.await
.unwrap(),
)
.await;
let published_version = assert_success_json(
client
.get(format!("{base_url}/agents/{agent_id}/versions/1"))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(published["published_version"], 1);
assert_eq!(agent_detail["current_draft_version"], 1);
assert_eq!(agent_detail["latest_published_version"], 1);
assert_eq!(published_version["bindings"].as_array().unwrap().len(), 1);
assert_eq!(
published_version["bindings"][0]["operation_id"],
published_operation_id
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn updates_lists_and_deletes_agent() {
let registry = test_registry().await;
let storage_root = test_storage_root("agent_mutations");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let operation = client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload(
&upstream_base_url,
"crm_create_lead_agents_page",
))
.send()
.await
.unwrap();
let operation = assert_success_json(operation).await;
let operation_id = operation["operation_id"].as_str().unwrap().to_owned();
client
.post(format!("{base_url}/operations/{operation_id}/publish"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap();
let created = client
.post(format!("{base_url}/agents"))
.json(&json!({
"slug": "support-team",
"display_name": "Support Team",
"description": "Support workflows",
"instructions": {},
"tool_selection_policy": {}
}))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let agent_id = created["agent_id"].as_str().unwrap().to_owned();
client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!([
{
"operation_id": operation_id,
"operation_version": 1,
"tool_name": "crm_create_lead_agents_page",
"tool_title": "Create Lead",
"tool_description_override": null,
"enabled": true
}
]))
.send()
.await
.unwrap();
client
.post(format!("{base_url}/agents/{agent_id}/publish"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap();
let listed = client
.get(format!("{base_url}/agents"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(listed["items"][0]["operation_count"], 1);
assert_eq!(listed["items"][0]["operation_ids"][0], operation_id);
assert_eq!(
listed["items"][0]["mcp_endpoint"],
"/mcp/v1/default/support-team"
);
assert_eq!(listed["items"][0]["status"], "published");
let updated = client
.patch(format!("{base_url}/agents/{agent_id}"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!({
"slug": "support-escalation",
"display_name": "Support Escalation",
"description": "Escalation workflows"
}))
.send()
.await
.unwrap();
assert_eq!(
updated.status(),
reqwest::StatusCode::CONFLICT,
"published Agent summary/slug must not mutate published MCP endpoint identity"
);
let updated = updated.json::<Value>().await.unwrap();
assert_eq!(
updated["error"]["context"]["error_code"],
"agent_published_summary_immutable"
);
let detail = client
.get(format!("{base_url}/agents/{agent_id}"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(detail["slug"], "support-team");
assert_eq!(detail["display_name"], "Support Team");
assert_eq!(detail["operation_count"], 1);
assert_eq!(detail["mcp_endpoint"], "/mcp/v1/default/support-team");
let delete_response = client
.delete(format!("{base_url}/agents/{agent_id}"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.send()
.await
.unwrap();
assert_eq!(
delete_response.status(),
reqwest::StatusCode::CONFLICT,
"published Agent must not be hard-deleted because immutable versions/history must remain"
);
let delete_body = delete_response.json::<Value>().await.unwrap();
assert_eq!(
delete_body["error"]["context"]["error_code"],
"agent_delete_forbidden"
);
let still_present = client
.get(format!("{base_url}/agents/{agent_id}"))
.send()
.await
.unwrap();
assert_eq!(still_present.status(), reqwest::StatusCode::OK);
}