diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65998a0..b3566e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,17 @@ jobs: # during the build. No real DB connection is made at build time. DATABASE_URL: ${{ secrets.DATABASE_URL || 'postgres://build:build@localhost/build' }} JWT_SECRET: ${{ secrets.JWT_SECRET || 'build-time-placeholder-secret-32chars!!' }} + + contracts: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Run Soroban Contract Tests + working-directory: contracts + run: cargo test --workspace diff --git a/contracts/contracts/dispute/src/lib.rs b/contracts/contracts/dispute/src/lib.rs index ab48a03..af5664b 100644 --- a/contracts/contracts/dispute/src/lib.rs +++ b/contracts/contracts/dispute/src/lib.rs @@ -253,8 +253,7 @@ impl DisputeContract { Ok(()) } - pub fn resolve(env: Env, dispute_id: u32) -> Result { - let caller = env.caller(); + pub fn resolve(env: Env, caller: Address, dispute_id: u32) -> Result { caller.require_auth(); let mut dispute: Dispute = env diff --git a/contracts/contracts/dispute/src/test.rs b/contracts/contracts/dispute/src/test.rs index 6e117f4..6e4f24d 100644 --- a/contracts/contracts/dispute/src/test.rs +++ b/contracts/contracts/dispute/src/test.rs @@ -108,7 +108,7 @@ fn test_voting_and_resolving() { // Fast forward to after deadline env.ledger().set_timestamp(1101); - let is_resolved_in_favor = client.resolve(&dispute_id); + let is_resolved_in_favor = client.resolve(&caller, &dispute_id); assert_eq!(is_resolved_in_favor, true); // votes_for > votes_against let dispute_final = client.get_dispute(&dispute_id); diff --git a/contracts/contracts/escrow/src/lib.rs b/contracts/contracts/escrow/src/lib.rs index b5f6a5a..0980aad 100644 --- a/contracts/contracts/escrow/src/lib.rs +++ b/contracts/contracts/escrow/src/lib.rs @@ -545,8 +545,8 @@ impl EscrowContract { Ok(()) } - pub fn auto_expire(env: Env, milestone_id: u32) -> Result<(), Error> { - let caller = env.caller(); + pub fn auto_expire(env: Env, caller: Address, milestone_id: u32) -> Result<(), Error> { + caller.require_auth(); let mut milestone: Milestone = env.storage().instance().get(&DataKey::Milestone(milestone_id)).ok_or(Error::MilestoneNotFound)?; if milestone.deadline == 0 { diff --git a/contracts/contracts/escrow/src/test.rs b/contracts/contracts/escrow/src/test.rs index 43e5768..e525e3c 100644 --- a/contracts/contracts/escrow/src/test.rs +++ b/contracts/contracts/escrow/src/test.rs @@ -2,7 +2,7 @@ use super::*; use soroban_sdk::{ - testutils::Address as _, + testutils::{Address as _, Events as _}, vec, Address, Env, String, }; @@ -51,6 +51,37 @@ fn setup_test() -> TestSetup { } } +fn milestone(env: &Env, id: u32, amount: i128) -> Milestone { + Milestone { + id, + deadline: 0, + amount, + status: MilestoneStatus::Pending, + description: String::from_str(env, "Security milestone"), + client_approved: false, + freelancer_approved: false, + } +} + +fn initialize_single_milestone(setup: &TestSetup, amount: i128) { + let milestones = vec![&setup.env, milestone(&setup.env, 1, amount)]; + setup.escrow_client.initialize( + &setup.admin, + &setup.client, + &setup.freelancer, + &setup.arbiter, + &setup.token_address, + &milestones, + ); +} + +fn fully_approve_single_milestone(setup: &TestSetup) { + setup.escrow_client.fund(); + setup.escrow_client.submit_milestone(&1); + setup.escrow_client.approve(&1); + setup.escrow_client.freelancer_confirm(&1); +} + #[test] fn test_happy_path() { let setup = setup_test(); @@ -346,7 +377,7 @@ fn test_release_without_approval_fails() { } #[test] -#[should_panic(expected = "HostError: Error(Contract, #10)")] +#[should_panic(expected = "HostError: Error(Contract, #6)")] fn test_double_client_approval_fails() { let setup = setup_test(); let escrow = setup.escrow_client; @@ -367,7 +398,7 @@ fn test_double_client_approval_fails() { escrow.fund(); escrow.submit_milestone(&1); escrow.approve(&1); - // Double approval should fail with AlreadyApproved (error code 10) + // Once approved, the milestone is no longer in Submitted state. escrow.approve(&1); } @@ -430,3 +461,154 @@ fn test_dispute_clears_approvals() { // Verify approvals are cleared assert_eq!(escrow.has_client_approval(&1), false); } + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #7)")] +fn test_unauthorized_refund_fails() { + let setup = setup_test(); + initialize_single_milestone(&setup, 150); + setup.escrow_client.fund(); + + setup.escrow_client.refund(&1, &setup.client); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #7)")] +fn test_unauthorized_dispute_fails() { + let setup = setup_test(); + initialize_single_milestone(&setup, 150); + setup.escrow_client.fund(); + + let stranger = Address::generate(&setup.env); + setup.escrow_client.dispute(&1, &stranger); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #5)")] +fn test_submit_invalid_milestone_fails() { + let setup = setup_test(); + initialize_single_milestone(&setup, 150); + setup.escrow_client.fund(); + + setup.escrow_client.submit_milestone(&99); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #5)")] +fn test_release_invalid_milestone_fails() { + let setup = setup_test(); + initialize_single_milestone(&setup, 150); + fully_approve_single_milestone(&setup); + + setup.escrow_client.release(&99, &setup.client); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #6)")] +fn test_double_release_replay_fails() { + let setup = setup_test(); + initialize_single_milestone(&setup, 150); + fully_approve_single_milestone(&setup); + setup.escrow_client.release(&1, &setup.client); + + setup.escrow_client.release(&1, &setup.client); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #6)")] +fn test_double_refund_replay_fails() { + let setup = setup_test(); + initialize_single_milestone(&setup, 150); + setup.escrow_client.fund(); + setup.escrow_client.refund(&1, &setup.freelancer); + + setup.escrow_client.refund(&1, &setup.freelancer); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #6)")] +fn test_dispute_after_release_fails() { + let setup = setup_test(); + initialize_single_milestone(&setup, 150); + fully_approve_single_milestone(&setup); + setup.escrow_client.release(&1, &setup.client); + + setup.escrow_client.dispute(&1, &setup.client); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #6)")] +fn test_refund_after_release_fails() { + let setup = setup_test(); + initialize_single_milestone(&setup, 150); + fully_approve_single_milestone(&setup); + setup.escrow_client.release(&1, &setup.client); + + setup.escrow_client.refund(&1, &setup.freelancer); +} + +#[test] +fn test_successful_security_events_are_emitted() { + let setup = setup_test(); + let env = setup.env.clone(); + + initialize_single_milestone(&setup, 150); + assert_eq!( + env.events() + .all() + .filter_by_contract(&setup.escrow_client.address) + .events() + .len(), + 1 + ); + + setup.escrow_client.fund(); + assert_eq!( + env.events() + .all() + .filter_by_contract(&setup.escrow_client.address) + .events() + .len(), + 1 + ); + + setup.escrow_client.submit_milestone(&1); + assert_eq!( + env.events() + .all() + .filter_by_contract(&setup.escrow_client.address) + .events() + .len(), + 1 + ); + + setup.escrow_client.approve(&1); + assert_eq!( + env.events() + .all() + .filter_by_contract(&setup.escrow_client.address) + .events() + .len(), + 1 + ); + + setup.escrow_client.freelancer_confirm(&1); + assert_eq!( + env.events() + .all() + .filter_by_contract(&setup.escrow_client.address) + .events() + .len(), + 1 + ); + + setup.escrow_client.release(&1, &setup.client); + assert_eq!( + env.events() + .all() + .filter_by_contract(&setup.escrow_client.address) + .events() + .len(), + 1 + ); +}