feat: complete Epic 1 production foundation

This commit is contained in:
2026-08-25 01:24:11 +03:00
parent 767428436d
commit 182bde8ac0
298 changed files with 35719 additions and 5299 deletions
@@ -36,7 +36,7 @@ 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";
const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000";
struct TestServer {
base_url: String,
@@ -123,15 +123,6 @@ async fn creates_publishes_and_tests_rest_operation() {
.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_response = client
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
.header("x-request-id", "req_admin_test_run")
@@ -151,6 +142,19 @@ async fn creates_publishes_and_tests_rest_operation() {
"0af7651916cd43dd8448eb211c80319c"
);
let test_run = test_run_response.json::<Value>().await.unwrap();
let published = client
.post(format!("{base_url}/operations/{operation_id}/publish"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(listed["items"][0]["name"], "crm_create_lead");
assert_eq!(
@@ -160,20 +164,24 @@ async fn creates_publishes_and_tests_rest_operation() {
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["request_preview"]["body_configured"], true);
let safe_preview = test_run["request_preview"].to_string();
assert!(!safe_preview.contains("user@example.com"));
assert_eq!(test_run["response_preview"]["id"], "lead_123");
let logs = registry
.list_invocation_logs(crank_registry::ListInvocationLogsQuery {
workspace_id: &WorkspaceId::new(DEFAULT_WORKSPACE_ID),
level: None,
status: None,
outcome_group: None,
search_text: None,
source: Some(crank_core::InvocationSource::AdminTestRun),
operation_id: Some(&crank_core::OperationId::new(&operation_id)),
agent_id: None,
created_after: None,
created_before: None,
cursor_created_at: None,
cursor_id: None,
limit: 10,
})
.await
@@ -267,6 +275,10 @@ async fn updates_archives_and_deletes_operation() {
let updated = client
.patch(format!("{base_url}/operations/{operation_id}"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.json(&json!({
"display_name": "Create Lead Updated",
"category": "marketing",
@@ -350,6 +362,10 @@ async fn updates_archives_and_deletes_operation() {
let archived = client
.post(format!("{base_url}/operations/{operation_id}/archive"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.send()
.await
.unwrap()
@@ -360,29 +376,29 @@ async fn updates_archives_and_deletes_operation() {
let deleted = client
.delete(format!("{base_url}/operations/{operation_id}"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.send()
.await
.unwrap();
assert_eq!(deleted.status(), reqwest::StatusCode::CONFLICT);
let deleted = deleted.json::<Value>().await.unwrap();
assert_eq!(
deleted["error"]["context"]["error_code"],
"operation_delete_forbidden"
);
let retained = client
.get(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
})
);
assert_eq!(retained["status"], "archived");
}
#[tokio::test(flavor = "multi_thread")]
@@ -408,6 +424,10 @@ async fn creates_binds_and_publishes_agent() {
client
.post(format!("{base_url}/operations/{operation_id}/publish"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
@@ -432,6 +452,10 @@ async fn creates_binds_and_publishes_agent() {
let bindings = client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!([
{
"operation_id": operation_id,
@@ -449,6 +473,10 @@ async fn creates_binds_and_publishes_agent() {
.unwrap();
let published = client
.post(format!("{base_url}/agents/{agent_id}/publish"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
@@ -489,6 +517,10 @@ async fn saves_and_previews_versioned_agent_tool_search_policy() {
assert_success_json(
client
.post(format!("{base_url}/operations/{operation_id}/publish"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.json(&json!({"version": 1}))
.send()
.await
@@ -535,6 +567,10 @@ async fn saves_and_previews_versioned_agent_tool_search_policy() {
let saved = assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&catalog)
.send()
.await
@@ -565,6 +601,10 @@ async fn saves_and_previews_versioned_agent_tool_search_policy() {
let published = assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/publish"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!({"version": 1}))
.send()
.await
@@ -576,7 +616,7 @@ async fn saves_and_previews_versioned_agent_tool_search_policy() {
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
async fn agent_binding_rejects_draft_operation_before_publish() {
let registry = test_registry().await;
let storage_root = test_storage_root("agent_publish_filters_drafts");
let upstream_base_url = spawn_upstream_server().await;
@@ -604,6 +644,10 @@ async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
.post(format!(
"{base_url}/operations/{published_operation_id}/publish"
))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &published_operation_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
@@ -642,9 +686,47 @@ async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
.await;
let agent_id = agent["agent_id"].as_str().unwrap().to_owned();
let rejected = client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!([
{
"operation_id": published_operation_id,
"operation_version": 1,
"tool_name": "crm_published_tool",
"tool_title": "Published Tool",
"tool_description_override": null,
"enabled": true
},
{
"operation_id": draft_operation_id,
"operation_version": 1,
"tool_name": "crm_draft_tool",
"tool_title": "Draft Tool",
"tool_description_override": null,
"enabled": true
}
]))
.send()
.await
.unwrap();
assert_eq!(rejected.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
let rejected_body = rejected.json::<Value>().await.unwrap();
assert_eq!(
rejected_body["error"]["code"],
"agent_binding_not_published"
);
assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!([
{
"operation_id": published_operation_id,
@@ -653,14 +735,6 @@ async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
"tool_title": "Published Tool",
"tool_description_override": null,
"enabled": true
},
{
"operation_id": draft_operation_id,
"operation_version": 1,
"tool_name": "crm_draft_tool",
"tool_title": "Draft Tool",
"tool_description_override": null,
"enabled": true
}
]))
.send()
@@ -672,6 +746,10 @@ async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
let published = assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/publish"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
@@ -694,24 +772,15 @@ async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
.unwrap(),
)
.await;
let draft_version = assert_success_json(
client
.get(format!("{base_url}/agents/{agent_id}/versions/2"))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(published["published_version"], 1);
assert_eq!(agent_detail["current_draft_version"], 2);
assert_eq!(agent_detail["current_draft_version"], 1);
assert_eq!(agent_detail["latest_published_version"], 1);
assert_eq!(published_version["bindings"].as_array().unwrap().len(), 1);
assert_eq!(
published_version["bindings"][0]["operation_id"],
published_operation_id
);
assert_eq!(draft_version["bindings"].as_array().unwrap().len(), 2);
}
#[tokio::test(flavor = "multi_thread")]
@@ -737,6 +806,10 @@ async fn updates_lists_and_deletes_agent() {
client
.post(format!("{base_url}/operations/{operation_id}/publish"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
@@ -761,6 +834,10 @@ async fn updates_lists_and_deletes_agent() {
client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!([
{
"operation_id": operation_id,
@@ -777,6 +854,10 @@ async fn updates_lists_and_deletes_agent() {
client
.post(format!("{base_url}/agents/{agent_id}/publish"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
@@ -800,6 +881,10 @@ async fn updates_lists_and_deletes_agent() {
let updated = client
.patch(format!("{base_url}/agents/{agent_id}"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!({
"slug": "support-escalation",
"display_name": "Support Escalation",
@@ -807,11 +892,17 @@ async fn updates_lists_and_deletes_agent() {
}))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(updated["agent_id"], agent_id);
assert_eq!(
updated.status(),
reqwest::StatusCode::CONFLICT,
"published Agent summary/slug must not mutate published MCP endpoint identity"
);
let updated = updated.json::<Value>().await.unwrap();
assert_eq!(
updated["error"]["context"]["error_code"],
"agent_published_summary_immutable"
);
let detail = client
.get(format!("{base_url}/agents/{agent_id}"))
@@ -821,148 +912,35 @@ async fn updates_lists_and_deletes_agent() {
.json::<Value>()
.await
.unwrap();
assert_eq!(detail["slug"], "support-escalation");
assert_eq!(detail["display_name"], "Support Escalation");
assert_eq!(detail["slug"], "support-team");
assert_eq!(detail["display_name"], "Support Team");
assert_eq!(detail["operation_count"], 1);
assert_eq!(detail["mcp_endpoint"], "/mcp/v1/default/support-escalation");
assert_eq!(detail["mcp_endpoint"], "/mcp/v1/default/support-team");
let deleted = client
let delete_response = client
.delete(format!("{base_url}/agents/{agent_id}"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(deleted["agent_id"], agent_id);
assert_eq!(
delete_response.status(),
reqwest::StatusCode::CONFLICT,
"published Agent must not be hard-deleted because immutable versions/history must remain"
);
let delete_body = delete_response.json::<Value>().await.unwrap();
assert_eq!(
delete_body["error"]["context"]["error_code"],
"agent_delete_forbidden"
);
let missing = client
let still_present = 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");
assert_eq!(still_present.status(), reqwest::StatusCode::OK);
}