#![allow(dead_code, unused_imports)] use super::common::*; use std::{ collections::BTreeMap, env, fmt, sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; use async_trait::async_trait; use axum::{Json, Router, routing::post}; use crank_core::{ ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol, ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId, }; use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome}; use crank_mapping::{MappingRule, MappingSet}; use crank_registry::PostgresRegistry; use crank_runtime::SecretCrypto; use crank_schema::{Schema, SchemaKind}; use serde_json::{Value, json}; use serial_test::serial; use tokio::net::TcpListener; use admin_api::{ app::build_app, auth::{AuthSettings, BootstrapAdminConfig, hash_password}, service::{AdminService, AdminServiceBuilder, OperationPayload}, state::AppState, }; const DEFAULT_WORKSPACE_ID: &str = "ws_default"; const TEST_AUTH_EMAIL: &str = "owner@crank.local"; const TEST_AUTH_PASSWORD: &str = "test-password"; const TEST_PASSWORD_PEPPER: &str = "test-password-pepper"; const TEST_SESSION_SECRET: &str = "test-session-secret"; const TEST_MASTER_KEY: &str = "test-master-key"; struct TestServer { base_url: String, shutdown: Option>, handle: Option>, } impl Drop for TestServer { fn drop(&mut self) { let shutdown = self.shutdown.take(); let handle = self.handle.take(); tokio::task::block_in_place(|| { if let Some(shutdown) = shutdown { let _ = shutdown.send(()); } if let Some(handle) = handle { let _ = tokio::runtime::Handle::current().block_on(handle); } }); } } impl fmt::Display for TestServer { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(&self.base_url) } } impl AsRef for TestServer { fn as_ref(&self) -> &str { &self.base_url } } struct RejectingIdentityProvider; #[async_trait] impl IdentityProvider for RejectingIdentityProvider { fn id(&self) -> &str { "rejecting-test-provider" } fn kind(&self) -> IdentityProviderKind { IdentityProviderKind::Password } async fn login_password( &self, _payload: crank_core::LoginPayload, ) -> Result { Err(IdentityError::BadCredentials) } } #[tokio::test(flavor = "multi_thread")] #[serial] async fn manages_auth_profiles_and_yaml_upsert() { let registry = test_registry().await; let storage_root = test_storage_root("yaml"); let upstream_base_url = spawn_upstream_server().await; let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let client = authorized_client(&base_url).await; let secret = client .post(format!("{base_url}/secrets")) .json(&json!({ "name": "crm-api-token", "kind": SecretKind::Token, "value": { "token": "super-secret-token" } })) .send() .await .unwrap(); let secret = assert_success_json(secret).await; let secret_id = secret["id"].as_str().unwrap(); let auth_profile = client .post(format!("{base_url}/auth-profiles")) .json(&json!({ "name": "crm-header", "kind": "api_key_header", "config": { "api_key_header": { "header_name": "X-Api-Key", "secret_id": secret_id } } })) .send() .await .unwrap() .json::() .await .unwrap(); let created = client .post(format!("{base_url}/operations")) .json(&test_operation_payload( &upstream_base_url, "crm_export_target", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap(); let yaml = client .get(format!("{base_url}/operations/{operation_id}/export")) .send() .await .unwrap() .text() .await .unwrap(); let imported = client .post(format!("{base_url}/operations/import?mode=upsert")) .body(yaml) .send() .await .unwrap() .json::() .await .unwrap(); assert_eq!(auth_profile["kind"], "api_key_header"); assert_eq!( auth_profile["config"]["api_key_header"]["secret_id"], secret_id ); assert_eq!(imported["operation_id"], operation_id); assert_eq!(imported["version"], 2); assert_eq!(imported["import_mode"], "upsert"); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn manages_workspace_secrets_without_exposing_plaintext() { let registry = test_registry().await; let storage_root = test_storage_root("secrets"); let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let client = authorized_client(&base_url).await; let created = client .post(format!("{base_url}/secrets")) .json(&json!({ "name": "crm-api-token", "kind": SecretKind::Token, "value": { "token": "super-secret-token" } })) .send() .await .unwrap(); let created = assert_success_json(created).await; let secret_id = created["id"].as_str().unwrap().to_owned(); let listed = client .get(format!("{base_url}/secrets")) .send() .await .unwrap(); let listed = assert_success_json(listed).await; let fetched = client .get(format!("{base_url}/secrets/{secret_id}")) .send() .await .unwrap(); let fetched = assert_success_json(fetched).await; let rotated = client .post(format!("{base_url}/secrets/{secret_id}/rotate")) .json(&json!({ "value": { "token": "rotated-token" } })) .send() .await .unwrap(); let rotated = assert_success_json(rotated).await; let deleted = client .delete(format!("{base_url}/secrets/{secret_id}")) .send() .await .unwrap(); let deleted = assert_success_json(deleted).await; let missing = client .get(format!("{base_url}/secrets/{secret_id}")) .send() .await .unwrap(); let missing_status = missing.status(); let missing = missing.json::().await.unwrap(); assert_eq!(created["name"], "crm-api-token"); assert_eq!(created["kind"], "token"); assert_eq!(created["current_version"], 1); assert!(created.get("value").is_none()); assert_eq!(listed["items"].as_array().unwrap().len(), 1); assert_eq!(fetched["id"], secret_id); assert!(fetched.get("value").is_none()); assert_eq!(rotated["current_version"], 2); assert_eq!(deleted["ok"], true); assert_eq!(missing_status, reqwest::StatusCode::NOT_FOUND); assert_eq!(missing["error"]["code"], "not_found"); assert_eq!( missing["error"]["context"], json!({ "secret_id": secret_id }) ); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn returns_structured_context_for_missing_operation_version() { let registry = test_registry().await; let storage_root = test_storage_root("missing_operation_version"); let upstream_base_url = spawn_upstream_server().await; let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let client = authorized_client(&base_url).await; let created = client .post(format!("{base_url}/operations")) .json(&test_operation_payload( &upstream_base_url, "crm_missing_operation_version", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap().to_owned(); let response = client .get(format!("{base_url}/operations/{operation_id}/versions/99")) .send() .await .unwrap(); let status = response.status(); let body = response.json::().await.unwrap(); assert_eq!(status, reqwest::StatusCode::NOT_FOUND); assert_eq!(body["error"]["code"], "not_found"); assert_eq!( body["error"]["message"], format!("operation version 99 for {operation_id} was not found") ); assert_eq!( body["error"]["context"], json!({ "operation_id": operation_id, "version": 99 }) ); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn rejects_auth_profile_with_missing_secret() { let registry = test_registry().await; let storage_root = test_storage_root("missing_secret_auth"); let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let client = authorized_client(&base_url).await; let response = client .post(format!("{base_url}/auth-profiles")) .json(&json!({ "name": "crm-header", "kind": "api_key_header", "config": { "api_key_header": { "header_name": "X-Api-Key", "secret_id": "secret_missing" } } })) .send() .await .unwrap(); let status = response.status(); let body = response.json::().await.unwrap(); assert_eq!(status, reqwest::StatusCode::NOT_FOUND); assert_eq!(body["error"]["code"], "not_found"); assert_eq!( body["error"]["message"], "secret secret_missing was not found" ); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn rejects_deleting_secret_referenced_by_auth_profile() { let registry = test_registry().await; let storage_root = test_storage_root("secret_references"); let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let client = authorized_client(&base_url).await; let secret = client .post(format!("{base_url}/secrets")) .json(&json!({ "name": "crm-api-token", "kind": SecretKind::Token, "value": { "token": "super-secret-token" } })) .send() .await .unwrap(); let secret = assert_success_json(secret).await; let secret_id = secret["id"].as_str().unwrap(); let auth_profile = client .post(format!("{base_url}/auth-profiles")) .json(&json!({ "name": "crm-header", "kind": "api_key_header", "config": { "api_key_header": { "header_name": "X-Api-Key", "secret_id": secret_id } } })) .send() .await .unwrap(); let auth_profile = assert_success_json(auth_profile).await; let auth_profile_id = auth_profile["id"].as_str().unwrap(); let response = client .delete(format!("{base_url}/secrets/{secret_id}")) .send() .await .unwrap(); let status = response.status(); let body = response.json::().await.unwrap(); assert_eq!(status, reqwest::StatusCode::CONFLICT); assert_eq!(body["error"]["code"], "conflict"); assert_eq!( body["error"]["message"], format!("secret {secret_id} is referenced by auth profile {auth_profile_id}") ); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn uploads_samples_and_generates_draft() { let registry = test_registry().await; let storage_root = test_storage_root("draft"); let upstream_base_url = spawn_upstream_server().await; let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let client = authorized_client(&base_url).await; let created = client .post(format!("{base_url}/operations")) .json(&test_operation_payload( &upstream_base_url, "crm_draft_target", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap(); client .post(format!( "{base_url}/operations/{operation_id}/samples/input-json" )) .json(&json!({ "email": "user@example.com", "name": "Ada" })) .send() .await .unwrap(); client .post(format!( "{base_url}/operations/{operation_id}/samples/output-json" )) .json(&json!({ "id": "lead_123", "status": "created" })) .send() .await .unwrap(); let generated = client .post(format!( "{base_url}/operations/{operation_id}/drafts/generate" )) .json(&json!({ "sources": ["input_json_sample", "output_json_sample"] })) .send() .await .unwrap() .json::() .await .unwrap(); assert_eq!(generated["generated_draft"]["status"], "available"); assert_eq!( generated["input_schema"]["fields"]["email"]["type"], "string" ); assert_eq!( generated["output_mapping"]["rules"][0]["target"], "$.output.id" ); }