Files
crank/apps/admin-api/src/app.rs
T
github-ops b546063998
Deploy / deploy (push) Successful in 30s
CI / Rust Checks (push) Successful in 4m59s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 4m8s
chore: publish clean community baseline
2026-06-17 07:13:01 +00:00

3958 lines
136 KiB
Rust

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<tokio::sync::oneshot::Sender<()>>,
handle: Option<tokio::task::JoinHandle<()>>,
}
impl Drop for TestServer {
fn drop(&mut self) {
let shutdown = self.shutdown.take();
let handle = self.handle.take();
tokio::task::block_in_place(|| {
if let Some(shutdown) = shutdown {
let _ = shutdown.send(());
}
if let Some(handle) = handle {
let _ = tokio::runtime::Handle::current().block_on(handle);
}
});
}
}
impl fmt::Display for TestServer {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.base_url)
}
}
impl AsRef<str> for TestServer {
fn as_ref(&self) -> &str {
&self.base_url
}
}
struct RejectingIdentityProvider;
#[async_trait]
impl IdentityProvider for RejectingIdentityProvider {
fn id(&self) -> &str {
"rejecting-test-provider"
}
fn kind(&self) -> IdentityProviderKind {
IdentityProviderKind::Password
}
async fn login_password(
&self,
_payload: crank_core::LoginPayload,
) -> Result<LoginOutcome, IdentityError> {
Err(IdentityError::BadCredentials)
}
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn creates_publishes_and_tests_rest_operation() {
let registry = test_registry().await;
let storage_root = test_storage_root("lifecycle");
let upstream_base_url = 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::<Value>()
.await
.unwrap();
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
let listed = client
.get(format!("{base_url}/operations"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let published = client
.post(format!("{base_url}/operations/{operation_id}/publish"))
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap()
.json::<Value>()
.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::<Value>()
.await
.unwrap();
assert_eq!(listed["items"][0]["name"], "crm_create_lead");
assert_eq!(
listed["items"][0]["target_url"],
format!("{upstream_base_url}/crm/leads")
);
assert_eq!(listed["items"][0]["target_action"], "POST");
assert_eq!(published["published_version"], 1);
assert_eq!(test_run["ok"], true);
assert_eq!(
test_run["request_preview"]["body"]["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::<Value>()
.await
.unwrap();
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
let listed = client
.get(format!("{base_url}/operations"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(listed["total"], 1);
assert_eq!(listed["items"][0]["category"], "sales");
assert_eq!(
listed["items"][0]["target_url"],
format!("{upstream_base_url}/crm/leads")
);
assert_eq!(listed["items"][0]["target_action"], "POST");
let updated = client
.patch(format!("{base_url}/operations/{operation_id}"))
.json(&json!({
"display_name": "Create Lead Updated",
"category": "marketing",
"target": {
"kind": "rest",
"base_url": upstream_base_url,
"method": "POST",
"path_template": "/crm/leads",
"static_headers": {}
},
"input_schema": {
"type": "object",
"required": true,
"nullable": false,
"fields": {
"email": {
"type": "string",
"required": true,
"nullable": false
}
}
},
"output_schema": {
"type": "object",
"required": true,
"nullable": false,
"fields": {
"id": {
"type": "string",
"required": true,
"nullable": false
}
}
},
"input_mapping": {
"rules": [
{
"source": "$.mcp.email",
"target": "$.request.body.email",
"required": true
}
]
},
"output_mapping": {
"rules": [
{
"source": "$.response.body.id",
"target": "$.output.id",
"required": true
}
]
},
"execution_config": {
"timeout_ms": 1000,
"headers": {}
},
"tool_description": {
"title": "Create Lead Updated",
"description": "Creates a CRM lead",
"tags": ["crm"]
}
}))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(updated["status"], "draft");
let detail = client
.get(format!("{base_url}/operations/{operation_id}"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(detail["display_name"], "Create Lead Updated");
assert_eq!(detail["category"], "marketing");
let archived = client
.post(format!("{base_url}/operations/{operation_id}/archive"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(archived["status"], "archived");
let deleted = client
.delete(format!("{base_url}/operations/{operation_id}"))
.send()
.await
.unwrap()
.json::<Value>()
.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::<Value>().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::<Value>()
.await
.unwrap();
let agent_id = agent["agent_id"].as_str().unwrap().to_owned();
let bindings = client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.json(&json!([
{
"operation_id": operation_id,
"operation_version": 1,
"tool_name": "crm_create_lead_agent",
"tool_title": "Create Lead",
"enabled": true
}
]))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let published = client
.post(format!("{base_url}/agents/{agent_id}/publish"))
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(
bindings["bindings"][0]["tool_name"],
"crm_create_lead_agent"
);
assert_eq!(published["published_version"], 1);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn 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::<Value>()
.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::<Value>()
.await
.unwrap();
assert_eq!(listed["items"][0]["operation_count"], 1);
assert_eq!(listed["items"][0]["operation_ids"][0], operation_id);
assert_eq!(
listed["items"][0]["mcp_endpoint"],
"/mcp/v1/default/support-team"
);
assert_eq!(listed["items"][0]["status"], "published");
let updated = client
.patch(format!("{base_url}/agents/{agent_id}"))
.json(&json!({
"slug": "support-escalation",
"display_name": "Support Escalation",
"description": "Escalation workflows"
}))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(updated["agent_id"], agent_id);
let detail = client
.get(format!("{base_url}/agents/{agent_id}"))
.send()
.await
.unwrap()
.json::<Value>()
.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::<Value>()
.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::<Value>().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::<Value>()
.await
.unwrap();
let created_invitation = client
.post(format!("{base_url}/invitations"))
.json(&json!({
"email": "operator@example.com",
"role": "operator"
}))
.send()
.await
.unwrap()
.json::<Value>()
.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::<Value>()
.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::<Value>().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::<Value>().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<_>>(),
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::<Value>().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::<Value>().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::<Value>().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::<Value>()
.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::<Value>().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::<Value>().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::<Value>().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::<Value>()
.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::<Value>()
.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::<Value>()
.await
.unwrap();
let usage = client
.get(format!("{base_url}/usage?period=7d"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let operation_usage = client
.get(format!(
"{base_url}/usage/operations/{operation_id}?period=7d"
))
.send()
.await
.unwrap()
.json::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.await
.unwrap();
let services = client
.get(format!(
"{base_url}/operations/{operation_id}/grpc/services"
))
.send()
.await
.unwrap()
.json::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>().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::<Value>()
.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::<Value>().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::<Value>()
.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::<Value>().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::<Value>()
.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::<Value>().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::<Value>().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::<Value>()
.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::<Value>()
.await
.unwrap();
let services = client
.get(format!(
"{base_url}/operations/{operation_id}/soap/services"
))
.send()
.await
.unwrap()
.json::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>().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::<Value>()
.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::<Value>().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::<Value>()
.await
.unwrap();
let created = client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload(
&upstream_base_url,
"crm_export_target",
))
.send()
.await
.unwrap()
.json::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>().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::<Value>().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::<Value>()
.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::<Value>().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::<Value>().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::<Value>().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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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<str>) -> 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::<Value>().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<Value>) -> Json<Value> {
Json(json!({
"id": "lead_123",
"status": "created",
"email": payload["email"]
}))
}
async fn graphql_handler(Json(payload): Json<Value>) -> Json<Value> {
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("<email>user@example.com</email>"));
(
axum::http::StatusCode::OK,
r#"<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<CreateLeadResponse>
<id>lead_123</id>
<status>created</status>
</CreateLeadResponse>
</soap:Body>
</soap:Envelope>"#
.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#"<?xml version="1.0"?>
<definitions xmlns="http://schemas.xmlsoap.org/wsdl/"
xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/"
xmlns:tns="urn:crm"
targetNamespace="urn:crm">
<binding name="LeadBinding" type="tns:LeadPortType">
<soap:binding transport="http://schemas.xmlsoap.org/soap/http" style="document"/>
<operation name="CreateLead">
<soap:operation soapAction="urn:createLead"/>
</operation>
</binding>
<service name="LeadService">
<port name="LeadPort" binding="tns:LeadBinding">
<soap:address location="https://soap.example.com/lead"/>
</port>
</service>
</definitions>"#;
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(),
}
}
}