diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 539ca308..a5ddaf32 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -893,6 +893,105 @@ fn test_fund_requires_sponsor_auth() { assert!(result.is_err()); } +#[test] +fn test_fund_accepts_targeted_sponsor_auth() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, token_client) = create_token(&env, &token_admin); + let sponsor = Address::generate(&env); + asset_client.mint(&sponsor, &1_000i128); + + let issue_id = 520u64; + let amount = 1_000i128; + let deadline = 1_000u64; + let params = ( + issue_id, + sponsor.clone(), + token_addr.clone(), + amount, + deadline, + Option::::None, + ); + let transfer_params = (sponsor.clone(), contract_id.clone(), amount); + + // Only the sponsor's authorization is mocked for this call (including the + // token transfer it authorizes as a sub-invocation). + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &sponsor, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "fund", + args: soroban_sdk::IntoVal::into_val(¶ms, &env), + sub_invokes: &[soroban_sdk::testutils::MockAuthInvoke { + contract: &token_addr, + fn_name: "transfer", + args: soroban_sdk::IntoVal::into_val(&transfer_params, &env), + sub_invokes: &[], + }], + }, + }]); + + client.fund(&issue_id, &sponsor, &token_addr, &amount, &deadline, &None); + + assert_eq!(client.get_escrow(&issue_id).amount, amount); + assert_eq!(token_client.balance(&sponsor), 0); +} + +#[test] +fn test_fund_rejects_non_sponsor_auth() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, token_client) = create_token(&env, &token_admin); + let sponsor = Address::generate(&env); + asset_client.mint(&sponsor, &1_000i128); + + let attacker = Address::generate(&env); + let issue_id = 521u64; + let amount = 1_000i128; + let deadline = 1_000u64; + let params = ( + issue_id, + sponsor.clone(), + token_addr.clone(), + amount, + deadline, + Option::::None, + ); + let transfer_params = (sponsor.clone(), contract_id.clone(), amount); + + // Mock a non-sponsor address as the signer: the sponsor's `require_auth` + // must still fail with a host authorization error. + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &attacker, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "fund", + args: soroban_sdk::IntoVal::into_val(¶ms, &env), + sub_invokes: &[soroban_sdk::testutils::MockAuthInvoke { + contract: &token_addr, + fn_name: "transfer", + args: soroban_sdk::IntoVal::into_val(&transfer_params, &env), + sub_invokes: &[], + }], + }, + }]); + + assert_host_auth_error(|| { + client.fund(&issue_id, &sponsor, &token_addr, &amount, &deadline, &None) + }); + assert_eq!( + client.try_get_escrow(&issue_id), + Err(Ok(Error::EscrowNotFound)) + ); + assert_eq!(token_client.balance(&sponsor), 1_000i128); +} + #[test] fn test_refund_before_deadline_requires_admin_auth() { let env = Env::default(); @@ -1263,6 +1362,97 @@ fn test_contribute_requires_sponsor_auth() { assert!(result.is_err()); } +#[test] +fn test_contribute_accepts_targeted_sponsor_auth() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, token_client) = create_token(&env, &token_admin); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + asset_client.mint(&alice, &10_000i128); + asset_client.mint(&bob, &10_000i128); + + let issue_id = 522u64; + client.fund(&issue_id, &alice, &token_addr, &5_000i128, &1_000u64, &None); + + let amount = 5_000i128; + let params = (issue_id, bob.clone(), amount); + let transfer_params = (bob.clone(), contract_id.clone(), amount); + + // Only bob's authorization is mocked for this contribution (including the + // token transfer it authorizes as a sub-invocation). + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &bob, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "contribute", + args: soroban_sdk::IntoVal::into_val(¶ms, &env), + sub_invokes: &[soroban_sdk::testutils::MockAuthInvoke { + contract: &token_addr, + fn_name: "transfer", + args: soroban_sdk::IntoVal::into_val(&transfer_params, &env), + sub_invokes: &[], + }], + }, + }]); + + client.contribute(&issue_id, &bob, &amount); + + let escrow = client.get_escrow(&issue_id); + assert_eq!(escrow.amount, 10_000i128); + assert_eq!(escrow.contributor_count, 2); + assert_eq!(token_client.balance(&bob), 5_000i128); +} + +#[test] +fn test_contribute_rejects_non_sponsor_auth() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, token_client) = create_token(&env, &token_admin); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + let attacker = Address::generate(&env); + asset_client.mint(&alice, &10_000i128); + asset_client.mint(&bob, &10_000i128); + + let issue_id = 523u64; + client.fund(&issue_id, &alice, &token_addr, &5_000i128, &1_000u64, &None); + + let amount = 5_000i128; + let params = (issue_id, bob.clone(), amount); + let transfer_params = (bob.clone(), contract_id.clone(), amount); + + // Mock a non-sponsor address as the signer: bob's `require_auth` must + // still fail with a host authorization error. + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &attacker, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "contribute", + args: soroban_sdk::IntoVal::into_val(¶ms, &env), + sub_invokes: &[soroban_sdk::testutils::MockAuthInvoke { + contract: &token_addr, + fn_name: "transfer", + args: soroban_sdk::IntoVal::into_val(&transfer_params, &env), + sub_invokes: &[], + }], + }, + }]); + + assert_host_auth_error(|| client.contribute(&issue_id, &bob, &amount)); + + let escrow = client.get_escrow(&issue_id); + assert_eq!(escrow.amount, 5_000i128); + assert_eq!(escrow.contributor_count, 1); + assert_eq!(token_client.balance(&bob), 10_000i128); +} + #[test] fn test_contribute_rejects_invalid_amount() { let env = Env::default(); diff --git a/contracts/milestones/src/test.rs b/contracts/milestones/src/test.rs index 1b8d0585..7ab259b6 100644 --- a/contracts/milestones/src/test.rs +++ b/contracts/milestones/src/test.rs @@ -1,5 +1,7 @@ #![cfg(test)] +extern crate std; + use super::*; use mergefi_common::{MAX_SPONSORS, MAX_SPONSORS_LIMIT}; use soroban_sdk::{ @@ -7,6 +9,26 @@ use soroban_sdk::{ token, vec, Address, Env, Event, }; +/// Runs `f` and asserts it panics with the host's `Auth/InvalidAction` error. +/// The typed `try_*` client methods collapse host errors into +/// `InvokeError::Abort`, so catching the panic is the only way to assert the +/// specific authorization error code. +fn assert_host_auth_error(f: impl FnOnce()) { + let err = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) + .expect_err("expected the call to fail authorization"); + let message = if let Some(s) = err.downcast_ref::<&str>() { + *s + } else if let Some(s) = err.downcast_ref::() { + s.as_str() + } else { + panic!("authorization panic payload was not a string"); + }; + assert!( + message.contains("Error(Auth, InvalidAction)"), + "expected host auth error, got: {message}" + ); +} + fn assert_event(env: &Env, contract_id: &Address, event: T) { assert!(env .events() @@ -656,6 +678,91 @@ fn test_create_milestone_requires_sponsor_auth() { assert!(result.is_err()); } +#[test] +fn test_create_milestone_accepts_targeted_sponsor_auth() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, token_client) = create_token(&env, &token_admin); + let sponsor = Address::generate(&env); + asset_client.mint(&sponsor, &1_000i128); + + let milestone_id = 60u64; + let amount = 1_000i128; + let deadline = 1_000u64; + let params = (milestone_id, sponsor.clone(), token_addr.clone(), amount, deadline); + let transfer_params = (sponsor.clone(), client.address.clone(), amount); + + // Only the sponsor's authorization is mocked for this call (including the + // token transfer it authorizes as a sub-invocation). + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &sponsor, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &client.address, + fn_name: "create_milestone", + args: soroban_sdk::IntoVal::into_val(¶ms, &env), + sub_invokes: &[soroban_sdk::testutils::MockAuthInvoke { + contract: &token_addr, + fn_name: "transfer", + args: soroban_sdk::IntoVal::into_val(&transfer_params, &env), + sub_invokes: &[], + }], + }, + }]); + + client.create_milestone(&milestone_id, &sponsor, &token_addr, &amount, &deadline); + + assert_eq!(client.get_milestone(&milestone_id).total_budget, amount); + assert_eq!(token_client.balance(&sponsor), 0); +} + +#[test] +fn test_create_milestone_rejects_non_sponsor_auth() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, token_client) = create_token(&env, &token_admin); + let sponsor = Address::generate(&env); + asset_client.mint(&sponsor, &1_000i128); + + let attacker = Address::generate(&env); + let milestone_id = 61u64; + let amount = 1_000i128; + let deadline = 1_000u64; + let params = (milestone_id, sponsor.clone(), token_addr.clone(), amount, deadline); + let transfer_params = (sponsor.clone(), client.address.clone(), amount); + + // Mock a non-sponsor address as the signer: the sponsor's `require_auth` + // must still fail with a host authorization error. + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &attacker, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &client.address, + fn_name: "create_milestone", + args: soroban_sdk::IntoVal::into_val(¶ms, &env), + sub_invokes: &[soroban_sdk::testutils::MockAuthInvoke { + contract: &token_addr, + fn_name: "transfer", + args: soroban_sdk::IntoVal::into_val(&transfer_params, &env), + sub_invokes: &[], + }], + }, + }]); + + assert_host_auth_error(|| { + client.create_milestone(&milestone_id, &sponsor, &token_addr, &amount, &deadline) + }); + assert_eq!( + client.try_get_milestone(&milestone_id), + Err(Ok(Error::MilestoneNotFound)) + ); + assert_eq!(token_client.balance(&sponsor), 1_000i128); +} + #[test] fn test_deallocate_requires_admin_auth() { let env = Env::default(); @@ -732,6 +839,83 @@ fn test_release_issue_requires_admin_auth() { assert!(result.is_err()); } +#[test] +fn test_release_issue_accepts_targeted_oracle_auth() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, treasury, client) = setup(&env); + let oracle = client.get_oracle(); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, token_client) = create_token(&env, &token_admin); + let sponsor = Address::generate(&env); + asset_client.mint(&sponsor, &1_000i128); + + let milestone_id = 70u64; + let issue_id = 701u64; + client.create_milestone(&milestone_id, &sponsor, &token_addr, &1_000i128, &1_000u64); + client.allocate(&milestone_id, &issue_id, &1_000i128); + + let recipient = Address::generate(&env); + let recipients = vec![&env, (recipient.clone(), 10_000u32)]; + let params = (milestone_id, issue_id, recipients.clone()); + + // Only the oracle's authorization is mocked for this release. + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &oracle, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &client.address, + fn_name: "release_issue", + args: soroban_sdk::IntoVal::into_val(¶ms, &env), + sub_invokes: &[], + }, + }]); + + client.release_issue(&milestone_id, &issue_id, &recipients); + + assert_eq!(token_client.balance(&recipient), 950i128); + assert_eq!(token_client.balance(&treasury), 50i128); +} + +#[test] +fn test_release_issue_rejects_non_oracle_auth() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, token_client) = create_token(&env, &token_admin); + let sponsor = Address::generate(&env); + asset_client.mint(&sponsor, &1_000i128); + + let milestone_id = 71u64; + let issue_id = 711u64; + client.create_milestone(&milestone_id, &sponsor, &token_addr, &1_000i128, &1_000u64); + client.allocate(&milestone_id, &issue_id, &1_000i128); + + let attacker = Address::generate(&env); + let recipient = Address::generate(&env); + let recipients = vec![&env, (recipient.clone(), 10_000u32)]; + let params = (milestone_id, issue_id, recipients.clone()); + + // Mock a non-oracle address as the signer: the oracle's `require_auth` + // must still fail with a host authorization error. + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &attacker, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &client.address, + fn_name: "release_issue", + args: soroban_sdk::IntoVal::into_val(¶ms, &env), + sub_invokes: &[], + }, + }]); + + assert_host_auth_error(|| client.release_issue(&milestone_id, &issue_id, &recipients)); + + assert_eq!(token_client.balance(&recipient), 0); + assert!(!client.get_milestone(&milestone_id).closed); +} + #[test] fn test_allocate_requires_admin_auth() { let env = Env::default();