use axum::{ Router, middleware, routing::{delete, get, post}, }; use crate::{ auth::{require_session, require_workspace_session}, rate_limit::apply_api_rate_limit, request_context::apply_request_context, routes::{ access::{ create_invitation, delete_invitation, delete_membership, delete_workspace, export_workspace, list_invitations, list_memberships, update_membership, }, agents::{ archive_agent, create_agent, create_agent_platform_api_key, delete_agent, delete_agent_platform_api_key, get_agent, get_agent_version, list_agent_platform_api_keys, list_agents, publish_agent, revoke_agent_platform_api_key, save_agent_bindings, unpublish_agent, update_agent, }, auth::{ change_password, get_profile, get_session, login, logout, update_current_workspace, update_profile, }, auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles}, capabilities::get_capabilities, machine_auth::{issue_agent_token, issue_one_time_agent_token}, observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs}, operations::{ archive_operation, create_operation, create_version, delete_operation, export_operation, generate_draft, get_operation, get_operation_version, list_operations, publish_operation, run_test, update_operation, upload_input_json, upload_output_json, }, secrets::{create_secret, delete_secret, get_secret, list_secrets, rotate_secret}, streaming::list_protocol_capabilities, workspaces::{create_workspace, get_workspace, list_workspaces, update_workspace}, }, state::AppState, }; pub fn build_app(state: AppState) -> Router { let workspace_router = Router::new() .route("/operations", get(list_operations).post(create_operation)) .route("/operations/import", post(import_operation)) .route( "/operations/{operation_id}", get(get_operation) .patch(update_operation) .delete(delete_operation), ) .route("/operations/{operation_id}/versions", post(create_version)) .route( "/operations/{operation_id}/versions/{version}", get(get_operation_version), ) .route( "/operations/{operation_id}/publish", post(publish_operation), ) .route( "/operations/{operation_id}/archive", post(archive_operation), ) .route("/operations/{operation_id}/test-runs", post(run_test)) .route( "/operations/{operation_id}/samples/input-json", post(upload_input_json), ) .route( "/operations/{operation_id}/samples/output-json", post(upload_output_json), ) .route( "/operations/{operation_id}/drafts/generate", post(generate_draft), ) .route("/operations/{operation_id}/export", get(export_operation)) .route("/agents", get(list_agents).post(create_agent)) .route( "/agents/{agent_id}", get(get_agent).patch(update_agent).delete(delete_agent), ) .route( "/agents/{agent_id}/versions/{version}", get(get_agent_version), ) .route("/agents/{agent_id}/bindings", post(save_agent_bindings)) .route("/agents/{agent_id}/publish", post(publish_agent)) .route("/agents/{agent_id}/unpublish", post(unpublish_agent)) .route("/agents/{agent_id}/archive", post(archive_agent)) .route( "/agents/{agent_id}/platform-api-keys", get(list_agent_platform_api_keys).post(create_agent_platform_api_key), ) .route( "/agents/{agent_id}/platform-api-keys/{key_id}/revoke", post(revoke_agent_platform_api_key), ) .route( "/agents/{agent_id}/platform-api-keys/{key_id}", delete(delete_agent_platform_api_key), ) .route( "/auth-profiles", get(list_auth_profiles).post(create_auth_profile), ) .route("/auth-profiles/{auth_profile_id}", get(get_auth_profile)) .route("/secrets", get(list_secrets).post(create_secret)) .route( "/secrets/{secret_id}", get(get_secret).delete(delete_secret), ) .route("/secrets/{secret_id}/rotate", post(rotate_secret)) .route("/members", get(list_memberships)) .route( "/members/{user_id}", axum::routing::patch(update_membership).delete(delete_membership), ) .route( "/invitations", get(list_invitations).post(create_invitation), ) .route("/invitations/{invitation_id}", delete(delete_invitation)) .route("/export", get(export_workspace)) .route("/logs", get(list_logs)) .route("/logs/{log_id}", get(get_log)) .route("/usage", get(get_usage)) .route("/usage/operations/{operation_id}", get(get_operation_usage)) .route("/usage/agents/{agent_id}", get(get_agent_usage)) .route("/protocol-capabilities", get(list_protocol_capabilities)); let workspace_root_router = Router::new() .route("/capabilities", get(get_capabilities)) .route("/workspaces", get(list_workspaces).post(create_workspace)) .layer(middleware::from_fn_with_state( state.clone(), require_session, )); let workspace_scoped_router = Router::new() .route( "/workspaces/{workspace_id}", get(get_workspace) .patch(update_workspace) .delete(delete_workspace), ) .nest("/workspaces/{workspace_id}", workspace_router) .layer(middleware::from_fn_with_state( state.clone(), require_workspace_session, )); let admin_router = workspace_root_router.merge(workspace_scoped_router); let protected_auth_router = Router::new() .route("/logout", post(logout)) .route("/session", get(get_session)) .route("/profile", get(get_profile).patch(update_profile)) .route("/current-workspace", post(update_current_workspace)) .route("/password", post(change_password)) .layer(middleware::from_fn_with_state( state.clone(), require_session, )); Router::new() .route("/health", get(crate::routes::health)) .nest( "/api/auth", Router::new() .route("/login", post(login)) .merge(protected_auth_router), ) .nest( "/mcp-auth/v1", Router::new() .route("/token", post(issue_agent_token)) .route("/token/one-time", post(issue_one_time_agent_token)), ) .nest("/api/admin", admin_router) .layer(middleware::from_fn_with_state( state.clone(), apply_api_rate_limit, )) .layer(middleware::from_fn(apply_request_context)) .with_state(state) } use crate::routes::operations::import_operation; #[cfg(test)] mod tests { use std::{ collections::BTreeMap, env, fmt, sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; use async_trait::async_trait; use axum::{Json, Router, routing::post}; #[cfg(any())] use crank_adapter_grpc::test_support as grpc_test_support; #[cfg(any())] use crank_core::{ AsyncJobHandle, AsyncJobId, DescriptorId, ExecutionMode, GrpcTarget, JobStatus, OperationId, SoapBindingStyle, SoapOperationMetadata, SoapTarget, SoapVersion, StreamSession, StreamStatus, TransportBehavior, }; use crank_core::{ ExecutionConfig, GraphqlOperationType, GraphqlTarget, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol, ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WebsocketTarget, WorkspaceId, }; use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome}; use crank_mapping::{MappingRule, MappingSet}; use crank_registry::PostgresRegistry; #[cfg(any())] use crank_registry::{CreateAsyncJobRequest, CreateStreamSessionRequest}; use crank_runtime::SecretCrypto; use crank_schema::{Schema, SchemaKind}; use serde_json::{Value, json}; use serial_test::serial; use sqlx::{Connection, Executor, PgConnection}; #[cfg(any())] use time::OffsetDateTime; use tokio::net::TcpListener; use crate::{ 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 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"); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn manages_platform_access_resources() { let registry = test_registry().await; let storage_root = test_storage_root("platform_access"); let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let client = authorized_client(&base_url).await; let members = client .get(format!("{base_url}/members")) .send() .await .unwrap() .json::() .await .unwrap(); let created_invitation = client .post(format!("{base_url}/invitations")) .json(&json!({ "email": "operator@example.com", "role": "operator" })) .send() .await .unwrap() .json::() .await .unwrap(); let invitation_id = created_invitation["invitation"]["invitation"]["id"] .as_str() .unwrap() .to_owned(); let invitations = client .get(format!("{base_url}/invitations")) .send() .await .unwrap() .json::() .await .unwrap(); let delete_invitation_status = client .delete(format!("{base_url}/invitations/{invitation_id}")) .send() .await .unwrap() .status(); assert_eq!(members["items"][0]["role"], "owner"); assert_eq!( created_invitation["invitation"]["invitation"]["status"], "pending" ); assert!( created_invitation["invite_token"] .as_str() .unwrap() .starts_with("invite_") ); assert_eq!( invitations["items"][0]["invitation"]["email"], "operator@example.com" ); assert_eq!(delete_invitation_status, reqwest::StatusCode::NO_CONTENT); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn manages_agent_platform_api_keys() { let registry = test_registry().await; let storage_root = test_storage_root("agent_platform_keys"); let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let client = authorized_client(&base_url).await; let created_agent = 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["agent_id"].as_str().unwrap().to_owned(); let created_key = assert_success_json( client .post(format!("{base_url}/agents/{agent_id}/platform-api-keys")) .json(&json!({ "name": "sales-routing-primary", "scopes": ["read", "write"] })) .send() .await .unwrap(), ) .await; let key_id = created_key["api_key"]["api_key"]["id"] .as_str() .unwrap() .to_owned(); let listed_keys = assert_success_json( client .get(format!("{base_url}/agents/{agent_id}/platform-api-keys")) .send() .await .unwrap(), ) .await; let revoke_status = client .post(format!( "{base_url}/agents/{agent_id}/platform-api-keys/{key_id}/revoke" )) .send() .await .unwrap() .status(); let delete_status = client .delete(format!( "{base_url}/agents/{agent_id}/platform-api-keys/{key_id}" )) .send() .await .unwrap() .status(); assert_eq!(created_key["api_key"]["api_key"]["agent_id"], agent_id); assert_eq!( listed_keys["items"][0]["api_key"]["agent_id"], json!(agent_id) ); assert_eq!( listed_keys["items"][0]["api_key"]["name"], "sales-routing-primary" ); assert!(created_key["secret"].as_str().unwrap().starts_with("crk_")); assert_eq!(revoke_status, reqwest::StatusCode::NO_CONTENT); assert_eq!(delete_status, reqwest::StatusCode::NO_CONTENT); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn returns_community_capabilities() { let registry = test_registry().await; let storage_root = test_storage_root("community_capabilities"); let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let client = authorized_client(&base_url).await; let root_url = base_url .as_ref() .split("/api/admin/workspaces/") .next() .unwrap(); let response = assert_success_json( client .get(format!("{root_url}/api/admin/capabilities")) .send() .await .unwrap(), ) .await; assert_eq!(response["edition"], "community"); assert_eq!(response["supported_protocols"], json!(["rest"])); assert_eq!(response["supported_security_levels"], json!(["standard"])); assert_eq!( response["machine_access_modes"], json!(["static_agent_key"]) ); assert_eq!(response["limits"]["max_workspaces"], 1); assert_eq!(response["limits"]["max_users_per_workspace"], 1); assert_eq!(response["limits"]["max_agents_per_workspace"], 1); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn rejects_short_lived_machine_token_issue_in_community() { let registry = test_registry().await; let storage_root = test_storage_root("community_short_lived_token_contract"); let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let root_url = base_url .as_ref() .split("/api/admin/workspaces/") .next() .unwrap(); let response = reqwest::Client::new() .post(format!("{root_url}/mcp-auth/v1/token")) .json(&json!({ "grant_type": "agent_key", "agent_key": "crk_agent_demo_secret", "scope": ["tools:call"] })) .send() .await .unwrap(); assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); let payload = response.json::().await.unwrap(); assert_eq!(payload["error"]["code"], "forbidden"); assert_eq!( payload["error"]["message"], "short-lived machine access is not available in Community" ); assert_eq!(payload["error"]["context"]["edition"], "community"); assert_eq!( payload["error"]["context"]["machine_access_mode"], "short_lived_token" ); assert_eq!(payload["error"]["context"]["grant_type"], "agent_key"); assert_eq!(payload["error"]["context"]["upgrade_required"], json!(true)); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn rejects_one_time_machine_token_issue_in_community() { let registry = test_registry().await; let storage_root = test_storage_root("community_one_time_token_contract"); let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let root_url = base_url .as_ref() .split("/api/admin/workspaces/") .next() .unwrap(); let response = reqwest::Client::new() .post(format!("{root_url}/mcp-auth/v1/token/one-time")) .json(&json!({ "agent_key": "crk_agent_demo_secret", "operation_id": "op_sensitive_01", "scope": ["tools:call"] })) .send() .await .unwrap(); assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); let payload = response.json::().await.unwrap(); assert_eq!(payload["error"]["code"], "forbidden"); assert_eq!( payload["error"]["message"], "one-time machine access is not available in Community" ); assert_eq!(payload["error"]["context"]["edition"], "community"); assert_eq!( payload["error"]["context"]["machine_access_mode"], "one_time_token" ); assert_eq!( payload["error"]["context"]["operation_id"], "op_sensitive_01" ); assert_eq!(payload["error"]["context"]["upgrade_required"], json!(true)); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn returns_community_protocol_capabilities_only_for_supported_protocols() { let registry = test_registry().await; let storage_root = test_storage_root("community_protocol_capabilities"); let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let client = authorized_client(&base_url).await; let response = assert_success_json( client .get(format!("{base_url}/protocol-capabilities")) .send() .await .unwrap(), ) .await; assert_eq!(response["items"].as_array().unwrap().len(), 1); assert_eq!( response["items"] .as_array() .unwrap() .iter() .map(|item| item["protocol"].as_str().unwrap()) .collect::>(), vec!["rest"] ); for item in response["items"].as_array().unwrap() { assert_eq!(item["supports_execution_modes"], json!(["unary"])); assert_eq!( item["supports_transport_behaviors"], json!(["request_response"]) ); } } #[tokio::test(flavor = "multi_thread")] #[serial] async fn rejects_graphql_protocol_for_community_operation_create() { let registry = test_registry().await; let storage_root = test_storage_root("community_graphql_reject_create"); let upstream_base_url = spawn_graphql_server().await; let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let client = authorized_client(&base_url).await; let response = client .post(format!("{base_url}/operations")) .json(&test_graphql_operation_payload( &upstream_base_url, "crm_graphql_not_in_community", )) .send() .await .unwrap(); let status = response.status(); let body = response.json::().await.unwrap(); assert_eq!(status, reqwest::StatusCode::BAD_REQUEST); assert_eq!(body["error"]["code"], "validation_error"); assert_eq!( body["error"]["message"], "protocol graphql is not supported in Community" ); assert_eq!( body["error"]["context"], json!({ "protocol": "graphql", "edition": "community", }) ); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn rejects_unsupported_protocol_for_community_operation_create() { let registry = test_registry().await; let storage_root = test_storage_root("community_protocol_reject"); let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let client = authorized_client(&base_url).await; let response = client .post(format!("{base_url}/operations")) .json(&test_websocket_operation_payload( "wss://example.com/stream", "telemetry_ws", )) .send() .await .unwrap(); let status = response.status(); let body = response.json::().await.unwrap(); assert_eq!(status, reqwest::StatusCode::BAD_REQUEST); assert_eq!(body["error"]["code"], "validation_error"); assert_eq!( body["error"]["message"], "protocol websocket is not supported in Community" ); assert_eq!( body["error"]["context"], json!({ "protocol": "websocket", "edition": "community", }) ); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn rejects_premium_security_level_for_community_operation_create() { let registry = test_registry().await; let storage_root = test_storage_root("community_security_reject_create"); 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 mut payload = test_operation_payload(&upstream_base_url, "crm_elevated"); payload.security_level = OperationSecurityLevel::Elevated; let response = client .post(format!("{base_url}/operations")) .json(&payload) .send() .await .unwrap(); let status = response.status(); let body = response.json::().await.unwrap(); assert_eq!(status, reqwest::StatusCode::BAD_REQUEST); assert_eq!(body["error"]["code"], "validation_error"); assert_eq!( body["error"]["message"], "security level elevated is not supported in Community" ); assert_eq!( body["error"]["context"], json!({ "security_level": "elevated", "edition": "community", }) ); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn rejects_premium_security_level_for_community_operation_update() { let registry = test_registry().await; let storage_root = test_storage_root("community_security_reject_update"); 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_update_elevated", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap().to_owned(); let response = client .patch(format!("{base_url}/operations/{operation_id}")) .json(&json!({ "display_name": "Create Lead", "category": "sales", "security_level": "elevated", "target": { "kind": "rest", "base_url": upstream_base_url, "method": "POST", "path_template": "/crm/leads", "static_headers": {} }, "input_schema": object_schema("email"), "output_schema": object_schema("id"), "input_mapping": { "rules": [{ "source": "$.mcp.email", "target": "$.request.body.email", "required": true, "default_value": null, "transform": null, "condition": null, "notes": null }] }, "output_mapping": { "rules": [{ "source": "$.response.body.id", "target": "$.output.id", "required": true, "default_value": null, "transform": null, "condition": null, "notes": null }] }, "execution_config": { "timeout_ms": 1000, "retry_policy": null, "auth_profile_ref": null, "headers": {}, "protocol_options": null, "streaming": null }, "tool_description": { "title": "Create Lead", "description": "Creates a CRM lead", "tags": ["crm"], "examples": [] } })) .send() .await .unwrap(); let status = response.status(); let body = response.json::().await.unwrap(); assert_eq!(status, reqwest::StatusCode::BAD_REQUEST); assert_eq!(body["error"]["code"], "validation_error"); assert_eq!( body["error"]["message"], "security level elevated is not supported in Community" ); assert_eq!( body["error"]["context"], json!({ "security_level": "elevated", "edition": "community", }) ); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn rejects_response_cache_for_non_get_rest_operation_create() { let registry = test_registry().await; let storage_root = test_storage_root("community_response_cache_post_reject"); 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 mut payload = test_operation_payload(&upstream_base_url, "crm_cache_post"); payload.execution_config.response_cache = Some(ResponseCachePolicy { ttl_ms: 5_000 }); let response = client .post(format!("{base_url}/operations")) .json(&payload) .send() .await .unwrap(); let status = response.status(); let body = response.json::().await.unwrap(); assert_eq!(status, reqwest::StatusCode::BAD_REQUEST); assert_eq!(body["error"]["code"], "validation_error"); assert_eq!( body["error"]["context"]["field"], "execution_config.response_cache" ); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn rejects_response_cache_for_operation_with_auth_profile() { let registry = test_registry().await; let storage_root = test_storage_root("community_response_cache_auth_reject"); 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 mut payload = test_operation_payload(&upstream_base_url, "crm_cache_auth"); payload.target = Target::Rest(RestTarget { base_url: upstream_base_url, method: HttpMethod::Get, path_template: "/crm/leads".to_owned(), static_headers: BTreeMap::new(), }); payload.execution_config.response_cache = Some(ResponseCachePolicy { ttl_ms: 5_000 }); payload.execution_config.auth_profile_ref = Some("auth_profile_01".into()); let response = client .post(format!("{base_url}/operations")) .json(&payload) .send() .await .unwrap(); let status = response.status(); let body = response.json::().await.unwrap(); assert_eq!(status, reqwest::StatusCode::BAD_REQUEST); assert_eq!(body["error"]["code"], "validation_error"); assert_eq!( body["error"]["context"]["field"], "execution_config.auth_profile_ref" ); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn manages_workspace_access_lifecycle() { let registry = test_registry().await; let storage_root = test_storage_root("workspace_access"); let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await; let client = authorized_client(&base_url).await; let second_user_id = registry .upsert_bootstrap_user("operator-2@crank.local", "Operator Two", "external-hash") .await .unwrap(); registry .ensure_membership( &WorkspaceId::new(DEFAULT_WORKSPACE_ID), &second_user_id, MembershipRole::Viewer, ) .await .unwrap(); let updated_members = assert_success_json( client .patch(format!("{base_url}/members/{}", second_user_id.as_str())) .json(&json!({ "role": "admin" })) .send() .await .unwrap(), ) .await; let updated_member = updated_members["items"] .as_array() .unwrap() .iter() .find(|item| item["user"]["id"] == second_user_id.as_str()) .unwrap(); assert_eq!(updated_member["role"], "admin"); let exported = assert_success_json( client .get(format!("{base_url}/export")) .send() .await .unwrap(), ) .await; assert_eq!( exported["workspace"]["workspace"]["id"], DEFAULT_WORKSPACE_ID ); assert_eq!(exported["memberships"].as_array().unwrap().len(), 2); let delete_member_status = client .delete(format!("{base_url}/members/{}", second_user_id.as_str())) .send() .await .unwrap() .status(); assert_eq!(delete_member_status, reqwest::StatusCode::NO_CONTENT); let delete_workspace_status = client .delete(base_url.as_ref()) .send() .await .unwrap() .status(); assert_eq!(delete_workspace_status, reqwest::StatusCode::NO_CONTENT); let root_url = base_url .as_ref() .split("/api/admin/workspaces/") .next() .unwrap(); let missing_workspace = client .get(format!( "{root_url}/api/admin/workspaces/{DEFAULT_WORKSPACE_ID}" )) .send() .await .unwrap(); assert_eq!(missing_workspace.status(), reqwest::StatusCode::FORBIDDEN); } #[tokio::test] #[serial] async fn seeds_demo_assets_for_live_ui() { let registry = test_registry().await; let storage_root = test_storage_root("demo_seed"); let service = AdminService::new( registry.clone(), storage_root, test_auth_settings(), test_secret_crypto(), ); service.bootstrap_admin_user().await.unwrap(); service.seed_demo_assets().await.unwrap(); let owner = registry .get_auth_user_by_email(TEST_AUTH_EMAIL) .await .unwrap() .unwrap(); let workspaces = service .list_workspaces_for_user(&owner.user.id) .await .unwrap(); assert!( workspaces .iter() .any(|item| item.workspace.slug == "default") ); assert!( workspaces .iter() .any(|item| item.workspace.slug == "growth-lab") ); let default_workspace_id = WorkspaceId::new(DEFAULT_WORKSPACE_ID); let operations = service .list_operations(&default_workspace_id) .await .unwrap(); assert!(operations.len() >= 2); let agents = service.list_agents(&default_workspace_id).await.unwrap(); assert!(!agents.is_empty()); assert!(agents.iter().any(|agent| agent.key_count > 0)); let logs = service .list_logs( &default_workspace_id, crate::service::LogsQuery { level: None, search: None, source: None, operation_id: None, agent_id: None, period: None, limit: Some(20), }, ) .await .unwrap(); assert!(!logs.is_empty()); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn updates_profile_and_changes_password() { let registry = test_registry().await; let storage_root = test_storage_root("settings_profile"); let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let root_url = base_url .as_ref() .split("/api/admin/workspaces/") .next() .unwrap() .to_owned(); let client = authorized_client(&base_url).await; let profile = assert_success_json( client .get(format!("{root_url}/api/auth/profile")) .send() .await .unwrap(), ) .await; assert_eq!(profile["user"]["email"], TEST_AUTH_EMAIL); let updated_profile = assert_success_json( client .patch(format!("{root_url}/api/auth/profile")) .json(&json!({ "display_name": "Updated Owner", "email": "updated-owner@crank.local" })) .send() .await .unwrap(), ) .await; assert_eq!(updated_profile["user"]["display_name"], "Updated Owner"); assert_eq!( updated_profile["user"]["email"], "updated-owner@crank.local" ); let password_status = client .post(format!("{root_url}/api/auth/password")) .json(&json!({ "current_password": TEST_AUTH_PASSWORD, "new_password": "updated-password-123" })) .send() .await .unwrap() .status(); assert_eq!(password_status, reqwest::StatusCode::NO_CONTENT); let relogin_client = reqwest::Client::builder() .cookie_store(true) .build() .unwrap(); let relogin_status = relogin_client .post(format!("{root_url}/api/auth/login")) .json(&json!({ "email": "updated-owner@crank.local", "password": "updated-password-123" })) .send() .await .unwrap() .status(); assert_eq!(relogin_status, reqwest::StatusCode::OK); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn switches_current_workspace_in_session() { let registry = test_registry().await; let storage_root = test_storage_root("session_workspace"); let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let root_url = base_url .as_ref() .split("/api/admin/workspaces/") .next() .unwrap() .to_owned(); let client = authorized_client(&base_url).await; let created_workspace = assert_success_json( client .post(format!("{root_url}/api/admin/workspaces")) .json(&json!({ "slug": "growth-lab", "display_name": "Growth Lab", "settings": {} })) .send() .await .unwrap(), ) .await; let workspace_id = created_workspace["workspace"]["id"].as_str().unwrap(); let switched = assert_success_json( client .post(format!("{root_url}/api/auth/current-workspace")) .json(&json!({ "workspace_id": workspace_id })) .send() .await .unwrap(), ) .await; assert_eq!(switched["current_workspace_id"], workspace_id); let session = assert_success_json( client .get(format!("{root_url}/api/auth/session")) .send() .await .unwrap(), ) .await; assert_eq!(session["current_workspace_id"], workspace_id); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn exposes_logs_and_usage_from_real_test_runs() { let registry = test_registry().await; let storage_root = test_storage_root("observability"); 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_observability", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap().to_owned(); client .post(format!("{base_url}/operations/{operation_id}/test-runs")) .json(&json!({ "version": 1, "input": { "email": "user@example.com" } })) .send() .await .unwrap(); let logs = client .get(format!("{base_url}/logs?period=7d")) .send() .await .unwrap() .json::() .await .unwrap(); let log_id = logs["items"][0]["log"]["id"].as_str().unwrap().to_owned(); let log_detail = client .get(format!("{base_url}/logs/{log_id}")) .send() .await .unwrap() .json::() .await .unwrap(); let usage = client .get(format!("{base_url}/usage?period=7d")) .send() .await .unwrap() .json::() .await .unwrap(); let operation_usage = client .get(format!( "{base_url}/usage/operations/{operation_id}?period=7d" )) .send() .await .unwrap() .json::() .await .unwrap(); assert_eq!(logs["items"][0]["log"]["source"], "admin_test_run"); assert_eq!(logs["items"][0]["operation_name"], "crm_observability"); assert_eq!(log_detail["log"]["status"], "ok"); assert_eq!(usage["summary"]["rollup"]["calls_total"], 1); assert_eq!(usage["summary"]["rollup"]["calls_ok"], 1); assert_eq!( usage["operations"][0]["operation_name"], "crm_observability" ); assert_eq!(operation_usage["rollup"]["calls_total"], 1); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn preserves_request_id_for_test_run_invocations() { let registry = test_registry().await; let storage_root = test_storage_root("observability_request_id"); 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_observability_request_id", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap().to_owned(); let response = client .post(format!("{base_url}/operations/{operation_id}/test-runs")) .header("x-request-id", "req_test_123") .json(&json!({ "version": 1, "input": { "email": "user@example.com" } })) .send() .await .unwrap(); assert_eq!( response.headers()["x-request-id"].to_str().unwrap(), "req_test_123" ); response.error_for_status().unwrap(); let logs = client .get(format!("{base_url}/logs?period=7d")) .send() .await .unwrap() .json::() .await .unwrap(); assert_eq!(logs["items"][0]["log"]["request_id"], "req_test_123"); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn generates_request_id_for_test_run_invocations() { let registry = test_registry().await; let storage_root = test_storage_root("observability_generated_request_id"); 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_observability_generated_request_id", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap().to_owned(); let response = client .post(format!("{base_url}/operations/{operation_id}/test-runs")) .json(&json!({ "version": 1, "input": { "email": "user@example.com" } })) .send() .await .unwrap(); let request_id = response .headers() .get("x-request-id") .unwrap() .to_str() .unwrap() .to_owned(); assert!(!request_id.is_empty()); response.error_for_status().unwrap(); let logs = client .get(format!("{base_url}/logs?period=7d")) .send() .await .unwrap() .json::() .await .unwrap(); assert_eq!(logs["items"][0]["log"]["request_id"], request_id); } #[cfg(any())] #[tokio::test(flavor = "multi_thread")] #[serial] async fn creates_publishes_and_tests_graphql_operation() { let registry = test_registry().await; let storage_root = test_storage_root("graphql"); let upstream_base_url = spawn_graphql_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_graphql_operation_payload( &upstream_base_url, "crm_create_lead_graphql", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap().to_owned(); 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!(published["published_version"], 1); assert_eq!(test_run["ok"], true); assert_eq!( test_run["request_preview"]["variables"]["email"], "user@example.com" ); assert_eq!(test_run["response_preview"]["id"], "lead_123"); } #[cfg(any())] #[tokio::test(flavor = "multi_thread")] #[serial] async fn uploads_descriptor_set_and_lists_grpc_services() { let registry = test_registry().await; let storage_root = test_storage_root("grpc_descriptor"); let server_addr = grpc_test_support::spawn_unary_echo_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_grpc_operation_payload( &server_addr, "echo_descriptor", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap().to_owned(); let uploaded = client .post(format!( "{base_url}/operations/{operation_id}/descriptors/descriptor-set" )) .header("x-file-name", "echo_descriptor.bin") .body(grpc_test_support::echo::FILE_DESCRIPTOR_SET.to_vec()) .send() .await .unwrap() .json::() .await .unwrap(); let services = client .get(format!( "{base_url}/operations/{operation_id}/grpc/services" )) .send() .await .unwrap() .json::() .await .unwrap(); assert!(uploaded["descriptor_id"].as_str().is_some()); assert_eq!(services["services"][0]["package"], "echo"); assert_eq!(services["services"][0]["service"], "EchoService"); assert_eq!(services["services"][0]["methods"][0]["name"], "UnaryEcho"); assert_eq!(services["services"][0]["methods"][0]["kind"], "unary"); } #[cfg(any())] #[tokio::test(flavor = "multi_thread")] #[serial] async fn creates_publishes_and_tests_grpc_operation() { let registry = test_registry().await; let storage_root = test_storage_root("grpc_runtime"); let server_addr = grpc_test_support::spawn_unary_echo_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_grpc_operation_payload(&server_addr, "echo_runtime")) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap().to_owned(); 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": { "message": "hello" } })) .send() .await .unwrap() .json::() .await .unwrap(); assert_eq!(published["published_version"], 1); assert_eq!(test_run["ok"], true); assert_eq!(test_run["request_preview"]["grpc"]["message"], "hello"); assert_eq!(test_run["response_preview"]["message"], "hello"); } #[cfg(any())] #[tokio::test(flavor = "multi_thread")] #[serial] async fn returns_window_metadata_for_streaming_test_runs() { let registry = test_registry().await; let storage_root = test_storage_root("grpc_window_test_run"); let server_addr = grpc_test_support::spawn_unary_echo_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_grpc_window_operation_payload( &server_addr, "echo_window_runtime", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap().to_owned(); let test_run = client .post(format!("{base_url}/operations/{operation_id}/test-runs")) .json(&json!({ "version": 1, "input": { "message": "hello" } })) .send() .await .unwrap() .json::() .await .unwrap(); assert_eq!(test_run["ok"], true); assert_eq!(test_run["mode"], "window"); assert!(test_run["window"].is_object()); assert!(test_run["window"]["window_complete"].is_boolean()); assert!(test_run["window"]["truncated"].is_boolean()); assert!(test_run["window"]["has_more"].is_boolean()); assert!(test_run["response_preview"]["items"].is_array()); } #[cfg(any())] #[tokio::test(flavor = "multi_thread")] #[serial] async fn starts_stream_session_from_streaming_test_runs() { let registry = test_registry().await; let storage_root = test_storage_root("grpc_session_test_run"); let server_addr = grpc_test_support::spawn_unary_echo_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_grpc_session_operation_payload( &server_addr, "echo_session_runtime", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap().to_owned(); let test_run = client .post(format!("{base_url}/operations/{operation_id}/test-runs")) .json(&json!({ "version": 1, "input": { "message": "hello" } })) .send() .await .unwrap() .json::() .await .unwrap(); let session_id = test_run["stream_session"]["session_id"] .as_str() .unwrap() .to_owned(); let session = client .get(format!("{base_url}/stream-sessions/{session_id}")) .send() .await .unwrap() .json::() .await .unwrap(); assert_eq!(test_run["ok"], true); assert_eq!(test_run["mode"], "session"); assert_eq!(test_run["stream_session"]["status"], "running"); assert!(test_run["response_preview"]["preview"]["items"].is_array()); assert_eq!(session["id"], session_id); assert_eq!(session["status"], "running"); } #[cfg(any())] #[tokio::test(flavor = "multi_thread")] #[serial] async fn starts_async_job_from_streaming_test_runs() { let registry = test_registry().await; let storage_root = test_storage_root("async_job_test_run"); 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_rest_async_job_operation_payload( &upstream_base_url, "crm_async_job_runtime", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap().to_owned(); 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(); let job_id = test_run["async_job"]["job_id"].as_str().unwrap().to_owned(); let mut job = Value::Null; for _ in 0..20 { job = client .get(format!("{base_url}/async-jobs/{job_id}")) .send() .await .unwrap() .json::() .await .unwrap(); if job["status"] == "completed" { break; } tokio::time::sleep(std::time::Duration::from_millis(25)).await; } let result = client .get(format!("{base_url}/async-jobs/{job_id}/result")) .send() .await .unwrap() .json::() .await .unwrap(); assert_eq!(test_run["ok"], true); assert_eq!(test_run["mode"], "async_job"); assert_eq!(test_run["async_job"]["status"], "running"); assert_eq!(job["status"], "completed"); assert_eq!(result["id"], "lead_123"); } #[cfg(any())] #[tokio::test(flavor = "multi_thread")] #[serial] async fn returns_structured_context_for_pending_async_job_result() { let registry = test_registry().await; let storage_root = test_storage_root("async_job_pending_result"); let upstream_base_url = spawn_upstream_server().await; let job_id = AsyncJobId::new("job_pending_result".to_owned()); 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_rest_async_job_operation_payload( &upstream_base_url, "crm_async_job_pending_result", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = OperationId::new(created["operation_id"].as_str().unwrap().to_owned()); let now = OffsetDateTime::now_utc(); registry .create_async_job(CreateAsyncJobRequest { job: &AsyncJobHandle { id: job_id.clone(), workspace_id: WorkspaceId::new(DEFAULT_WORKSPACE_ID), agent_id: None, operation_id, status: JobStatus::Running, progress: json!({ "pct": 25 }), result: None, error: None, expires_at: Some(now + time::Duration::minutes(5)), last_poll_at: None, created_at: now, updated_at: now, finished_at: None, }, }) .await .unwrap(); let response = client .get(format!("{base_url}/async-jobs/{}/result", job_id.as_str())) .send() .await .unwrap(); let status = response.status(); let body = response.json::().await.unwrap(); assert_eq!(status, reqwest::StatusCode::CONFLICT); assert_eq!(body["error"]["code"], "conflict"); assert_eq!(body["error"]["message"], "async job result is not ready"); assert_eq!( body["error"]["context"], json!({ "job_id": job_id.as_str(), "status": "running" }) ); } #[cfg(any())] #[tokio::test(flavor = "multi_thread")] #[serial] async fn rate_limits_rapid_stream_session_reads() { let registry = test_registry().await; let storage_root = test_storage_root("stream_session_rate_limit"); let server_addr = grpc_test_support::spawn_unary_echo_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_grpc_session_operation_payload( &server_addr, "echo_session_rate_limit", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = OperationId::new(created["operation_id"].as_str().unwrap().to_owned()); let now = OffsetDateTime::now_utc(); let session_id = crank_core::StreamSessionId::new("sess_rate_limited".to_owned()); registry .create_stream_session(CreateStreamSessionRequest { session: &StreamSession { id: session_id.clone(), workspace_id: WorkspaceId::new(DEFAULT_WORKSPACE_ID), agent_id: None, operation_id, protocol: Protocol::Grpc, mode: ExecutionMode::Session, status: StreamStatus::Running, cursor: None, state: json!({ "items": [] }), expires_at: now + time::Duration::minutes(5), last_poll_at: Some(now), created_at: now, closed_at: None, }, }) .await .unwrap(); let response = client .get(format!( "{base_url}/stream-sessions/{}", session_id.as_str() )) .send() .await .unwrap(); let status = response.status(); let body = response.json::().await.unwrap(); assert_eq!(status, reqwest::StatusCode::TOO_MANY_REQUESTS); assert_eq!(body["error"]["code"], "rate_limited"); assert_eq!( body["error"]["message"], "stream session poll rate limit exceeded" ); assert_eq!(body["error"]["context"]["session_id"], session_id.as_str()); assert!(body["error"]["context"]["poll_after_ms"].as_u64().unwrap() > 0); } #[cfg(any())] #[tokio::test(flavor = "multi_thread")] #[serial] async fn rate_limits_rapid_async_job_result_polls() { let registry = test_registry().await; let storage_root = test_storage_root("async_job_rate_limit"); let upstream_base_url = spawn_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_rest_async_job_operation_payload( &upstream_base_url, "crm_async_job_rate_limit", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = OperationId::new(created["operation_id"].as_str().unwrap().to_owned()); let now = OffsetDateTime::now_utc(); let job_id = AsyncJobId::new("job_rate_limited".to_owned()); registry .create_async_job(CreateAsyncJobRequest { job: &AsyncJobHandle { id: job_id.clone(), workspace_id: WorkspaceId::new(DEFAULT_WORKSPACE_ID), agent_id: None, operation_id, status: JobStatus::Running, progress: json!({ "pct": 25 }), result: None, error: None, expires_at: Some(now + time::Duration::minutes(5)), last_poll_at: Some(now), created_at: now, updated_at: now, finished_at: None, }, }) .await .unwrap(); let response = client .get(format!("{base_url}/async-jobs/{}/result", job_id.as_str())) .send() .await .unwrap(); let status = response.status(); let body = response.json::().await.unwrap(); assert_eq!(status, reqwest::StatusCode::TOO_MANY_REQUESTS); assert_eq!(body["error"]["code"], "rate_limited"); assert_eq!( body["error"]["message"], "async job poll rate limit exceeded" ); assert_eq!(body["error"]["context"]["job_id"], job_id.as_str()); assert!(body["error"]["context"]["poll_after_ms"].as_u64().unwrap() > 0); } #[cfg(any())] #[tokio::test(flavor = "multi_thread")] #[serial] async fn allows_completed_async_job_result_fetch_without_poll_delay() { let registry = test_registry().await; let storage_root = test_storage_root("async_job_completed_result"); let upstream_base_url = spawn_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_rest_async_job_operation_payload( &upstream_base_url, "crm_async_job_completed_result", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = OperationId::new(created["operation_id"].as_str().unwrap().to_owned()); let now = OffsetDateTime::now_utc(); let job_id = AsyncJobId::new("job_completed_result".to_owned()); registry .create_async_job(CreateAsyncJobRequest { job: &AsyncJobHandle { id: job_id.clone(), workspace_id: WorkspaceId::new(DEFAULT_WORKSPACE_ID), agent_id: None, operation_id, status: JobStatus::Completed, progress: json!({ "pct": 100 }), result: Some(json!({ "id": "lead_123" })), error: None, expires_at: Some(now + time::Duration::minutes(5)), last_poll_at: Some(now), created_at: now, updated_at: now, finished_at: Some(now), }, }) .await .unwrap(); let response = client .get(format!("{base_url}/async-jobs/{}/result", job_id.as_str())) .send() .await .unwrap(); let status = response.status(); let body = response.json::().await.unwrap(); assert_eq!(status, reqwest::StatusCode::OK); assert_eq!(body["id"], "lead_123"); } #[cfg(any())] #[tokio::test(flavor = "multi_thread")] #[serial] async fn returns_structured_context_for_missing_stream_session() { let registry = test_registry().await; let storage_root = test_storage_root("missing_stream_session"); let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let client = authorized_client(&base_url).await; let response = client .get(format!("{base_url}/stream-sessions/sess_missing")) .send() .await .unwrap(); let status = response.status(); let body = response.json::().await.unwrap(); assert_eq!(status, reqwest::StatusCode::NOT_FOUND); assert_eq!(body["error"]["code"], "not_found"); assert_eq!( body["error"]["message"], "stream session sess_missing was not found" ); assert_eq!( body["error"]["context"], json!({ "session_id": "sess_missing" }) ); } #[cfg(any())] #[tokio::test(flavor = "multi_thread")] #[serial] async fn uploads_wsdl_and_tests_soap_operation() { let registry = test_registry().await; let storage_root = test_storage_root("soap_runtime"); let endpoint = spawn_soap_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_soap_operation_payload( &endpoint, "crm_create_lead_soap", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap().to_owned(); let uploaded = client .post(format!( "{base_url}/operations/{operation_id}/descriptors/wsdl" )) .header("x-file-name", "lead.wsdl") .body(SOAP_TEST_WSDL) .send() .await .unwrap() .json::() .await .unwrap(); let services = client .get(format!( "{base_url}/operations/{operation_id}/soap/services" )) .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!(uploaded["version"], 1); assert_eq!(services["services"][0]["service_name"], "LeadService"); assert_eq!(services["services"][0]["ports"][0]["port_name"], "LeadPort"); assert_eq!( services["services"][0]["ports"][0]["operations"][0]["operation_name"], "CreateLead" ); assert_eq!(test_run["ok"], true); assert_eq!(test_run["response_preview"]["id"], "lead_123"); } #[cfg(any())] #[tokio::test(flavor = "multi_thread")] #[serial] async fn returns_structured_context_when_wsdl_is_missing() { let registry = test_registry().await; let storage_root = test_storage_root("soap_missing_wsdl"); let endpoint = spawn_soap_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_soap_operation_payload( &endpoint, "crm_create_lead_soap_missing_wsdl", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap().to_owned(); let response = client .get(format!( "{base_url}/operations/{operation_id}/soap/services" )) .send() .await .unwrap(); let status = response.status(); let body = response.json::().await.unwrap(); assert_eq!(status, reqwest::StatusCode::NOT_FOUND); assert_eq!(body["error"]["code"], "not_found"); assert_eq!(body["error"]["message"], "wsdl was not uploaded"); assert_eq!( body["error"]["context"], json!({ "operation_id": operation_id, "version": 1, "descriptor_kind": "wsdl_upload" }) ); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn returns_structured_context_for_missing_operation_usage() { let registry = test_registry().await; let storage_root = test_storage_root("missing_operation_usage"); 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_missing_usage", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap().to_owned(); let response = client .get(format!( "{base_url}/usage/operations/{operation_id}?period=7d" )) .send() .await .unwrap(); let status = response.status(); let body = response.json::().await.unwrap(); assert_eq!(status, reqwest::StatusCode::NOT_FOUND); assert_eq!(body["error"]["code"], "not_found"); assert_eq!( body["error"]["message"], format!("usage for operation {operation_id} was not found") ); assert_eq!( body["error"]["context"], json!({ "operation_id": operation_id }) ); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn manages_auth_profiles_and_yaml_upsert() { let registry = test_registry().await; let storage_root = test_storage_root("yaml"); 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 secret = client .post(format!("{base_url}/secrets")) .json(&json!({ "name": "crm-api-token", "kind": SecretKind::Token, "value": { "token": "super-secret-token" } })) .send() .await .unwrap(); let secret = assert_success_json(secret).await; let secret_id = secret["id"].as_str().unwrap(); let auth_profile = client .post(format!("{base_url}/auth-profiles")) .json(&json!({ "name": "crm-header", "kind": "api_key_header", "config": { "api_key_header": { "header_name": "X-Api-Key", "secret_id": secret_id } } })) .send() .await .unwrap() .json::() .await .unwrap(); let created = client .post(format!("{base_url}/operations")) .json(&test_operation_payload( &upstream_base_url, "crm_export_target", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap(); let yaml = client .get(format!("{base_url}/operations/{operation_id}/export")) .send() .await .unwrap() .text() .await .unwrap(); let imported = client .post(format!("{base_url}/operations/import?mode=upsert")) .body(yaml) .send() .await .unwrap() .json::() .await .unwrap(); assert_eq!(auth_profile["kind"], "api_key_header"); assert_eq!( auth_profile["config"]["api_key_header"]["secret_id"], secret_id ); assert_eq!(imported["operation_id"], operation_id); assert_eq!(imported["version"], 2); assert_eq!(imported["import_mode"], "upsert"); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn rejects_premium_security_level_for_community_yaml_upsert() { let registry = test_registry().await; let storage_root = test_storage_root("community_yaml_security_reject"); 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_yaml_standard", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap().to_owned(); let mut yaml = client .get(format!("{base_url}/operations/{operation_id}/export")) .send() .await .unwrap() .text() .await .unwrap(); yaml = yaml.replace("security_level: standard", "security_level: elevated"); let response = client .post(format!("{base_url}/operations/import?mode=upsert")) .body(yaml) .send() .await .unwrap(); let status = response.status(); let body = response.json::().await.unwrap(); assert_eq!(status, reqwest::StatusCode::BAD_REQUEST); assert_eq!(body["error"]["code"], "validation_error"); assert_eq!( body["error"]["message"], "security level elevated is not supported in Community" ); assert_eq!( body["error"]["context"], json!({ "security_level": "elevated", "edition": "community", }) ); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn manages_workspace_secrets_without_exposing_plaintext() { let registry = test_registry().await; let storage_root = test_storage_root("secrets"); 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}/secrets")) .json(&json!({ "name": "crm-api-token", "kind": SecretKind::Token, "value": { "token": "super-secret-token" } })) .send() .await .unwrap(); let created = assert_success_json(created).await; let secret_id = created["id"].as_str().unwrap().to_owned(); let listed = client .get(format!("{base_url}/secrets")) .send() .await .unwrap(); let listed = assert_success_json(listed).await; let fetched = client .get(format!("{base_url}/secrets/{secret_id}")) .send() .await .unwrap(); let fetched = assert_success_json(fetched).await; let rotated = client .post(format!("{base_url}/secrets/{secret_id}/rotate")) .json(&json!({ "value": { "token": "rotated-token" } })) .send() .await .unwrap(); let rotated = assert_success_json(rotated).await; let deleted = client .delete(format!("{base_url}/secrets/{secret_id}")) .send() .await .unwrap(); let deleted = assert_success_json(deleted).await; let missing = client .get(format!("{base_url}/secrets/{secret_id}")) .send() .await .unwrap(); let missing_status = missing.status(); let missing = missing.json::().await.unwrap(); assert_eq!(created["name"], "crm-api-token"); assert_eq!(created["kind"], "token"); assert_eq!(created["current_version"], 1); assert!(created.get("value").is_none()); assert_eq!(listed["items"].as_array().unwrap().len(), 1); assert_eq!(fetched["id"], secret_id); assert!(fetched.get("value").is_none()); assert_eq!(rotated["current_version"], 2); assert_eq!(deleted["ok"], true); assert_eq!(missing_status, reqwest::StatusCode::NOT_FOUND); assert_eq!(missing["error"]["code"], "not_found"); assert_eq!( missing["error"]["context"], json!({ "secret_id": secret_id }) ); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn returns_structured_context_for_missing_operation_version() { let registry = test_registry().await; let storage_root = test_storage_root("missing_operation_version"); 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_missing_operation_version", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap().to_owned(); let response = client .get(format!("{base_url}/operations/{operation_id}/versions/99")) .send() .await .unwrap(); let status = response.status(); let body = response.json::().await.unwrap(); assert_eq!(status, reqwest::StatusCode::NOT_FOUND); assert_eq!(body["error"]["code"], "not_found"); assert_eq!( body["error"]["message"], format!("operation version 99 for {operation_id} was not found") ); assert_eq!( body["error"]["context"], json!({ "operation_id": operation_id, "version": 99 }) ); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn rejects_auth_profile_with_missing_secret() { let registry = test_registry().await; let storage_root = test_storage_root("missing_secret_auth"); let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let client = authorized_client(&base_url).await; let response = client .post(format!("{base_url}/auth-profiles")) .json(&json!({ "name": "crm-header", "kind": "api_key_header", "config": { "api_key_header": { "header_name": "X-Api-Key", "secret_id": "secret_missing" } } })) .send() .await .unwrap(); let status = response.status(); let body = response.json::().await.unwrap(); assert_eq!(status, reqwest::StatusCode::NOT_FOUND); assert_eq!(body["error"]["code"], "not_found"); assert_eq!( body["error"]["message"], "secret secret_missing was not found" ); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn rejects_deleting_secret_referenced_by_auth_profile() { let registry = test_registry().await; let storage_root = test_storage_root("secret_references"); let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let client = authorized_client(&base_url).await; let secret = client .post(format!("{base_url}/secrets")) .json(&json!({ "name": "crm-api-token", "kind": SecretKind::Token, "value": { "token": "super-secret-token" } })) .send() .await .unwrap(); let secret = assert_success_json(secret).await; let secret_id = secret["id"].as_str().unwrap(); let auth_profile = client .post(format!("{base_url}/auth-profiles")) .json(&json!({ "name": "crm-header", "kind": "api_key_header", "config": { "api_key_header": { "header_name": "X-Api-Key", "secret_id": secret_id } } })) .send() .await .unwrap(); let auth_profile = assert_success_json(auth_profile).await; let auth_profile_id = auth_profile["id"].as_str().unwrap(); let response = client .delete(format!("{base_url}/secrets/{secret_id}")) .send() .await .unwrap(); let status = response.status(); let body = response.json::().await.unwrap(); assert_eq!(status, reqwest::StatusCode::CONFLICT); assert_eq!(body["error"]["code"], "conflict"); assert_eq!( body["error"]["message"], format!("secret {secret_id} is referenced by auth profile {auth_profile_id}") ); } #[cfg(any())] #[tokio::test(flavor = "multi_thread")] #[serial] async fn roundtrips_graphql_operation_through_yaml_upsert() { let registry = test_registry().await; let storage_root = test_storage_root("yaml_graphql"); let upstream_base_url = spawn_graphql_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_graphql_operation_payload( &upstream_base_url, "crm_yaml_graphql", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap().to_owned(); let yaml = client .get(format!("{base_url}/operations/{operation_id}/export")) .send() .await .unwrap() .text() .await .unwrap(); let imported = client .post(format!("{base_url}/operations/import?mode=upsert")) .body(yaml) .send() .await .unwrap() .json::() .await .unwrap(); let test_run = client .post(format!("{base_url}/operations/{operation_id}/test-runs")) .json(&json!({ "version": 2, "input": { "email": "user@example.com" } })) .send() .await .unwrap() .json::() .await .unwrap(); assert_eq!(imported["operation_id"], operation_id); assert_eq!(imported["version"], 2); assert_eq!(test_run["ok"], true); assert_eq!(test_run["response_preview"]["id"], "lead_123"); } #[cfg(any())] #[tokio::test(flavor = "multi_thread")] #[serial] async fn roundtrips_grpc_operation_through_yaml_upsert() { let registry = test_registry().await; let storage_root = test_storage_root("yaml_grpc"); let server_addr = grpc_test_support::spawn_unary_echo_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_grpc_operation_payload(&server_addr, "echo_yaml_grpc")) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap().to_owned(); let yaml = client .get(format!("{base_url}/operations/{operation_id}/export")) .send() .await .unwrap() .text() .await .unwrap(); let imported = client .post(format!("{base_url}/operations/import?mode=upsert")) .body(yaml) .send() .await .unwrap() .json::() .await .unwrap(); let test_run = client .post(format!("{base_url}/operations/{operation_id}/test-runs")) .json(&json!({ "version": 2, "input": { "message": "hello" } })) .send() .await .unwrap() .json::() .await .unwrap(); assert_eq!(imported["operation_id"], operation_id); assert_eq!(imported["version"], 2); assert_eq!(test_run["ok"], true); assert_eq!(test_run["response_preview"]["message"], "hello"); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn uploads_samples_and_generates_draft() { let registry = test_registry().await; let storage_root = test_storage_root("draft"); 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_draft_target", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap(); client .post(format!( "{base_url}/operations/{operation_id}/samples/input-json" )) .json(&json!({ "email": "user@example.com", "name": "Ada" })) .send() .await .unwrap(); client .post(format!( "{base_url}/operations/{operation_id}/samples/output-json" )) .json(&json!({ "id": "lead_123", "status": "created" })) .send() .await .unwrap(); let generated = client .post(format!( "{base_url}/operations/{operation_id}/drafts/generate" )) .json(&json!({ "sources": ["input_json_sample", "output_json_sample"] })) .send() .await .unwrap() .json::() .await .unwrap(); assert_eq!(generated["generated_draft"]["status"], "available"); assert_eq!( generated["input_schema"]["fields"]["email"]["type"], "string" ); assert_eq!( generated["output_mapping"]["rules"][0]["target"], "$.output.id" ); } fn build_test_app(registry: PostgresRegistry, storage_root: std::path::PathBuf) -> Router { build_app(AppState { service: AdminService::new( registry, storage_root, test_auth_settings(), test_secret_crypto(), ), api_rate_limiter: crank_runtime::RequestRateLimiter::new( crank_runtime::RequestRateLimitConfig::new(10_000, 10_000).unwrap(), ), }) } async fn spawn_upstream_server() -> String { let app = Router::new().route("/crm/leads", post(create_lead)); 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) } async fn spawn_graphql_server() -> String { let app = Router::new().route("/", post(graphql_handler)); 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) } #[cfg(any())] async fn spawn_soap_server() -> String { let app = Router::new().route("/", post(soap_handler)); 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) } async fn spawn_admin_api(app: Router) -> TestServer { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let handle = tokio::spawn(async move { axum::serve(listener, app) .with_graceful_shutdown(async move { let _ = shutdown_rx.await; }) .await .unwrap(); }); TestServer { base_url: format!( "http://{}/api/admin/workspaces/{}", address, DEFAULT_WORKSPACE_ID ), shutdown: Some(shutdown_tx), handle: Some(handle), } } async fn authorized_client(workspace_base_url: impl AsRef) -> reqwest::Client { let root_url = workspace_base_url .as_ref() .split("/api/admin/workspaces/") .next() .unwrap(); let client = reqwest::Client::builder() .cookie_store(true) .build() .unwrap(); client .post(format!("{root_url}/api/auth/login")) .json(&json!({ "email": TEST_AUTH_EMAIL, "password": TEST_AUTH_PASSWORD, })) .send() .await .unwrap() .error_for_status() .unwrap(); client } #[tokio::test(flavor = "multi_thread")] #[serial] async fn rejects_rapid_login_requests_with_429() { let registry = test_registry().await; let storage_root = test_storage_root("login_rate_limit"); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); let app = build_app(AppState { service: AdminService::new( registry, storage_root, test_auth_settings(), test_secret_crypto(), ), api_rate_limiter: crank_runtime::RequestRateLimiter::new( crank_runtime::RequestRateLimitConfig::new(1, 1).unwrap(), ), }); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let handle = tokio::spawn(async move { axum::serve(listener, app) .with_graceful_shutdown(async move { let _ = shutdown_rx.await; }) .await .unwrap(); }); let client = reqwest::Client::new(); let root_url = format!("http://{address}"); let first_response = client .post(format!("{root_url}/api/auth/login")) .header("x-request-id", "req_admin_rate_01") .json(&json!({ "email": TEST_AUTH_EMAIL, "password": TEST_AUTH_PASSWORD, })) .send() .await .unwrap(); assert_eq!(first_response.status(), reqwest::StatusCode::OK); let second_response = client .post(format!("{root_url}/api/auth/login")) .header("x-request-id", "req_admin_rate_02") .json(&json!({ "email": TEST_AUTH_EMAIL, "password": TEST_AUTH_PASSWORD, })) .send() .await .unwrap(); assert_eq!( second_response.status(), reqwest::StatusCode::TOO_MANY_REQUESTS ); assert_eq!( second_response.headers()["x-request-id"].to_str().unwrap(), "req_admin_rate_02" ); let payload = second_response.json::().await.unwrap(); assert_eq!(payload["error"]["code"], "rate_limited"); let retry_after_ms = payload["error"]["context"]["retry_after_ms"] .as_u64() .unwrap(); assert!((1..=1000).contains(&retry_after_ms)); let _ = shutdown_tx.send(()); let _ = handle.await; } #[tokio::test(flavor = "multi_thread")] #[serial] async fn login_uses_identity_provider_when_configured() { let registry = test_registry().await; let storage_root = test_storage_root("identity_provider_login"); let service = AdminServiceBuilder::new( registry, storage_root, test_auth_settings(), test_secret_crypto(), crank_runtime::community_default().build(), ) .with_identity_provider(Arc::new(RejectingIdentityProvider)) .build(); let error = match service .login(crate::service::LoginPayload { email: TEST_AUTH_EMAIL.to_owned(), password: TEST_AUTH_PASSWORD.to_owned(), }) .await { Ok(_) => panic!("login should delegate to identity provider"), Err(error) => error, }; assert_eq!(error.to_string(), "invalid email or password"); } async fn assert_success_json(response: reqwest::Response) -> Value { let status = response.status(); let body = response.text().await.unwrap(); assert!( status.is_success(), "request failed with status {status}: {body}" ); serde_json::from_str(&body).unwrap() } async fn create_lead(Json(payload): Json) -> Json { Json(json!({ "id": "lead_123", "status": "created", "email": payload["email"] })) } async fn graphql_handler(Json(payload): Json) -> Json { let email = payload .get("variables") .and_then(|variables| variables.get("email")) .and_then(Value::as_str) .unwrap_or_default(); Json(json!({ "data": { "createLead": { "id": "lead_123", "status": "created", "email": email } } })) } #[cfg(any())] async fn soap_handler(body: String) -> (axum::http::StatusCode, String) { assert!(body.contains("user@example.com")); ( axum::http::StatusCode::OK, r#" lead_123 created "# .to_owned(), ) } async fn test_registry() -> PostgresRegistry { let database_url = env::var("TEST_DATABASE_URL") .unwrap_or_else(|_| "postgres://crank:crank@127.0.0.1:15432/crank".to_owned()); let mut admin_connection = PgConnection::connect(&database_url).await.unwrap(); let schema = format!( "test_admin_api_{}_{}", std::process::id(), SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() ); admin_connection .execute(sqlx::query(&format!("create schema {schema}"))) .await .unwrap(); let registry = PostgresRegistry::connect(&format!("{database_url}?options=-csearch_path%3D{schema}")) .await .unwrap(); let password_hash = hash_password(TEST_AUTH_PASSWORD, TEST_PASSWORD_PEPPER).unwrap(); let user_id = registry .upsert_bootstrap_user(TEST_AUTH_EMAIL, "Test Owner", &password_hash) .await .unwrap(); registry .ensure_membership( &WorkspaceId::new(DEFAULT_WORKSPACE_ID), &user_id, MembershipRole::Owner, ) .await .unwrap(); registry } fn test_storage_root(name: &str) -> std::path::PathBuf { env::temp_dir().join(format!( "crank_admin_api_{name}_{}_{}", std::process::id(), SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() )) } fn test_auth_settings() -> AuthSettings { AuthSettings { session_secret: TEST_SESSION_SECRET.to_owned(), password_pepper: TEST_PASSWORD_PEPPER.to_owned(), session_ttl_hours: 24, cookie_secure: false, bootstrap_admin: BootstrapAdminConfig { email: TEST_AUTH_EMAIL.to_owned(), password: TEST_AUTH_PASSWORD.to_owned(), display_name: "Test Owner".to_owned(), }, } } fn test_secret_crypto() -> SecretCrypto { SecretCrypto::new(TEST_MASTER_KEY).unwrap() } fn test_operation_payload(base_url: &str, name: &str) -> OperationPayload { OperationPayload { name: name.to_owned(), display_name: "Create Lead".to_owned(), category: "sales".to_owned(), protocol: Protocol::Rest, security_level: OperationSecurityLevel::Standard, target: Target::Rest(RestTarget { base_url: base_url.to_owned(), method: HttpMethod::Post, path_template: "/crm/leads".to_owned(), static_headers: BTreeMap::new(), }), input_schema: object_schema("email"), output_schema: object_schema("id"), input_mapping: MappingSet { rules: vec![MappingRule { source: "$.mcp.email".to_owned(), target: "$.request.body.email".to_owned(), required: true, default_value: None, transform: None, condition: None, notes: None, }], }, output_mapping: MappingSet { rules: vec![MappingRule { source: "$.response.body.id".to_owned(), target: "$.output.id".to_owned(), required: true, default_value: None, transform: None, condition: None, notes: None, }], }, execution_config: ExecutionConfig { timeout_ms: 1_000, retry_policy: None, response_cache: None, auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, streaming: None, }, tool_description: ToolDescription { title: "Create Lead".to_owned(), description: "Creates a CRM lead".to_owned(), tags: vec!["crm".to_owned()], examples: Vec::new(), }, } } fn test_graphql_operation_payload(endpoint: &str, name: &str) -> OperationPayload { OperationPayload { name: name.to_owned(), display_name: "Create Lead GraphQL".to_owned(), category: "sales".to_owned(), protocol: Protocol::Graphql, security_level: OperationSecurityLevel::Standard, target: Target::Graphql(GraphqlTarget { endpoint: endpoint.to_owned(), operation_type: GraphqlOperationType::Mutation, operation_name: "CreateLead".to_owned(), query_template: "mutation CreateLead($email: String!) { createLead(email: $email) { id status email } }" .to_owned(), response_path: "$.response.body.data.createLead".to_owned(), }), input_schema: object_schema("email"), output_schema: object_schema("id"), input_mapping: MappingSet { rules: vec![MappingRule { source: "$.mcp.email".to_owned(), target: "$.request.variables.email".to_owned(), required: true, default_value: None, transform: None, condition: None, notes: None, }], }, output_mapping: MappingSet { rules: vec![MappingRule { source: "$.response.data.id".to_owned(), target: "$.output.id".to_owned(), required: true, default_value: None, transform: None, condition: None, notes: None, }], }, execution_config: ExecutionConfig { timeout_ms: 1_000, retry_policy: None, response_cache: None, auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, streaming: None, }, tool_description: ToolDescription { title: "Create Lead GraphQL".to_owned(), description: "Creates a CRM lead through GraphQL".to_owned(), tags: vec!["crm".to_owned(), "graphql".to_owned()], examples: Vec::new(), }, } } #[cfg(any())] fn test_grpc_operation_payload(server_addr: &str, name: &str) -> OperationPayload { OperationPayload { name: name.to_owned(), display_name: "Unary Echo gRPC".to_owned(), category: "support".to_owned(), protocol: Protocol::Grpc, security_level: OperationSecurityLevel::Standard, target: Target::Grpc(GrpcTarget { server_addr: server_addr.to_owned(), package: "echo".to_owned(), service: "EchoService".to_owned(), method: "UnaryEcho".to_owned(), read_only: false, descriptor_ref: DescriptorId::new("desc_echo"), descriptor_set_b64: grpc_test_support::descriptor_set_b64(), }), input_schema: object_schema("message"), output_schema: object_schema("message"), input_mapping: MappingSet { rules: vec![MappingRule { source: "$.mcp.message".to_owned(), target: "$.request.grpc.message".to_owned(), required: true, default_value: None, transform: None, condition: None, notes: None, }], }, output_mapping: MappingSet { rules: vec![MappingRule { source: "$.response.data.message".to_owned(), target: "$.output.message".to_owned(), required: true, default_value: None, transform: None, condition: None, notes: None, }], }, execution_config: ExecutionConfig { timeout_ms: 1_000, retry_policy: None, response_cache: None, auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, streaming: None, }, tool_description: ToolDescription { title: "Unary Echo gRPC".to_owned(), description: "Echoes a unary gRPC payload".to_owned(), tags: vec!["grpc".to_owned()], examples: Vec::new(), }, } } #[cfg(any())] fn test_grpc_window_operation_payload(server_addr: &str, name: &str) -> OperationPayload { let mut payload = test_grpc_operation_payload(server_addr, name); payload.display_name = "Server Echo Window".to_owned(); payload.target = Target::Grpc(GrpcTarget { server_addr: server_addr.to_owned(), package: "echo".to_owned(), service: "EchoService".to_owned(), method: "ServerEcho".to_owned(), read_only: false, descriptor_ref: DescriptorId::new("desc_echo_stream"), descriptor_set_b64: grpc_test_support::descriptor_set_b64(), }); payload.execution_config.streaming = Some(crank_core::StreamingConfig { mode: ExecutionMode::Window, transport_behavior: TransportBehavior::ServerStream, window_duration_ms: Some(100), poll_interval_ms: None, upstream_timeout_ms: Some(1_000), idle_timeout_ms: None, max_session_lifetime_ms: None, max_items: Some(2), max_bytes: None, aggregation_mode: crank_core::AggregationMode::RawItems, summary_path: None, items_path: Some("$.response.body.items".to_owned()), cursor_path: None, status_path: None, done_path: Some("$.response.body.done".to_owned()), redacted_paths: Vec::new(), truncate_item_fields: false, max_field_length: None, drop_duplicates: false, sampling_rate: None, tool_family: crank_core::ToolFamilyConfig::default(), }); payload } #[cfg(any())] fn test_grpc_session_operation_payload(server_addr: &str, name: &str) -> OperationPayload { let mut payload = test_grpc_window_operation_payload(server_addr, name); payload.display_name = "Server Echo Session".to_owned(); if let Some(streaming) = payload.execution_config.streaming.as_mut() { streaming.mode = ExecutionMode::Session; streaming.poll_interval_ms = Some(1_000); streaming.max_session_lifetime_ms = Some(60_000); streaming.tool_family = crank_core::ToolFamilyConfig { start_tool_name: Some("echo_session_start".to_owned()), poll_tool_name: Some("echo_session_poll".to_owned()), stop_tool_name: Some("echo_session_stop".to_owned()), status_tool_name: None, result_tool_name: None, cancel_tool_name: None, }; } payload } #[cfg(any())] fn test_rest_async_job_operation_payload(base_url: &str, name: &str) -> OperationPayload { let mut payload = test_operation_payload(base_url, name); payload.display_name = "Create Lead Async Job".to_owned(); payload.execution_config.streaming = Some(crank_core::StreamingConfig { mode: ExecutionMode::AsyncJob, transport_behavior: TransportBehavior::DeferredResult, window_duration_ms: None, poll_interval_ms: Some(1_000), upstream_timeout_ms: Some(1_000), idle_timeout_ms: None, max_session_lifetime_ms: Some(300_000), max_items: None, max_bytes: None, aggregation_mode: crank_core::AggregationMode::SummaryPlusSamples, summary_path: None, items_path: None, cursor_path: None, status_path: None, done_path: None, redacted_paths: Vec::new(), truncate_item_fields: false, max_field_length: None, drop_duplicates: false, sampling_rate: None, tool_family: crank_core::ToolFamilyConfig { start_tool_name: Some("crm_async_start".to_owned()), poll_tool_name: None, stop_tool_name: None, status_tool_name: Some("crm_async_status".to_owned()), result_tool_name: Some("crm_async_result".to_owned()), cancel_tool_name: Some("crm_async_cancel".to_owned()), }, }); payload } #[cfg(any())] fn test_soap_operation_payload(endpoint: &str, name: &str) -> OperationPayload { OperationPayload { name: name.to_owned(), display_name: "Create Lead SOAP".to_owned(), category: "sales".to_owned(), protocol: Protocol::Soap, security_level: OperationSecurityLevel::Standard, target: Target::Soap(SoapTarget { wsdl_ref: "sample_wsdl".into(), service_name: "LeadService".to_owned(), port_name: "LeadPort".to_owned(), operation_name: "CreateLead".to_owned(), endpoint_override: Some(endpoint.to_owned()), soap_version: SoapVersion::Soap11, soap_action: Some("urn:createLead".to_owned()), binding_style: SoapBindingStyle::DocumentLiteral, headers: Vec::new(), fault_contract: None, metadata: SoapOperationMetadata { input_part_names: vec!["CreateLeadRequest".to_owned()], output_part_names: vec!["CreateLeadResponse".to_owned()], namespaces: vec!["urn:crm".to_owned()], }, }), input_schema: object_schema("email"), output_schema: object_schema("id"), input_mapping: MappingSet { rules: vec![MappingRule { source: "$.mcp.email".to_owned(), target: "$.request.body.email".to_owned(), required: true, default_value: None, transform: None, condition: None, notes: None, }], }, output_mapping: MappingSet { rules: vec![MappingRule { source: "$.response.body.id".to_owned(), target: "$.output.id".to_owned(), required: true, default_value: None, transform: None, condition: None, notes: None, }], }, execution_config: ExecutionConfig { timeout_ms: 1_000, retry_policy: None, response_cache: None, auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, streaming: None, }, tool_description: ToolDescription { title: "Create Lead SOAP".to_owned(), description: "Creates a CRM lead through SOAP".to_owned(), tags: vec!["crm".to_owned(), "soap".to_owned()], examples: Vec::new(), }, } } fn test_websocket_operation_payload(url: &str, name: &str) -> OperationPayload { OperationPayload { name: name.to_owned(), display_name: "Telemetry WebSocket".to_owned(), category: "telemetry".to_owned(), protocol: Protocol::Websocket, security_level: OperationSecurityLevel::Standard, target: Target::Websocket(WebsocketTarget { url: url.to_owned(), subprotocols: Vec::new(), subscribe_message_template: Some(json!({ "subscribe": true })), unsubscribe_message_template: None, static_headers: BTreeMap::new(), }), input_schema: object_schema("channel"), output_schema: object_schema("event"), input_mapping: MappingSet { rules: vec![MappingRule { source: "$.mcp.channel".to_owned(), target: "$.request.body.channel".to_owned(), required: true, default_value: None, transform: None, condition: None, notes: None, }], }, output_mapping: MappingSet { rules: vec![MappingRule { source: "$.response.body.event".to_owned(), target: "$.output.event".to_owned(), required: true, default_value: None, transform: None, condition: None, notes: None, }], }, execution_config: ExecutionConfig { timeout_ms: 1_000, retry_policy: None, response_cache: None, auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, streaming: None, }, tool_description: ToolDescription { title: "Telemetry WebSocket".to_owned(), description: "Streams telemetry events".to_owned(), tags: vec!["telemetry".to_owned(), "websocket".to_owned()], examples: Vec::new(), }, } } #[cfg(any())] const SOAP_TEST_WSDL: &str = r#" "#; fn object_schema(field_name: &str) -> Schema { Schema { kind: SchemaKind::Object, description: None, required: true, nullable: false, default_value: None, fields: BTreeMap::from([( field_name.to_owned(), Schema { kind: SchemaKind::String, description: None, required: true, nullable: false, default_value: None, fields: BTreeMap::new(), items: None, enum_values: Vec::new(), variants: Vec::new(), }, )]), items: None, enum_values: Vec::new(), variants: Vec::new(), } } }