use cm_domain::{ AccessPolicy, Agent, AgentId, AgentStatus, GatedCategory, Role, User, UserId, Workspace, WorkspaceId, }; use cm_safety::{ approvals, checkpoint, grants, ApprovalStatus, Decision, NewApproval, SafetyError, }; use serde_json::json; struct Seed { workspace: Workspace, owner: User, agent: Agent, run_id: uuid::Uuid, } async fn seeded(pool: &sqlx::PgPool) -> Seed { let workspace = Workspace { id: WorkspaceId::new(), name: "Acme".into(), plan: "team".into(), }; cm_db::repo::workspaces::insert(pool, &workspace) .await .unwrap(); let owner = User { id: UserId::new(), workspace_id: workspace.id, email: format!("{}@acme.test", UserId::new()), role: Role::Owner, display_name: "Owner".into(), created_at: time::OffsetDateTime::UNIX_EPOCH, }; cm_db::repo::users::insert(pool, &owner).await.unwrap(); let agent = Agent { id: AgentId::new(), workspace_id: workspace.id, name: "Scout".into(), job_title: "Analyst".into(), system_prompt: String::new(), avatar: String::new(), accent: String::new(), wallpaper: String::new(), managed_by: owner.id, status: AgentStatus::Online, }; cm_db::repo::agents::insert(pool, &agent, &AccessPolicy::default()) .await .unwrap(); let session = cm_db::repo::sessions::create(pool, agent.id, workspace.id, "Chat") .await .unwrap(); let run_id = cm_db::repo::runs::create(pool, session.id).await.unwrap(); Seed { workspace, owner, agent, run_id, } } fn new_approval(seed: &Seed) -> NewApproval { NewApproval { workspace_id: seed.workspace.id, run_id: seed.run_id, session_key: "agent:x-claw-0:session:y:z".into(), action_type: "email.send".into(), category: GatedCategory::OutboundMessage, payload: json!({"to": "ceo@example.com", "subject": "Q2", "body": "Numbers"}), preview: json!({"summary": "Send email to ceo@example.com", "body": "Numbers"}), requested_by_agent: seed.agent.id, taint_sources: vec![], expires_at: None, } } #[tokio::test] async fn created_approvals_are_listed_pending_with_exact_preview() { let pool = cm_testkit::test_pool().await; let seed = seeded(&pool).await; let approval = approvals::create(&pool, new_approval(&seed)).await.unwrap(); assert_eq!(approval.status, ApprovalStatus::Pending); let pending = approvals::list_pending(&pool, seed.workspace.id) .await .unwrap(); assert_eq!(pending.len(), 1); assert_eq!(pending[0].id, approval.id); assert_eq!( pending[0].preview["summary"], "Send email to ceo@example.com" ); assert_eq!(pending[0].category, GatedCategory::OutboundMessage); } #[tokio::test] async fn approve_is_a_cas_that_mints_one_grant_and_audits() { let pool = cm_testkit::test_pool().await; let seed = seeded(&pool).await; let approval = approvals::create(&pool, new_approval(&seed)).await.unwrap(); let decided = approvals::decide(&pool, approval.id, seed.owner.id, Decision::Approve) .await .unwrap(); assert_eq!(decided.status, ApprovalStatus::Approved); assert_eq!(decided.decided_by, Some(seed.owner.id)); // Double-decide is rejected, not silently absorbed. let again = approvals::decide(&pool, approval.id, seed.owner.id, Decision::Approve).await; assert!(matches!(again, Err(SafetyError::AlreadyDecided))); // Decision is audit-logged. let audited = sqlx::query_scalar::<_, i64>( "SELECT count(*) FROM audit_log WHERE event_type = 'approval.approved'", ) .fetch_one(&pool) .await .unwrap(); assert_eq!(audited, 1); // The grant is single-use: first consumption succeeds, second refuses. grants::consume(&pool, approval.id).await.unwrap(); let reused = grants::consume(&pool, approval.id).await; assert!(matches!(reused, Err(SafetyError::GrantUnavailable))); } #[tokio::test] async fn reject_mints_no_grant() { let pool = cm_testkit::test_pool().await; let seed = seeded(&pool).await; let approval = approvals::create(&pool, new_approval(&seed)).await.unwrap(); let decided = approvals::decide(&pool, approval.id, seed.owner.id, Decision::Reject) .await .unwrap(); assert_eq!(decided.status, ApprovalStatus::Rejected); let no_grant = grants::consume(&pool, approval.id).await; assert!(matches!(no_grant, Err(SafetyError::GrantUnavailable))); } #[tokio::test] async fn pending_approvals_cannot_be_consumed() { let pool = cm_testkit::test_pool().await; let seed = seeded(&pool).await; let approval = approvals::create(&pool, new_approval(&seed)).await.unwrap(); let blocked = grants::consume(&pool, approval.id).await; assert!(matches!(blocked, Err(SafetyError::GrantUnavailable))); } #[tokio::test] async fn checkpoint_round_trips_and_resume_claim_is_exclusive() { let pool = cm_testkit::test_pool().await; let seed = seeded(&pool).await; let state = json!({"request": {"model": "scripted"}, "step_seq": 3}); checkpoint::suspend(&pool, seed.run_id, &state) .await .unwrap(); let run = cm_db::repo::runs::get(&pool, seed.run_id).await.unwrap(); assert_eq!(run.state, cm_domain::RunState::AwaitingApproval); let loaded = checkpoint::load(&pool, seed.run_id).await.unwrap(); assert_eq!(loaded, state); // Exactly one resumer can claim the run. assert!(checkpoint::claim_resume(&pool, seed.run_id).await.unwrap()); assert!(!checkpoint::claim_resume(&pool, seed.run_id).await.unwrap()); } #[tokio::test] async fn expiry_sweep_expires_overdue_pending_approvals() { let pool = cm_testkit::test_pool().await; let seed = seeded(&pool).await; let mut overdue = new_approval(&seed); overdue.expires_at = Some(time::OffsetDateTime::now_utc() - time::Duration::minutes(5)); let approval = approvals::create(&pool, overdue).await.unwrap(); let expired = approvals::sweep_expired(&pool).await.unwrap(); assert_eq!(expired, 1); let after = approvals::get(&pool, approval.id).await.unwrap(); assert_eq!(after.status, ApprovalStatus::Expired); // Expired approvals cannot be decided. let late = approvals::decide(&pool, approval.id, seed.owner.id, Decision::Approve).await; assert!(matches!(late, Err(SafetyError::AlreadyDecided))); } #[tokio::test] async fn decided_unresumed_finds_runs_awaiting_resume() { let pool = cm_testkit::test_pool().await; let seed = seeded(&pool).await; let approval = approvals::create(&pool, new_approval(&seed)).await.unwrap(); checkpoint::suspend(&pool, seed.run_id, &json!({"s": 1})) .await .unwrap(); // Nothing decided yet. assert!(approvals::decided_unresumed(&pool) .await .unwrap() .is_empty()); approvals::decide(&pool, approval.id, seed.owner.id, Decision::Approve) .await .unwrap(); let ready = approvals::decided_unresumed(&pool).await.unwrap(); assert_eq!(ready.len(), 1); assert_eq!(ready[0].run_id, seed.run_id); assert_eq!(ready[0].approval_id, approval.id); }