diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index a6c082c..e3768cc 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "access_control", + "cntr", "manage_hub", "membership_token", "common_types", diff --git a/contracts/cntr/Cargo.toml b/contracts/cntr/Cargo.toml new file mode 100644 index 0000000..40fdfbc --- /dev/null +++ b/contracts/cntr/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "cntr" +version = "0.1.0" +edition = "2021" diff --git a/contracts/cntr/src/credit_topup.rs b/contracts/cntr/src/credit_topup.rs new file mode 100644 index 0000000..f4790bf --- /dev/null +++ b/contracts/cntr/src/credit_topup.rs @@ -0,0 +1,58 @@ +/// Validates a credit top-up operation. +/// +/// Returns `Ok(new_balance)` when `topup_amount > 0` and +/// `current_balance + topup_amount <= max_balance`. +pub fn validate_topup( + current_balance: i128, + topup_amount: i128, + max_balance: i128, +) -> Result { + if topup_amount <= 0 { + return Err("Top-up amount must be positive"); + } + let new_balance = current_balance.checked_add(topup_amount).ok_or("Balance would exceed maximum allowed")?; + if new_balance > max_balance { + return Err("Balance would exceed maximum allowed"); + } + Ok(new_balance) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_topup() { + assert_eq!(validate_topup(100, 50, 1000), Ok(150)); + } + + #[test] + fn test_zero_topup_rejected() { + assert_eq!(validate_topup(100, 0, 1000), Err("Top-up amount must be positive")); + } + + #[test] + fn test_negative_topup_rejected() { + assert_eq!(validate_topup(100, -10, 1000), Err("Top-up amount must be positive")); + } + + #[test] + fn test_exact_maximum_boundary() { + assert_eq!(validate_topup(900, 100, 1000), Ok(1000)); + } + + #[test] + fn test_one_above_maximum_rejected() { + assert_eq!(validate_topup(900, 101, 1000), Err("Balance would exceed maximum allowed")); + } + + #[test] + fn test_overflow_guard() { + assert_eq!(validate_topup(i128::MAX, 1, i128::MAX), Err("Balance would exceed maximum allowed")); + } + + #[test] + fn test_zero_current_balance() { + assert_eq!(validate_topup(0, 500, 1000), Ok(500)); + } +} diff --git a/contracts/cntr/src/lib.rs b/contracts/cntr/src/lib.rs new file mode 100644 index 0000000..f0def92 --- /dev/null +++ b/contracts/cntr/src/lib.rs @@ -0,0 +1,4 @@ +pub mod credit_topup; +pub mod multi_booking; +pub mod payment_validator; +pub mod role_checker; diff --git a/contracts/cntr/src/multi_booking.rs b/contracts/cntr/src/multi_booking.rs new file mode 100644 index 0000000..83e970b --- /dev/null +++ b/contracts/cntr/src/multi_booking.rs @@ -0,0 +1,61 @@ +/// Returns the maximum number of simultaneous active bookings allowed for a tier. +/// Unknown tier strings default to the Bronze limit (2). +pub fn get_booking_limit(tier: &str) -> u32 { + match tier { + "Bronze" => 2, + "Silver" => 5, + "Gold" => 10, + "Platinum" => u32::MAX, + _ => 2, + } +} + +/// Returns true if the member can make another booking given their current active bookings. +pub fn can_make_booking(tier: &str, current_active_bookings: u32) -> bool { + current_active_bookings < get_booking_limit(tier) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bronze_limit() { + assert_eq!(get_booking_limit("Bronze"), 2); + } + + #[test] + fn test_silver_limit() { + assert_eq!(get_booking_limit("Silver"), 5); + } + + #[test] + fn test_gold_limit() { + assert_eq!(get_booking_limit("Gold"), 10); + } + + #[test] + fn test_platinum_limit() { + assert_eq!(get_booking_limit("Platinum"), u32::MAX); + } + + #[test] + fn test_unknown_tier_defaults_to_bronze() { + assert_eq!(get_booking_limit("Diamond"), 2); + } + + #[test] + fn test_can_make_booking_below_limit() { + assert!(can_make_booking("Bronze", 1)); + } + + #[test] + fn test_cannot_make_booking_at_limit() { + assert!(!can_make_booking("Bronze", 2)); + } + + #[test] + fn test_cannot_make_booking_above_limit() { + assert!(!can_make_booking("Silver", 6)); + } +} diff --git a/contracts/cntr/src/payment_validator.rs b/contracts/cntr/src/payment_validator.rs new file mode 100644 index 0000000..0ca7a2e --- /dev/null +++ b/contracts/cntr/src/payment_validator.rs @@ -0,0 +1,58 @@ +/// Validates that a received payment amount is within tolerance of the expected amount. +/// +/// Returns `Ok(())` when `received_stroops >= expected_stroops - tolerance_stroops`. +pub fn validate_payment_amount( + received_stroops: i128, + expected_stroops: i128, + tolerance_stroops: i128, +) -> Result<(), &'static str> { + if expected_stroops <= 0 { + return Err("Expected amount must be positive"); + } + let threshold = expected_stroops.saturating_sub(tolerance_stroops); + if received_stroops >= threshold { + Ok(()) + } else { + Err("Payment amount insufficient") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_exact_amount() { + assert_eq!(validate_payment_amount(1000, 1000, 0), Ok(())); + } + + #[test] + fn test_within_tolerance() { + assert_eq!(validate_payment_amount(990, 1000, 10), Ok(())); + } + + #[test] + fn test_one_stroop_below_tolerance() { + assert_eq!(validate_payment_amount(989, 1000, 10), Err("Payment amount insufficient")); + } + + #[test] + fn test_overpayment_accepted() { + assert_eq!(validate_payment_amount(1500, 1000, 0), Ok(())); + } + + #[test] + fn test_zero_expected_rejected() { + assert_eq!(validate_payment_amount(0, 0, 0), Err("Expected amount must be positive")); + } + + #[test] + fn test_negative_expected_rejected() { + assert_eq!(validate_payment_amount(100, -1, 0), Err("Expected amount must be positive")); + } + + #[test] + fn test_underpayment_no_tolerance() { + assert_eq!(validate_payment_amount(999, 1000, 0), Err("Payment amount insufficient")); + } +} diff --git a/contracts/cntr/src/role_checker.rs b/contracts/cntr/src/role_checker.rs new file mode 100644 index 0000000..d368bd2 --- /dev/null +++ b/contracts/cntr/src/role_checker.rs @@ -0,0 +1,41 @@ +use std::collections::HashMap; + +/// Simple in-memory role registry for access control. +#[derive(Default)] +pub struct RoleRegistry { + roles: HashMap>, +} + +impl RoleRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Grants a role to an address. Idempotent — duplicate grants are ignored. + pub fn grant_role(&mut self, address: &str, role: &str) { + let roles = self.roles.entry(address.to_string()).or_default(); + if !roles.contains(&role.to_string()) { + roles.push(role.to_string()); + } + } + + /// Revokes a role from an address. No-op if the role is not held. + pub fn revoke_role(&mut self, address: &str, role: &str) { + if let Some(roles) = self.roles.get_mut(address) { + roles.retain(|r| r != role); + } + } + + /// Returns true if the address holds the given role. + pub fn has_role(&self, address: &str, role: &str) -> bool { + self.roles + .get(address) + .map(|roles| roles.contains(&role.to_string())) + .unwrap_or(false) + } + + /// Returns all roles held by an address. + pub fn get_roles(&self, address: &str) -> Vec { + self.roles.get(address).cloned().unwrap_or_default() + } +} diff --git a/contracts/cntr/tests/access_control_tests.rs b/contracts/cntr/tests/access_control_tests.rs new file mode 100644 index 0000000..16bee39 --- /dev/null +++ b/contracts/cntr/tests/access_control_tests.rs @@ -0,0 +1,98 @@ +use cntr::role_checker::RoleRegistry; + +#[test] +fn test_grant_role_to_new_address() { + let mut reg = RoleRegistry::new(); + reg.grant_role("alice", "Admin"); + assert!(reg.has_role("alice", "Admin")); +} + +#[test] +fn test_grant_same_role_twice_is_idempotent() { + let mut reg = RoleRegistry::new(); + reg.grant_role("alice", "Admin"); + reg.grant_role("alice", "Admin"); + let roles = reg.get_roles("alice"); + assert_eq!(roles.iter().filter(|r| *r == "Admin").count(), 1); +} + +#[test] +fn test_grant_two_different_roles_to_same_address() { + let mut reg = RoleRegistry::new(); + reg.grant_role("alice", "Admin"); + reg.grant_role("alice", "Member"); + assert!(reg.has_role("alice", "Admin")); + assert!(reg.has_role("alice", "Member")); +} + +#[test] +fn test_revoke_existing_role() { + let mut reg = RoleRegistry::new(); + reg.grant_role("alice", "Admin"); + reg.revoke_role("alice", "Admin"); + assert!(!reg.has_role("alice", "Admin")); +} + +#[test] +fn test_revoke_nonexistent_role_does_not_panic() { + let mut reg = RoleRegistry::new(); + reg.revoke_role("alice", "Admin"); // should not panic +} + +#[test] +fn test_has_role_for_unknown_address_returns_false() { + let reg = RoleRegistry::new(); + assert!(!reg.has_role("unknown", "Admin")); +} + +#[test] +fn test_get_all_roles_for_multi_role_holder() { + let mut reg = RoleRegistry::new(); + reg.grant_role("bob", "Admin"); + reg.grant_role("bob", "Member"); + reg.grant_role("bob", "Auditor"); + let roles = reg.get_roles("bob"); + assert_eq!(roles.len(), 3); + assert!(roles.contains(&"Admin".to_string())); + assert!(roles.contains(&"Member".to_string())); + assert!(roles.contains(&"Auditor".to_string())); +} + +#[test] +fn test_has_role_with_empty_registry() { + let reg = RoleRegistry::new(); + assert!(!reg.has_role("alice", "Member")); +} + +#[test] +fn test_revoke_one_role_leaves_others_intact() { + let mut reg = RoleRegistry::new(); + reg.grant_role("carol", "Admin"); + reg.grant_role("carol", "Member"); + reg.revoke_role("carol", "Admin"); + assert!(!reg.has_role("carol", "Admin")); + assert!(reg.has_role("carol", "Member")); +} + +#[test] +fn test_multiple_addresses_independent() { + let mut reg = RoleRegistry::new(); + reg.grant_role("alice", "Admin"); + reg.grant_role("bob", "Member"); + assert!(reg.has_role("alice", "Admin")); + assert!(!reg.has_role("alice", "Member")); + assert!(reg.has_role("bob", "Member")); + assert!(!reg.has_role("bob", "Admin")); +} + +#[test] +fn test_get_roles_returns_empty_for_unknown_address() { + let reg = RoleRegistry::new(); + assert!(reg.get_roles("nobody").is_empty()); +} + +#[test] +fn test_revoke_from_unknown_address_does_not_panic() { + let mut reg = RoleRegistry::new(); + reg.revoke_role("ghost", "Admin"); // should not panic +}