315 lines
11 KiB
Rust
315 lines
11 KiB
Rust
use std::sync::Arc;
|
|
|
|
use crank_core::{
|
|
AgentId, AgentOperationBinding, AgentStatus, ApprovalRequest, ApprovalRequestId,
|
|
ApprovalRequestStatus, OperationApprovalRiskLevel, OperationId, PlatformApiKey,
|
|
PlatformApiKeyId, PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, WorkspaceId,
|
|
};
|
|
use crank_registry::{
|
|
CreateAgentRequest, CreateApprovalRequest, CreatePlatformApiKeyRequest, DecideApprovalRequest,
|
|
FinishApprovalRequest,
|
|
};
|
|
use serde_json::{Value, json};
|
|
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
|
|
|
use super::common::{TestDatabase, test_agent, test_agent_version, test_operation};
|
|
|
|
fn timestamp(value: &str) -> OffsetDateTime {
|
|
OffsetDateTime::parse(value, &Rfc3339).unwrap()
|
|
}
|
|
|
|
fn approval_request(id: &str, payload: Value) -> ApprovalRequest {
|
|
ApprovalRequest {
|
|
id: ApprovalRequestId::new(id),
|
|
workspace_id: WorkspaceId::new("ws_default"),
|
|
agent_id: AgentId::new("agent_approval_atomic"),
|
|
operation_id: OperationId::new("operation_approval_atomic"),
|
|
operation_version: 1,
|
|
status: ApprovalRequestStatus::Pending,
|
|
risk_level: OperationApprovalRiskLevel::Dangerous,
|
|
request_id: None,
|
|
trace_id: None,
|
|
request_payload: payload,
|
|
response_payload: None,
|
|
created_at: timestamp("2026-03-25T12:01:00Z"),
|
|
expires_at: timestamp("2027-03-25T12:06:00Z"),
|
|
decided_at: None,
|
|
decided_by_key_id: None,
|
|
decision_note: None,
|
|
}
|
|
}
|
|
|
|
fn approval_key() -> PlatformApiKey {
|
|
PlatformApiKey {
|
|
id: PlatformApiKeyId::new("approval_atomic_key"),
|
|
workspace_id: WorkspaceId::new("ws_default"),
|
|
agent_id: Some(AgentId::new("agent_approval_atomic")),
|
|
key_kind: PlatformApiKeyKind::Approval,
|
|
name: "approval atomic key".to_owned(),
|
|
prefix: "crk_appr".to_owned(),
|
|
scopes: vec![PlatformApiKeyScope::Approve, PlatformApiKeyScope::Deny],
|
|
status: PlatformApiKeyStatus::Active,
|
|
created_at: timestamp("2026-03-25T12:00:00Z"),
|
|
last_used_at: None,
|
|
expires_at: None,
|
|
allowed_origins: Vec::new(),
|
|
}
|
|
}
|
|
|
|
async fn seed_approval_graph(registry: &crank_registry::PostgresRegistry) {
|
|
let workspace_id = WorkspaceId::new("ws_default");
|
|
let operation = test_operation(
|
|
"operation_approval_atomic",
|
|
1,
|
|
crank_core::OperationStatus::Published,
|
|
);
|
|
registry
|
|
.create_operation(&workspace_id, &operation, None)
|
|
.await
|
|
.unwrap();
|
|
|
|
let agent = test_agent("agent_approval_atomic", AgentStatus::Draft);
|
|
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
|
|
registry
|
|
.create_agent(CreateAgentRequest {
|
|
agent: &agent,
|
|
version: &version,
|
|
bindings: &[AgentOperationBinding {
|
|
operation_id: operation.id.clone(),
|
|
operation_version: 1,
|
|
tool_name: "dangerous_write".to_owned(),
|
|
tool_title: "Dangerous write".to_owned(),
|
|
tool_description_override: None,
|
|
enabled: true,
|
|
}],
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let key = approval_key();
|
|
registry
|
|
.create_platform_api_key(CreatePlatformApiKeyRequest {
|
|
api_key: &key,
|
|
secret_hash: "approval-key-secret-hash",
|
|
})
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn concurrent_equal_scope_creates_one_pending_approval_without_losing_raw_execution_payload()
|
|
{
|
|
let database = TestDatabase::new().await;
|
|
let registry = Arc::new(database.registry().await);
|
|
seed_approval_graph(®istry).await;
|
|
|
|
let mut tasks = Vec::new();
|
|
for index in 0..32 {
|
|
let registry = Arc::clone(®istry);
|
|
tasks.push(tokio::spawn(async move {
|
|
let mut approval = approval_request(
|
|
&format!("approval_concurrent_{index}"),
|
|
json!({
|
|
"email": "customer@example.com",
|
|
"token": "SECRET_APPROVAL_CANARY",
|
|
"_crank_confirmation_token": format!("ct_{index}")
|
|
}),
|
|
);
|
|
approval.created_at += time::Duration::milliseconds(index);
|
|
registry
|
|
.create_approval_request(CreateApprovalRequest {
|
|
approval: &approval,
|
|
})
|
|
.await
|
|
.unwrap()
|
|
}));
|
|
}
|
|
|
|
let mut ids = std::collections::BTreeSet::new();
|
|
for task in tasks {
|
|
ids.insert(task.await.unwrap().approval.id.as_str().to_owned());
|
|
}
|
|
assert_eq!(
|
|
ids.len(),
|
|
1,
|
|
"all callers must receive one canonical pending approval"
|
|
);
|
|
|
|
let pending = registry
|
|
.list_pending_approval_requests_for_agent(
|
|
&WorkspaceId::new("ws_default"),
|
|
&AgentId::new("agent_approval_atomic"),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(pending.len(), 1);
|
|
assert_eq!(
|
|
pending[0].approval.request_payload["token"], "SECRET_APPROVAL_CANARY",
|
|
"registry keeps the raw payload for the eventual approved execution"
|
|
);
|
|
assert!(
|
|
pending[0].approval.request_payload["_crank_confirmation_token"]
|
|
.as_str()
|
|
.is_some_and(|value| value.starts_with("ct_")),
|
|
"only projections may redact control data; execution input remains intact"
|
|
);
|
|
let safe_preview =
|
|
crank_core::sanitize_invocation_preview(&pending[0].approval.request_payload);
|
|
let preview = safe_preview.to_string();
|
|
assert!(!preview.contains("SECRET_APPROVAL_CANARY"));
|
|
|
|
database.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn nested_business_control_like_fields_are_part_of_approval_fingerprint() {
|
|
let database = TestDatabase::new().await;
|
|
let registry = database.registry().await;
|
|
seed_approval_graph(®istry).await;
|
|
|
|
let first = approval_request(
|
|
"approval_nested_control_a",
|
|
json!({
|
|
"line": {
|
|
"_crank_approval_id": "business-a"
|
|
},
|
|
"_crank_confirmation_token": "transport-a"
|
|
}),
|
|
);
|
|
registry
|
|
.create_approval_request(CreateApprovalRequest { approval: &first })
|
|
.await
|
|
.unwrap();
|
|
let second = approval_request(
|
|
"approval_nested_control_b",
|
|
json!({
|
|
"line": {
|
|
"_crank_approval_id": "business-b"
|
|
},
|
|
"_crank_confirmation_token": "transport-b"
|
|
}),
|
|
);
|
|
let created_second = registry
|
|
.create_approval_request(CreateApprovalRequest { approval: &second })
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(created_second.approval.id, second.id);
|
|
let pending = registry
|
|
.list_pending_approval_requests_for_agent(
|
|
&WorkspaceId::new("ws_default"),
|
|
&AgentId::new("agent_approval_atomic"),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(pending.len(), 2);
|
|
|
|
database.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn decision_claim_finish_and_replay_are_single_winner_transitions() {
|
|
let database = TestDatabase::new().await;
|
|
let registry = Arc::new(database.registry().await);
|
|
seed_approval_graph(®istry).await;
|
|
let approval = approval_request(
|
|
"approval_single_winner",
|
|
json!({"email":"lead@example.com"}),
|
|
);
|
|
let created = registry
|
|
.create_approval_request(CreateApprovalRequest {
|
|
approval: &approval,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(created.approval.status, ApprovalRequestStatus::Pending);
|
|
|
|
let mut decisions = Vec::new();
|
|
for index in 0..16 {
|
|
let registry = Arc::clone(®istry);
|
|
let operation_id = approval.operation_id.clone();
|
|
let operation_version = approval.operation_version;
|
|
let request_payload = approval.request_payload.clone();
|
|
decisions.push(tokio::spawn(async move {
|
|
let key_id = PlatformApiKeyId::new("approval_atomic_key");
|
|
registry
|
|
.decide_approval_request(DecideApprovalRequest {
|
|
workspace_id: &WorkspaceId::new("ws_default"),
|
|
agent_id: &AgentId::new("agent_approval_atomic"),
|
|
approval_id: &ApprovalRequestId::new("approval_single_winner"),
|
|
operation_id: &operation_id,
|
|
operation_version,
|
|
request_payload: &request_payload,
|
|
status: if index % 2 == 0 {
|
|
ApprovalRequestStatus::Approved
|
|
} else {
|
|
ApprovalRequestStatus::Denied
|
|
},
|
|
decided_at: timestamp("2026-03-25T12:02:00Z"),
|
|
decided_by_key_id: Some(&key_id),
|
|
response_payload: Some(json!({ "decision": index })),
|
|
decision_note: Some("race decision"),
|
|
})
|
|
.await
|
|
.unwrap()
|
|
}));
|
|
}
|
|
let mut winners = 0;
|
|
for decision in decisions {
|
|
if decision.await.unwrap().is_some() {
|
|
winners += 1;
|
|
}
|
|
}
|
|
assert_eq!(winners, 1);
|
|
|
|
let mut claims = Vec::new();
|
|
for _ in 0..16 {
|
|
let registry = Arc::clone(®istry);
|
|
claims.push(tokio::spawn(async move {
|
|
registry
|
|
.claim_approval_request(
|
|
&WorkspaceId::new("ws_default"),
|
|
&AgentId::new("agent_approval_atomic"),
|
|
&ApprovalRequestId::new("approval_single_winner"),
|
|
timestamp("2026-03-25T12:02:01Z"),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
}));
|
|
}
|
|
let mut claim_winners = 0;
|
|
for claim in claims {
|
|
if claim.await.unwrap().is_some() {
|
|
claim_winners += 1;
|
|
}
|
|
}
|
|
assert_eq!(claim_winners, 1);
|
|
|
|
let first_finish = registry
|
|
.finish_approval_request(FinishApprovalRequest {
|
|
workspace_id: &WorkspaceId::new("ws_default"),
|
|
agent_id: &AgentId::new("agent_approval_atomic"),
|
|
approval_id: &ApprovalRequestId::new("approval_single_winner"),
|
|
status: ApprovalRequestStatus::Completed,
|
|
response_payload: Some(json!({"ok":true})),
|
|
decision_note: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(first_finish.is_some());
|
|
let replay_finish = registry
|
|
.finish_approval_request(FinishApprovalRequest {
|
|
workspace_id: &WorkspaceId::new("ws_default"),
|
|
agent_id: &AgentId::new("agent_approval_atomic"),
|
|
approval_id: &ApprovalRequestId::new("approval_single_winner"),
|
|
status: ApprovalRequestStatus::Completed,
|
|
response_payload: Some(json!({"ok":"replay"})),
|
|
decision_note: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(replay_finish.is_none());
|
|
|
|
database.cleanup().await;
|
|
}
|