diff --git a/near-plugins/src/ownable.rs b/near-plugins/src/ownable.rs index cc4899c..197ed3f 100644 --- a/near-plugins/src/ownable.rs +++ b/near-plugins/src/ownable.rs @@ -57,198 +57,3 @@ impl AsEvent for OwnershipTransferred { } } } - -#[cfg(not(target_arch = "wasm32"))] -#[cfg(test)] -mod tests { - use crate as near_plugins; - use crate::test_utils::get_context; - use crate::{only, Ownable}; - use near_sdk::{near_bindgen, testing_env, VMContext}; - use std::convert::TryInto; - - #[near_bindgen] - #[derive(Ownable)] - struct Counter { - counter: u64, - } - - #[near_bindgen] - impl Counter { - /// Specify the owner of the contract in the constructor - #[init] - fn new() -> Self { - let mut contract = Self { counter: 0 }; - contract.owner_set(Some(near_sdk::env::predecessor_account_id())); - contract - } - - /// Only owner account, or the contract itself can call this method. - #[only(self, owner)] - fn protected(&mut self) { - self.counter += 1; - } - - /// *Only* owner account can call this method. - #[only(owner)] - fn protected_owner(&mut self) { - self.counter += 1; - } - - /// *Only* self account can call this method. This can be used even if the contract is not Ownable. - #[only(self)] - fn protected_self(&mut self) { - self.counter += 1; - } - - /// Everyone can call this method - fn unprotected(&mut self) { - self.counter += 1; - } - } - - /// Setup basic account. Owner of the account is `carol.test` - fn setup_basic() -> (Counter, VMContext) { - let ctx = get_context(); - testing_env!(ctx.clone()); - let mut counter = Counter::new(); - counter.owner_set(Some("carol.test".to_string().try_into().unwrap())); - (counter, ctx) - } - - #[test] - fn build_contract() { - let _ = setup_basic(); - } - - #[test] - fn test_is_owner() { - let (counter, mut ctx) = setup_basic(); - assert!(!counter.owner_is()); - ctx.predecessor_account_id = "carol.test".to_string().try_into().unwrap(); - testing_env!(ctx); - assert!(counter.owner_is()); - } - - #[test] - fn test_set_owner_ok() { - let (mut counter, mut ctx) = setup_basic(); - ctx.predecessor_account_id = "carol.test".to_string().try_into().unwrap(); - testing_env!(ctx); - counter.owner_set(Some("eve.test".to_string().try_into().unwrap())); - } - - #[test] - #[should_panic(expected = r#"Ownable: Only owner can update current owner"#)] - fn test_set_owner_fail() { - let (mut counter, _) = setup_basic(); - counter.owner_set(Some("eve.test".to_string().try_into().unwrap())); - } - - #[test] - fn test_remove_owner() { - let (mut counter, mut ctx) = setup_basic(); - ctx.predecessor_account_id = "carol.test".to_string().try_into().unwrap(); - testing_env!(ctx); - counter.owner_set(None); - assert_eq!(counter.owner_get(), None); - } - - #[test] - fn counter_unprotected() { - let (mut counter, _) = setup_basic(); - assert_eq!(counter.counter, 0); - counter.unprotected(); - assert_eq!(counter.counter, 1); - } - - #[test] - fn protected_self_ok() { - let (mut counter, _) = setup_basic(); - - counter.protected_self(); - assert_eq!(counter.counter, 1); - } - - #[test] - #[should_panic(expected = r#"Method is private"#)] - fn protected_self_fail() { - let (mut counter, mut ctx) = setup_basic(); - - ctx.predecessor_account_id = "mallory.test".to_string().try_into().unwrap(); - testing_env!(ctx); - - counter.protected_self(); - assert_eq!(counter.counter, 0); - } - - #[test] - #[should_panic(expected = r#"Method is private"#)] - fn protected_self_owner_fail() { - let (mut counter, mut ctx) = setup_basic(); - - ctx.predecessor_account_id = "carol.test".to_string().try_into().unwrap(); - testing_env!(ctx); - - counter.protected_self(); - assert_eq!(counter.counter, 0); - } - - #[test] - fn protected_owner_ok() { - let (mut counter, mut ctx) = setup_basic(); - - ctx.predecessor_account_id = "carol.test".to_string().try_into().unwrap(); - testing_env!(ctx); - - counter.protected_owner(); - assert_eq!(counter.counter, 1); - } - - #[test] - #[should_panic(expected = r#"Ownable: Method must be called from owner"#)] - fn protected_owner_self_fail() { - let (mut counter, _) = setup_basic(); - - counter.protected_owner(); - assert_eq!(counter.counter, 0); - } - - #[test] - #[should_panic(expected = r#"Ownable: Method must be called from owner"#)] - fn protected_owner_fail() { - let (mut counter, mut ctx) = setup_basic(); - - ctx.predecessor_account_id = "mallory.test".to_string().try_into().unwrap(); - testing_env!(ctx); - - counter.protected_owner(); - assert_eq!(counter.counter, 0); - } - - #[test] - fn protected_ok() { - let (mut counter, mut ctx) = setup_basic(); - - counter.protected(); - assert_eq!(counter.counter, 1); - - ctx.predecessor_account_id = "carol.test".to_string().try_into().unwrap(); - testing_env!(ctx); - - counter.protected(); - assert_eq!(counter.counter, 2); - } - - #[test] - #[should_panic(expected = r#"Method is private"#)] - fn protected_fail() { - let (mut counter, mut ctx) = setup_basic(); - - ctx.predecessor_account_id = "mallory.test".to_string().try_into().unwrap(); - testing_env!(ctx); - - counter.protected(); - assert_eq!(counter.counter, 0); - } -} diff --git a/near-plugins/tests/common/mod.rs b/near-plugins/tests/common/mod.rs index 8e99cb2..1a3cb9f 100644 --- a/near-plugins/tests/common/mod.rs +++ b/near-plugins/tests/common/mod.rs @@ -1,4 +1,5 @@ pub mod access_controllable_contract; +pub mod ownable_contract; pub mod pausable_contract; pub mod repo; pub mod utils; diff --git a/near-plugins/tests/common/ownable_contract.rs b/near-plugins/tests/common/ownable_contract.rs new file mode 100644 index 0000000..1d13d32 --- /dev/null +++ b/near-plugins/tests/common/ownable_contract.rs @@ -0,0 +1,46 @@ +use near_sdk::serde_json::json; +use workspaces::result::ExecutionFinalResult; +use workspaces::{Account, AccountId, Contract}; + +/// Wrapper for a contract that is `#[ownable]`. It allows implementing helpers for calling contract +/// methods. +pub struct OwnableContract { + contract: Contract, +} + +impl OwnableContract { + pub fn new(contract: Contract) -> Self { + Self { contract } + } + + pub fn contract(&self) -> &Contract { + &self.contract + } + + pub async fn owner_get(&self, caller: &Account) -> anyhow::Result> { + let res = caller.call(self.contract.id(), "owner_get").view().await?; + Ok(res.json::>()?) + } + + pub async fn owner_set( + &self, + caller: &Account, + owner: Option, + ) -> workspaces::Result { + caller + .call(self.contract.id(), "owner_set") + .args_json(json!({ "owner": owner })) + .max_gas() + .transact() + .await + } + + pub async fn owner_is(&self, caller: &Account) -> anyhow::Result { + let res = caller + .call(self.contract.id(), "owner_is") + .max_gas() + .transact() + .await?; + Ok(res.json::()?) + } +} diff --git a/near-plugins/tests/common/utils.rs b/near-plugins/tests/common/utils.rs index 541761e..45badc6 100644 --- a/near-plugins/tests/common/utils.rs +++ b/near-plugins/tests/common/utils.rs @@ -84,6 +84,51 @@ pub fn assert_method_is_paused(res: ExecutionFinalResult) { ); } +pub fn assert_owner_update_failure(res: ExecutionFinalResult) { + let err = res + .into_result() + .err() + .expect("Transaction should have failed"); + let err = format!("{}", err); + let must_contain = "Ownable: Only owner can update current owner"; + assert!( + err.contains(&must_contain), + "Expected failure due to caller not being owner, instead it failed with: {}", + err + ); +} + +/// Assert failure due to calling a method protected by `#[only]` without required permissions. +pub fn assert_ownable_permission_failure(res: ExecutionFinalResult) { + let err = res + .into_result() + .err() + .expect("Transaction should have failed"); + let err = format!("{}", err); + let must_contain = "Method is private"; + assert!( + err.contains(&must_contain), + "Expected failure due to insufficient permissions, instead it failed with: {}", + err + ); +} + +/// Assert failure due to calling a method protected by `#[only(owner)]` from an account other than the +/// owner. +pub fn assert_only_owner_permission_failure(res: ExecutionFinalResult) { + let err = res + .into_result() + .err() + .expect("Transaction should have failed"); + let err = format!("{}", err); + let must_contain = "Ownable: Method must be called from owner"; + assert!( + err.contains(&must_contain), + "Expected failure due to caller not being owner, instead it failed with: {}", + err + ); +} + /// Asserts the execution of `res` failed and the error contains `must_contain`. pub fn assert_failure_with(res: ExecutionFinalResult, must_contain: &str) { let err = res diff --git a/near-plugins/tests/contracts/ownable/Cargo.toml b/near-plugins/tests/contracts/ownable/Cargo.toml new file mode 100644 index 0000000..4937efa --- /dev/null +++ b/near-plugins/tests/contracts/ownable/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "ownable" +version = "0.0.0" +edition = "2018" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +near-plugins = { path = "../../../../near-plugins" } +near-sdk = "4.1.0" + +[profile.release] +codegen-units = 1 +opt-level = "z" +lto = true +debug = false +panic = "abort" +overflow-checks = true + +[workspace] diff --git a/near-plugins/tests/contracts/ownable/Makefile b/near-plugins/tests/contracts/ownable/Makefile new file mode 100644 index 0000000..aa2cf20 --- /dev/null +++ b/near-plugins/tests/contracts/ownable/Makefile @@ -0,0 +1,8 @@ +build: + cargo build --target wasm32-unknown-unknown --release + +# Helpful for debugging. Requires `cargo-expand`. +expand: + cargo expand > expanded.rs + +.PHONY: build expand diff --git a/near-plugins/tests/contracts/ownable/rust-toolchain b/near-plugins/tests/contracts/ownable/rust-toolchain new file mode 100644 index 0000000..973115b --- /dev/null +++ b/near-plugins/tests/contracts/ownable/rust-toolchain @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.64.0" +components = ["clippy", "rustfmt"] diff --git a/near-plugins/tests/contracts/ownable/src/lib.rs b/near-plugins/tests/contracts/ownable/src/lib.rs new file mode 100644 index 0000000..652bfa9 --- /dev/null +++ b/near-plugins/tests/contracts/ownable/src/lib.rs @@ -0,0 +1,58 @@ +use near_plugins::{only, Ownable}; +use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; +use near_sdk::{near_bindgen, AccountId, PanicOnDefault}; + +#[near_bindgen] +#[derive(Ownable, PanicOnDefault, BorshDeserialize, BorshSerialize)] +pub struct Counter { + counter: u64, +} + +#[near_bindgen] +impl Counter { + /// Optionally set the owner in the constructor. + #[init] + pub fn new(owner: Option) -> Self { + let mut contract = Self { counter: 0 }; + if owner.is_some() { + contract.owner_set(owner); + } + contract + } + + /// Returns the value of the counter. + pub fn get_counter(&self) -> u64 { + self.counter + } + + /// Anyone may call this method successfully. + pub fn increase(&mut self) -> u64 { + self.counter += 1; + self.counter + } + + /// _Only_ the owner or the contract itself may call this method successfully. It panics if + /// anyone else calls it. + #[only(self, owner)] + pub fn increase_2(&mut self) -> u64 { + self.counter += 2; + self.counter + } + + /// _Only_ the owner may call this method successfully. It panics if anyone else calls it. + #[only(owner)] + pub fn increase_3(&mut self) -> u64 { + self.counter += 3; + self.counter + } + + /// _Only_ the contract itself may call this method successfully. It panics if anyone else calls + /// it. + /// + /// It is possible to use `#[only(self)]` even if the contract does not derive `Ownable`. + #[only(self)] + pub fn increase_4(&mut self) -> u64 { + self.counter += 4; + self.counter + } +} diff --git a/near-plugins/tests/ownable.rs b/near-plugins/tests/ownable.rs new file mode 100644 index 0000000..c8ab4e8 --- /dev/null +++ b/near-plugins/tests/ownable.rs @@ -0,0 +1,315 @@ +// Using `pub` to avoid invalid `dead_code` warnings, see +// https://users.rust-lang.org/t/invalid-dead-code-warning-for-submodule-in-integration-test/80259 +pub mod common; + +use anyhow::Ok; +use common::ownable_contract::OwnableContract; +use common::utils::{ + assert_only_owner_permission_failure, assert_ownable_permission_failure, + assert_owner_update_failure, assert_success_with, +}; +use near_sdk::serde_json::json; +use std::path::Path; +use workspaces::network::Sandbox; +use workspaces::result::ExecutionFinalResult; +use workspaces::{Account, AccountId, Contract, Worker}; + +const PROJECT_PATH: &str = "./tests/contracts/ownable"; + +/// Allows spinning up a setup for testing the contract in [`PROJECT_PATH`] and bundles related +/// resources. +struct Setup { + /// The worker interacting with the current sandbox. + worker: Worker, + /// Instance of the deployed contract. + contract: Contract, + /// Wrapper around the deployed contract that facilitates interacting with methods provided by + /// the `Ownable` plugin. + ownable_contract: OwnableContract, + /// A newly created account without any `Ownable` permissions. + unauth_account: Account, +} + +impl Setup { + /// Deploys and initializes the contract in [`PROJECT_PATH`] and returns a new `Setup`. + /// + /// The `owner` parameter is passed on to the contract's constructor, allowing to optionally set + /// the owner during initialization. + async fn new(worker: Worker, owner: Option) -> anyhow::Result { + // Compile and deploy the contract. + let wasm = common::repo::compile_project(&Path::new(PROJECT_PATH), "ownable").await?; + let contract = worker.dev_deploy(&wasm).await?; + let ownable_contract = OwnableContract::new(contract.clone()); + + // Call the contract's constructor. + contract + .call("new") + .args_json(json!({ + "owner": owner, + })) + .max_gas() + .transact() + .await? + .into_result()?; + + let unauth_account = worker.dev_create_account().await?; + Ok(Self { + worker, + contract, + ownable_contract, + unauth_account, + }) + } + + /// Calls the contract's `get_counter` method from an account without any `Ownable` permissions. + async fn get_counter(&self) -> anyhow::Result { + let res = self + .unauth_account + .call(self.contract.id(), "get_counter") + .view() + .await?; + Ok(res.json::()?) + } + + /// Calls one of the methods that increases the counter with signature: + /// + /// ```ignore + /// method_name(&mut self) -> u64 + /// ``` + async fn call_counter_increaser( + &self, + caller: &Account, + method_name: &str, + ) -> workspaces::Result { + caller + .call(self.contract.id(), method_name) + .max_gas() + .transact() + .await + } + + /// Asserts the contract's `owner_get` method returns the expected value. + async fn assert_owner_is(&self, expected: Option<&AccountId>) { + // Call from an account without any permissions since `owner_get` is unrestricted. + let owner = self + .ownable_contract + .owner_get(&self.unauth_account) + .await + .unwrap(); + assert_eq!(owner.as_ref(), expected); + } +} + +/// Smoke test of contract setup and basic functionality. +#[tokio::test] +async fn test_setup() -> anyhow::Result<()> { + let worker = workspaces::sandbox().await?; + let setup = Setup::new(worker, None).await?; + + assert_eq!(setup.get_counter().await?, 0); + let res = setup + .call_counter_increaser(&setup.unauth_account, "increase") + .await?; + assert_success_with(res, 1); + assert_eq!(setup.get_counter().await?, 1); + + Ok(()) +} + +#[tokio::test] +async fn test_owner_is() -> anyhow::Result<()> { + let worker = workspaces::sandbox().await?; + let owner = worker.dev_create_account().await?; + let setup = Setup::new(worker, Some(owner.id().clone())).await?; + + // Returns false for an account that isn't owner. + assert_eq!( + setup + .ownable_contract + .owner_is(&setup.unauth_account) + .await?, + false + ); + + // Returns true for the owner. + assert_eq!(setup.ownable_contract.owner_is(&owner).await?, true); + + Ok(()) +} + +#[tokio::test] +async fn test_set_owner_ok() -> anyhow::Result<()> { + let worker = workspaces::sandbox().await?; + let setup = Setup::new(worker, None).await?; + + setup.assert_owner_is(None).await; + + let owner_id = setup.unauth_account.id(); + setup + .ownable_contract + .owner_set(&setup.contract.as_account(), Some(owner_id.clone())) + .await? + .into_result()?; + setup.assert_owner_is(Some(owner_id)).await; + + Ok(()) +} + +#[tokio::test] +async fn test_set_owner_fail() -> anyhow::Result<()> { + let worker = workspaces::sandbox().await?; + let owner = worker.dev_create_account().await?; + let setup = Setup::new(worker, Some(owner.id().clone())).await?; + + setup.assert_owner_is(Some(owner.id())).await; + let res = setup + .ownable_contract + .owner_set( + &setup.unauth_account, + Some(setup.unauth_account.id().clone()), + ) + .await?; + assert_owner_update_failure(res); + + Ok(()) +} + +#[tokio::test] +async fn test_remove_owner() -> anyhow::Result<()> { + let worker = workspaces::sandbox().await?; + let owner = worker.dev_create_account().await?; + let setup = Setup::new(worker, Some(owner.id().clone())).await?; + + setup.assert_owner_is(Some(owner.id())).await; + + setup + .ownable_contract + .owner_set(&owner, None) + .await? + .into_result()?; + setup.assert_owner_is(None).await; + + Ok(()) +} + +/// Contract itself may successfully call a method protected by `#[only(self)]`. +#[tokio::test] +async fn test_only_self_ok() -> anyhow::Result<()> { + let worker = workspaces::sandbox().await?; + let setup = Setup::new(worker, None).await?; + + assert_eq!(setup.get_counter().await?, 0); + let res = setup + .call_counter_increaser(&setup.contract.as_account(), "increase_4") + .await?; + assert_success_with(res, 4); + assert_eq!(setup.get_counter().await?, 4); + + Ok(()) +} + +/// A method protected by `#[only(self)]` fails if called from another account. +#[tokio::test] +async fn test_only_self_fail_unauth() -> anyhow::Result<()> { + let worker = workspaces::sandbox().await?; + let setup = Setup::new(worker, None).await?; + + let res = setup + .call_counter_increaser(&setup.unauth_account, "increase_4") + .await?; + assert_ownable_permission_failure(res); + + Ok(()) +} + +/// A method protected by `#[only(self)]` fails if called by the owner. +#[tokio::test] +async fn test_only_self_fail_owner() -> anyhow::Result<()> { + let worker = workspaces::sandbox().await?; + let owner = worker.dev_create_account().await?; + let setup = Setup::new(worker, Some(owner.id().clone())).await?; + + let res = setup.call_counter_increaser(&owner, "increase_4").await?; + assert_ownable_permission_failure(res); + + Ok(()) +} + +/// Calling a method protected by `#[only(owner)]` from the owner succeeds. +#[tokio::test] +async fn test_only_owner_ok() -> anyhow::Result<()> { + let worker = workspaces::sandbox().await?; + let owner = worker.dev_create_account().await?; + let setup = Setup::new(worker, Some(owner.id().clone())).await?; + + assert_eq!(setup.get_counter().await?, 0); + let res = setup.call_counter_increaser(&owner, "increase_3").await?; + assert_success_with(res, 3); + assert_eq!(setup.get_counter().await?, 3); + + Ok(()) +} + +/// A method protected by `#[only(owner)]` fails if called by the contract itself. +#[tokio::test] +async fn test_only_owner_fail_self() -> anyhow::Result<()> { + let worker = workspaces::sandbox().await?; + let setup = Setup::new(worker, None).await?; + + let res = setup + .call_counter_increaser(setup.contract.as_account(), "increase_3") + .await?; + assert_only_owner_permission_failure(res); + + Ok(()) +} + +/// A method protected by `#[only(owner)]` fails if called by another account. +#[tokio::test] +async fn test_only_owner_fail() -> anyhow::Result<()> { + let worker = workspaces::sandbox().await?; + let setup = Setup::new(worker, None).await?; + + let res = setup + .call_counter_increaser(&setup.unauth_account, "increase_3") + .await?; + assert_only_owner_permission_failure(res); + + Ok(()) +} + +/// Calling a method protected by `#[only(self, owner)]` succeeds if called by the contract itself +/// or by the owner. +#[tokio::test] +async fn test_only_self_owner_ok() -> anyhow::Result<()> { + let worker = workspaces::sandbox().await?; + let owner = worker.dev_create_account().await?; + let setup = Setup::new(worker, Some(owner.id().clone())).await?; + + assert_eq!(setup.get_counter().await?, 0); + let res = setup + .call_counter_increaser(setup.contract.as_account(), "increase_2") + .await?; + assert_success_with(res, 2); + assert_eq!(setup.get_counter().await?, 2); + + let res = setup.call_counter_increaser(&owner, "increase_2").await?; + assert_success_with(res, 4); + assert_eq!(setup.get_counter().await?, 4); + + Ok(()) +} + +/// Calling a method protected by `#[only(self, owner)]` fails if called by another account. +#[tokio::test] +async fn test_only_self_owner_fail() -> anyhow::Result<()> { + let worker = workspaces::sandbox().await?; + let setup = Setup::new(worker, None).await?; + + let res = setup + .call_counter_increaser(&setup.unauth_account, "increase_2") + .await?; + assert_ownable_permission_failure(res); + + Ok(()) +}