#![allow(dead_code, unused_imports)] use std::{ collections::BTreeMap, env, fmt, sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; use async_trait::async_trait; use axum::{Json, Router, routing::post}; use crank_core::{ ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol, ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId, }; use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome}; use crank_mapping::{MappingRule, MappingSet}; use crank_registry::PostgresRegistry; use crank_runtime::SecretCrypto; use crank_schema::{Schema, SchemaKind}; use serde_json::{Value, json}; use serial_test::serial; use tokio::net::TcpListener; use admin_api::{ app::build_app, auth::{AuthSettings, BootstrapAdminConfig, hash_password}, service::{AdminService, AdminServiceBuilder, OperationPayload}, state::AppState, }; const DEFAULT_WORKSPACE_ID: &str = "ws_default"; const TEST_AUTH_EMAIL: &str = "owner@crank.local"; const TEST_AUTH_PASSWORD: &str = "test-password"; const TEST_PASSWORD_PEPPER: &str = "test-password-pepper"; const TEST_SESSION_SECRET: &str = "test-session-secret"; const TEST_MASTER_KEY: &str = "test-master-key"; pub(super) struct TestServer { base_url: String, shutdown: Option>, handle: Option>, } impl Drop for TestServer { fn drop(&mut self) { let shutdown = self.shutdown.take(); let handle = self.handle.take(); tokio::task::block_in_place(|| { if let Some(shutdown) = shutdown { let _ = shutdown.send(()); } if let Some(handle) = handle { let _ = tokio::runtime::Handle::current().block_on(handle); } }); } } impl fmt::Display for TestServer { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(&self.base_url) } } impl AsRef for TestServer { fn as_ref(&self) -> &str { &self.base_url } } pub(super) struct RejectingIdentityProvider; #[async_trait] impl IdentityProvider for RejectingIdentityProvider { fn id(&self) -> &str { "rejecting-test-provider" } fn kind(&self) -> IdentityProviderKind { IdentityProviderKind::Password } async fn login_password( &self, _payload: crank_core::LoginPayload, ) -> Result { Err(IdentityError::BadCredentials) } } pub(super) fn build_test_app( registry: PostgresRegistry, storage_root: std::path::PathBuf, ) -> Router { build_app(AppState { service: test_service( 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(), ), trust_forwarded_headers: false, }) } pub(super) fn test_service( registry: PostgresRegistry, storage_root: std::path::PathBuf, auth_settings: AuthSettings, secret_crypto: SecretCrypto, ) -> AdminService { let outbound_policy = crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]); let runtime = crank_runtime::community_with_outbound_policy(outbound_policy.clone()).build(); AdminServiceBuilder::new( registry, storage_root, auth_settings, secret_crypto, runtime, ) .with_outbound_http_policy(outbound_policy) .build() } pub(super) 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) } pub(super) 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), } } pub(super) async fn authorized_client(workspace_base_url: impl AsRef) -> reqwest::Client { let root_url = workspace_base_url .as_ref() .split("/api/admin/workspaces/") .next() .unwrap(); let client = reqwest::Client::builder() .cookie_store(true) .build() .unwrap(); client .post(format!("{root_url}/api/auth/login")) .json(&json!({ "email": TEST_AUTH_EMAIL, "password": TEST_AUTH_PASSWORD, })) .send() .await .unwrap() .error_for_status() .unwrap(); client } pub(super) 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() } pub(super) async fn create_lead(Json(payload): Json) -> Json { Json(json!({ "id": "lead_123", "status": "created", "email": payload["email"] })) } pub(super) async fn test_registry() -> PostgresRegistry { let database_url = crank_test_support::postgres_schema_url("test_admin_api").await; let registry = PostgresRegistry::connect(&database_url).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 } pub(super) 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() )) } pub(super) 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(), }, } } pub(super) fn test_secret_crypto() -> SecretCrypto { SecretCrypto::new(TEST_MASTER_KEY).unwrap() } pub(super) 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, idempotency: None, safety: None, approval_policy: None, auth_profile_ref: None, headers: BTreeMap::new(), }, tool_description: ToolDescription { title: "Create Lead".to_owned(), description: "Creates a CRM lead".to_owned(), tags: vec!["crm".to_owned()], examples: Vec::new(), }, wizard_state: None, } } pub(super) 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(), } }