diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs new file mode 100644 index 0000000..bbc65b0 --- /dev/null +++ b/server/crates/arbiter-server/tests/governance.rs @@ -0,0 +1,424 @@ +use arbiter_crypto::authn::{self, GOVERNANCE_CONTEXT}; +use arbiter_server::{ + actors::{ + GlobalActors, + proposal_manager::{CastVote, CreateProposal, Error as ProposalError, ExpireStale, ProposalKind, QueryPending, VoteOutcome}, + }, + crypto::KeyCell, + db, +}; +use arbiter_server::actors::vault::Bootstrap; +use arbiter_server::db::schema::operator_identity; +use diesel::{ExpressionMethods, QueryDsl, insert_into}; +use diesel_async::RunQueryDsl; + +async fn register_operator(db: &db::DatabasePool, pubkey: &authn::PublicKey) -> i32 { + let mut conn = db.get().await.unwrap(); + insert_into(operator_identity::table) + .values(operator_identity::public_key.eq(pubkey.to_bytes())) + .returning(operator_identity::id) + .get_result::(&mut conn) + .await + .unwrap() +} + +fn make_vote_message(proposal_id: i32, approve: bool) -> Vec { + let mut msg = Vec::with_capacity(9); + msg.extend_from_slice(&(proposal_id as i64).to_be_bytes()); + msg.push(u8::from(approve)); + msg +} + +async fn insert_unapproved_client(db: &db::DatabasePool, pubkey: &authn::PublicKey) -> i32 { + use arbiter_server::db::schema::{client_metadata, program_client}; + let mut conn = db.get().await.unwrap(); + let metadata_id: i32 = insert_into(client_metadata::table) + .values(( + client_metadata::name.eq("test-client"), + client_metadata::description.eq(Option::::None), + client_metadata::version.eq(Option::::None), + )) + .returning(client_metadata::id) + .get_result(&mut conn) + .await + .unwrap(); + + insert_into(program_client::table) + .values(( + program_client::public_key.eq(pubkey.to_bytes()), + program_client::metadata_id.eq(metadata_id), + )) + .returning(program_client::id) + .get_result(&mut conn) + .await + .unwrap() +} + +#[tokio::test] +async fn create_proposal_returns_id() { + let db = db::create_test_pool().await; + let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + actors + .vault + .ask(Bootstrap { + seal_key: KeyCell::from([0u8; 32]), + }) + .await + .unwrap(); + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient { client_id: 42 }, + initiator_id: 1, + ttl_secs: None, + }) + .await + .unwrap(); + + assert!(proposal_id > 0); +} + +#[tokio::test] +async fn single_operator_vote_reaches_quorum() { + let db = db::create_test_pool().await; + let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + actors + .vault + .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) + .await + .unwrap(); + + let signing_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &signing_key.public_key()).await; + + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient { client_id }, + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + assert_eq!(outcome, VoteOutcome::QuorumApproved); +} + +#[tokio::test] +async fn two_operator_first_vote_is_pending() { + let db = db::create_test_pool().await; + let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + actors + .vault + .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) + .await + .unwrap(); + + let key1 = authn::SigningKey::generate(); + let key2 = authn::SigningKey::generate(); + let op1 = register_operator(&db, &key1.public_key()).await; + let _op2 = register_operator(&db, &key2.public_key()).await; + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient { client_id }, + initiator_id: op1, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = key1.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op1, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + assert_eq!(outcome, VoteOutcome::Pending); +} + +#[tokio::test] +async fn duplicate_vote_rejected() { + let db = db::create_test_pool().await; + let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + actors + .vault + .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) + .await + .unwrap(); + + let key = authn::SigningKey::generate(); + let op = register_operator(&db, &key.public_key()).await; + + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient { client_id }, + initiator_id: op, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + // Second vote same operator + let sig2 = key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let result = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op, + approve: true, + signature: sig2.to_bytes(), + }) + .await; + + assert!(matches!( + result, + Err(kameo::error::SendError::HandlerError(ProposalError::AlreadyVoted)) + )); +} + +#[tokio::test] +async fn invalid_signature_rejected() { + let db = db::create_test_pool().await; + let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + actors + .vault + .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) + .await + .unwrap(); + + let key = authn::SigningKey::generate(); + let op = register_operator(&db, &key.public_key()).await; + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient { client_id }, + initiator_id: op, + ttl_secs: None, + }) + .await + .unwrap(); + + let result = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op, + approve: true, + signature: vec![0u8; 32], // garbage + }) + .await; + + assert!(matches!( + result, + Err(kameo::error::SendError::HandlerError(ProposalError::InvalidSignature)) + )); +} + +#[tokio::test] +async fn query_pending_excludes_already_voted() { + let db = db::create_test_pool().await; + let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + actors + .vault + .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) + .await + .unwrap(); + + let signing_key = authn::SigningKey::generate(); + let op = register_operator(&db, &signing_key.public_key()).await; + + let client_key1 = authn::SigningKey::generate(); + let client_id1 = insert_unapproved_client(&db, &client_key1.public_key()).await; + let client_key2 = authn::SigningKey::generate(); + let client_id2 = insert_unapproved_client(&db, &client_key2.public_key()).await; + + let p1 = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient { client_id: client_id1 }, + initiator_id: op, + ttl_secs: None, + }) + .await + .unwrap(); + + let p2 = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient { client_id: client_id2 }, + initiator_id: op, + ttl_secs: None, + }) + .await + .unwrap(); + + // Vote on p1 — with 1 operator this reaches quorum (QuorumApproved) + let msg = make_vote_message(p1, true); + let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id: p1, + operator_id: op, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + assert_eq!(outcome, VoteOutcome::QuorumApproved); + + // QueryPending should return only p2 + let pending = actors + .proposal_manager + .ask(QueryPending { operator_id: op }) + .await + .unwrap(); + + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].id, p2); +} + +#[tokio::test] +async fn expire_stale_marks_old_proposals_expired() { + let db = db::create_test_pool().await; + let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + actors + .vault + .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) + .await + .unwrap(); + + let signing_key = authn::SigningKey::generate(); + let op = register_operator(&db, &signing_key.public_key()).await; + + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + // Create proposal with ttl_secs = -1 so it's immediately expired + let _proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient { client_id }, + initiator_id: op, + ttl_secs: Some(-1), + }) + .await + .unwrap(); + + let expired = actors + .proposal_manager + .ask(ExpireStale) + .await + .unwrap(); + assert_eq!(expired, 1); + + let pending = actors + .proposal_manager + .ask(QueryPending { operator_id: op }) + .await + .unwrap(); + assert!(pending.is_empty()); +} + +#[tokio::test] +async fn approve_sdk_client_writes_integrity_envelope() { + use arbiter_server::db::schema::integrity_envelope; + + let db = db::create_test_pool().await; + let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + actors + .vault + .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) + .await + .unwrap(); + + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let op_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &op_key.public_key()).await; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient { client_id }, + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = op_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + assert_eq!(outcome, VoteOutcome::QuorumApproved); + + let mut conn = db.get().await.unwrap(); + let count: i64 = integrity_envelope::table + .filter(integrity_envelope::entity_kind.eq("client_credentials")) + .count() + .get_result(&mut conn) + .await + .unwrap(); + assert_eq!(count, 1); +}