453 lines
13 KiB
Rust
453 lines
13 KiB
Rust
#![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<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 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 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}")
|
|
);
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
}
|