#![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>, handle: Option>, } 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 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 { 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::() .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::() .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::().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::() .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>>) { 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>>>, headers: HeaderMap, Json(payload): Json, ) -> Json { *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::() .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::() .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::() .await .unwrap(); assert_eq!(updated["status"], "draft"); let detail = client .get(format!("{base_url}/operations/{operation_id}")) .send() .await .unwrap() .json::() .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::() .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::().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::() .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::() .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::() .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::() .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::().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::() .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::() .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::().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::() .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::().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); }