#![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, 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"; 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 = 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_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 published = client .post(format!("{base_url}/operations/{operation_id}/publish")) .json(&json!({ "version": 1 })) .send() .await .unwrap() .json::() .await .unwrap(); let test_run = client .post(format!("{base_url}/operations/{operation_id}/test-runs")) .json(&json!({ "version": 1, "input": { "email": "user@example.com" } })) .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"]["email"], "user@example.com" ); assert_eq!(test_run["response_preview"]["id"], "lead_123"); } #[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}")) .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")) .send() .await .unwrap() .json::() .await .unwrap(); assert_eq!(archived["status"], "archived"); let deleted = client .delete(format!("{base_url}/operations/{operation_id}")) .send() .await .unwrap() .json::() .await .unwrap(); assert_eq!(deleted["operation_id"], operation_id); let missing = client .get(format!("{base_url}/operations/{operation_id}")) .send() .await .unwrap(); let missing_status = missing.status(); let missing = missing.json::().await.unwrap(); assert_eq!(missing_status, reqwest::StatusCode::NOT_FOUND); assert_eq!(missing["error"]["code"], "not_found"); assert_eq!( missing["error"]["context"], json!({ "operation_id": operation_id }) ); } #[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")) .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")) .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")) .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 agent_publish_skips_draft_operation_bindings_and_preserves_draft() { 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" )) .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(); assert_success_json( client .post(format!("{base_url}/agents/{agent_id}/bindings")) .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(), ) .await; let published = assert_success_json( client .post(format!("{base_url}/agents/{agent_id}/publish")) .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; let draft_version = assert_success_json( client .get(format!("{base_url}/agents/{agent_id}/versions/2")) .send() .await .unwrap(), ) .await; assert_eq!(published["published_version"], 1); assert_eq!(agent_detail["current_draft_version"], 2); 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 ); assert_eq!(draft_version["bindings"].as_array().unwrap().len(), 2); } #[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")) .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")) .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")) .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}")) .json(&json!({ "slug": "support-escalation", "display_name": "Support Escalation", "description": "Escalation workflows" })) .send() .await .unwrap() .json::() .await .unwrap(); assert_eq!(updated["agent_id"], agent_id); let detail = client .get(format!("{base_url}/agents/{agent_id}")) .send() .await .unwrap() .json::() .await .unwrap(); assert_eq!(detail["slug"], "support-escalation"); assert_eq!(detail["display_name"], "Support Escalation"); assert_eq!(detail["operation_count"], 1); assert_eq!(detail["mcp_endpoint"], "/mcp/v1/default/support-escalation"); let deleted = client .delete(format!("{base_url}/agents/{agent_id}")) .send() .await .unwrap() .json::() .await .unwrap(); assert_eq!(deleted["agent_id"], agent_id); let missing = client .get(format!("{base_url}/agents/{agent_id}")) .send() .await .unwrap(); let missing_status = missing.status(); let missing = missing.json::().await.unwrap(); assert_eq!(missing_status, reqwest::StatusCode::NOT_FOUND); assert_eq!(missing["error"]["code"], "not_found"); assert_eq!( missing["error"]["context"], json!({ "agent_id": agent_id }) ); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn unpublishes_and_archives_agent() { let registry = test_registry().await; let storage_root = test_storage_root("agent_statuses"); 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, "crm_create_lead_agent_status", )) .send() .await .unwrap(), ) .await; let operation_id = operation["operation_id"].as_str().unwrap().to_owned(); client .post(format!("{base_url}/operations/{operation_id}/publish")) .json(&json!({ "version": 1 })) .send() .await .unwrap(); let created = assert_success_json( client .post(format!("{base_url}/agents")) .json(&json!({ "slug": "sales-routing", "display_name": "Sales Routing", "description": "Routing agent", "instructions": {}, "tool_selection_policy": {} })) .send() .await .unwrap(), ) .await; let agent_id = created["agent_id"].as_str().unwrap().to_owned(); client .post(format!("{base_url}/agents/{agent_id}/bindings")) .json(&json!([ { "operation_id": operation_id, "operation_version": 1, "tool_name": "crm_create_lead_agent_status", "tool_title": "Create Lead", "tool_description_override": null, "enabled": true } ])) .send() .await .unwrap(); client .post(format!("{base_url}/agents/{agent_id}/publish")) .json(&json!({ "version": 1 })) .send() .await .unwrap(); let unpublished = assert_success_json( client .post(format!("{base_url}/agents/{agent_id}/unpublish")) .json(&json!({})) .send() .await .unwrap(), ) .await; assert_eq!(unpublished["agent_id"], agent_id); let draft_detail = assert_success_json( client .get(format!("{base_url}/agents/{agent_id}")) .send() .await .unwrap(), ) .await; assert_eq!(draft_detail["status"], "draft"); assert_eq!(draft_detail["latest_published_version"], 1); let archived = assert_success_json( client .post(format!("{base_url}/agents/{agent_id}/archive")) .json(&json!({})) .send() .await .unwrap(), ) .await; assert_eq!(archived["agent_id"], agent_id); let archived_detail = assert_success_json( client .get(format!("{base_url}/agents/{agent_id}")) .send() .await .unwrap(), ) .await; assert_eq!(archived_detail["status"], "archived"); }