341 lines
9.7 KiB
Rust
341 lines
9.7 KiB
Rust
#![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<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
|
|
}
|
|
}
|
|
|
|
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<LoginOutcome, IdentityError> {
|
|
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 {
|
|
AdminServiceBuilder::new(
|
|
registry,
|
|
storage_root,
|
|
auth_settings,
|
|
secret_crypto,
|
|
crank_runtime::RuntimeExecutor::new(),
|
|
)
|
|
.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<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
|
|
}
|
|
|
|
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<Value>) -> Json<Value> {
|
|
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(),
|
|
}
|
|
}
|