425 lines
12 KiB
Rust
425 lines
12 KiB
Rust
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::<i32>(&mut conn)
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
fn make_vote_message(proposal_id: i32, approve: bool) -> Vec<u8> {
|
|
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::<String>::None),
|
|
client_metadata::version.eq(Option::<String>::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);
|
|
}
|