From bec6fefca46d984f44349736647f859a9ba0baa4 Mon Sep 17 00:00:00 2001 From: MatanOga01 Date: Sun, 18 Oct 2026 20:31:25 +0000 Subject: [PATCH 1/2] fix(access_control): enforce blacklist policy across governance (CT-28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A blacklisted administrator could still create, approve, reject and execute governance proposals because require_admin only checked membership of the admin list. Blacklist checks now run at the single authorisation choke point that every privileged call goes through, and again at execution, where a blacklisted signer's stored approval stops counting towards the quorum and a blacklisted proposer cannot have their proposal run. Self-blacklisting is rejected so one admin cannot lock governance out of its only account, and the new error semantics are covered by role-style tests for creation, approval, rejection, execution and direct admin actions. The test module's merge-damaged tail (unclosed delimiters and spliced test bodies from merges 97b99c8, 3510a28 and 8649a50) is rebuilt so the CT-23 to CT-30 tests compile and run again. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- .../access_control/src/access_control.rs | 81 ++- .../src/access_control_tests.rs | 617 +++++++++++++----- contracts/access_control/src/errors.rs | 6 +- 3 files changed, 507 insertions(+), 197 deletions(-) diff --git a/contracts/access_control/src/access_control.rs b/contracts/access_control/src/access_control.rs index b5c477ce..4d7158c5 100644 --- a/contracts/access_control/src/access_control.rs +++ b/contracts/access_control/src/access_control.rs @@ -273,17 +273,29 @@ impl AccessControlModule { matches!(user_role, UserRole::Admin) } + /// Require that `caller` is an administrator (single-admin or multisig). + /// + /// CT-28: being on the admin list is not sufficient — a blacklisted + /// administrator may not act. The blacklist is checked here, at the one + /// choke point every privileged mutation goes through, so proposal + /// creation, voting and the direct admin operations cannot drift apart. pub fn require_admin(env: &Env, caller: &Address) -> AccessControlResult<()> { - if let Some(multisig_config) = Self::get_multisig_config(env) { - if multisig_config.admins.contains(caller) { - caller.require_auth(); - return Ok(()); - } - } else if Self::is_admin(env, caller.clone()) { - caller.require_auth(); - return Ok(()); + let is_configured_admin = if let Some(multisig_config) = Self::get_multisig_config(env) { + multisig_config.admins.contains(caller) + } else { + Self::is_admin(env, caller.clone()) + }; + + if !is_configured_admin { + return Err(AccessControlError::AdminRequired); } - Err(AccessControlError::AdminRequired) + + if Self::is_blacklisted(env, caller) { + return Err(AccessControlError::UserBlacklisted); + } + + caller.require_auth(); + Ok(()) } /// Check if the system is initialized @@ -603,6 +615,13 @@ impl AccessControlModule { pub fn blacklist_user(env: &Env, caller: Address, user: Address) -> AccessControlResult<()> { Self::require_admin(env, &caller)?; + // CT-28: an administrator cannot blacklist themselves. In single-admin + // mode that would leave the only account able to lift the restriction + // blacklisted, permanently freezing governance. + if caller == user { + return Err(AccessControlError::InvalidAddress); + } + env.storage() .persistent() .set(&DataKey::Blacklisted(user.clone()), &true); @@ -884,8 +903,25 @@ impl AccessControlModule { } } + // CT-28: blacklist checks apply to execution, not only to voting. + // A proposal raised by a blacklisted administrator is not executable, + // and approvals cast by blacklisted signers stop counting towards the + // quorum. Filtering instead of failing outright means the remaining + // signers can still reach the threshold, so blacklisting one signer + // cannot permanently freeze governance. + if Self::is_blacklisted(env, &proposal.proposer) { + return Err(AccessControlError::UserBlacklisted); + } + + let mut eligible_approvals = 0u32; + for approver in proposal.approvals.iter() { + if !Self::is_blacklisted(env, &approver) { + eligible_approvals += 1; + } + } + // Validate signatures - if proposal.approvals.len() < proposal.required_signatures { + if eligible_approvals < proposal.required_signatures { return Err(AccessControlError::InsufficientApprovals); } @@ -1456,31 +1492,6 @@ impl AccessControlModule { ) } - /// Validate the raw parameters of a multisig configuration, returning the - /// most specific error for the violation. - /// - /// Shared by first-time initialization and reconfiguration so both paths - /// enforce the same `1 <= threshold <= signer_count` rule and reject - /// duplicate signers. - fn validate_multisig_input(config: &MultiSigConfig) -> AccessControlResult<()> { - if config.admins.is_empty() { - return Err(AccessControlError::InvalidAddress); - } - if config.required_signatures == 0 { - return Err(AccessControlError::ThresholdTooLow); - } - if config.required_signatures > config.admins.len() { - return Err(AccessControlError::ThresholdTooHigh); - } - if config.has_duplicate_admins() { - return Err(AccessControlError::DuplicateAdmin); - } - if !config.validate() { - return Err(AccessControlError::InvalidMultisigConfig); - } - Ok(()) - } - /// Check if emergency mode is active pub fn is_emergency_mode(env: &Env) -> bool { env.storage() diff --git a/contracts/access_control/src/access_control_tests.rs b/contracts/access_control/src/access_control_tests.rs index 316e8a66..e3e2ff4a 100644 --- a/contracts/access_control/src/access_control_tests.rs +++ b/contracts/access_control/src/access_control_tests.rs @@ -1080,7 +1080,7 @@ fn test_rejected_proposal_cleanup_from_pending_list() { // Verify proposal is in pending list let pending_list = AccessControlModule::get_pending_proposals(&env); - assert!(pending_list.contains(&proposal_id)); + assert!(pending_list.contains(proposal_id)); assert_eq!(pending_list.len(), 1); let stats = AccessControlModule::get_proposal_stats(&env); @@ -1093,7 +1093,7 @@ fn test_rejected_proposal_cleanup_from_pending_list() { // Verify proposal is removed from pending list after rejection let pending_list = AccessControlModule::get_pending_proposals(&env); - assert!(!pending_list.contains(&proposal_id)); + assert!(!pending_list.contains(proposal_id)); assert_eq!(pending_list.len(), 0); // Verify stats are updated correctly @@ -1776,6 +1776,8 @@ fn test_failed_access_persists_attempt() { // ==================== CT-29 / CT-30: authorization + emergency policy ==================== +// ==================== CT-29 / CT-30: authorization + emergency policy ==================== + #[test] fn test_unauthorized_mutations_leave_storage_unchanged() { let (env, contract_id, _admin, user1, user2) = setup_initialized_env(); @@ -1821,6 +1823,9 @@ fn test_unauthorized_mutations_leave_storage_unchanged() { AccessControlModule::is_blacklisted(&env, &user2), blacklisted_before ); + }); +} + // ==================== CT-26 / CT-27: execution lifecycle + cleanup bound ==================== fn advance_ledger_time(env: &Env, seconds: u64) { @@ -1879,41 +1884,15 @@ fn test_proposal_executes_after_time_lock_and_only_once() { #[test] fn test_expired_proposal_cannot_execute() { -// ==================== CT-24 / CT-25: threshold + duplicate approvals ==================== - -#[test] -fn test_initialize_multisig_rejects_out_of_range_thresholds() { - let env = Env::default(); - let contract_id = env.register(crate::AccessControl, ()); - let admin1 = Address::generate(&env); - let admin2 = Address::generate(&env); - - env.as_contract(&contract_id, || { - let zero = Vec::from_array(&env, [admin1.clone(), admin2.clone()]); - assert_eq!( - AccessControlModule::initialize_multisig(&env, zero, 0, None).unwrap_err(), - AccessControlError::ThresholdTooLow - ); - - let too_high = Vec::from_array(&env, [admin1.clone(), admin2.clone()]); - assert_eq!( - AccessControlModule::initialize_multisig(&env, too_high, 3, None).unwrap_err(), - AccessControlError::ThresholdTooHigh - ); -// ==================== CT-23 / CT-24: multisig configuration ==================== - -#[test] -fn test_update_multisig_config_full_creates_proposal() { let env = Env::default(); env.mock_all_auths(); let contract_id = env.register(crate::AccessControl, ()); let admin1 = Address::generate(&env); let admin2 = Address::generate(&env); - let admin3 = Address::generate(&env); let user = Address::generate(&env); env.as_contract(&contract_id, || { - let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone(), admin3.clone()]); + let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone()]); AccessControlModule::initialize_multisig(&env, admins, 2, None).unwrap(); let proposal_id = AccessControlModule::create_proposal( @@ -1934,6 +1913,22 @@ fn test_update_multisig_config_full_creates_proposal() { UserRole::Guest ); + // The expired proposal is retired rather than left pending. + assert_eq!(AccessControlModule::get_pending_proposals(&env).len(), 0); + }); +} + +// ==================== CT-23 / CT-24: multisig configuration ==================== + +#[test] +fn test_update_multisig_config_full_creates_proposal() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::AccessControl, ()); + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + let admin3 = Address::generate(&env); + env.as_contract(&contract_id, || { let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone(), admin3.clone()]); AccessControlModule::initialize_multisig(&env, admins.clone(), 2, None).unwrap(); @@ -1955,7 +1950,7 @@ fn test_update_multisig_config_full_creates_proposal() { let pending = AccessControlModule::get_pending_proposals(&env); assert_eq!(pending.len(), 1); - assert!(pending.contains(&proposal_id)); + assert!(pending.contains(proposal_id)); let proposal = AccessControlModule::get_proposal(&env, proposal_id).unwrap(); match proposal.action { @@ -1969,8 +1964,6 @@ fn test_update_multisig_config_full_creates_proposal() { } #[test] -fn test_initialize_multisig_rejects_duplicate_signers() { - let env = Env::default(); fn test_multisig_config_rejects_duplicate_signers() { let env = Env::default(); env.mock_all_auths(); @@ -1979,20 +1972,16 @@ fn test_multisig_config_rejects_duplicate_signers() { let admin2 = Address::generate(&env); env.as_contract(&contract_id, || { - let dup = Vec::from_array(&env, [admin1.clone(), admin1.clone(), admin2.clone()]); - assert_eq!( - AccessControlModule::initialize_multisig(&env, dup, 2, None).unwrap_err(), - AccessControlError::DuplicateAdmin - ); let seeded = Vec::from_array(&env, [admin1.clone(), admin2.clone()]); AccessControlModule::initialize_multisig(&env, seeded, 2, None).unwrap(); // admin1 appears twice: a single signer must not fill two quorum slots. - let dup = Vec::from_array(&env, [admin1.clone(), admin1.clone(), admin2.clone()]); + let duplicate_admins = + Vec::from_array(&env, [admin1.clone(), admin1.clone(), admin2.clone()]); let result = AccessControlModule::propose_multisig_config( &env, admin1.clone(), - dup, + duplicate_admins.clone(), 2, 2, 2, @@ -2001,12 +1990,76 @@ fn test_multisig_config_rejects_duplicate_signers() { 604_800, ); assert_eq!(result.unwrap_err(), AccessControlError::DuplicateAdmin); + + // The same rule is reported by the shared raw-config validator. + let duplicate = MultiSigConfig { + admins: duplicate_admins, + required_signatures: 2, + critical_threshold: 2, + emergency_threshold: 2, + time_lock_duration: 86_400, + max_pending_proposals: 50, + proposal_expiry_duration: 604_800, + }; + assert_eq!( + AccessControlModule::validate_multisig_input(&duplicate).unwrap_err(), + AccessControlError::DuplicateAdmin + ); + }); +} + +#[test] +fn test_multisig_config_rejects_out_of_range_thresholds() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::AccessControl, ()); + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + + env.as_contract(&contract_id, || { + let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone()]); + AccessControlModule::initialize_multisig(&env, admins.clone(), 2, None).unwrap(); + + // Zero threshold. + assert_eq!( + AccessControlModule::propose_multisig_config( + &env, + admin1.clone(), + admins.clone(), + 0, + 0, + 0, + 86_400, + 50, + 604_800, + ) + .unwrap_err(), + AccessControlError::ThresholdTooLow + ); + + // Threshold larger than the signer set. + assert_eq!( + AccessControlModule::propose_multisig_config( + &env, + admin1.clone(), + admins.clone(), + 3, + 3, + 3, + 86_400, + 50, + 604_800, + ) + .unwrap_err(), + AccessControlError::ThresholdTooHigh + ); }); } +// ==================== CT-24 / CT-25: threshold + duplicate approvals ==================== + #[test] fn test_cancel_proposal_requires_proposer_and_changes_nothing_otherwise() { -fn test_execute_proposal_is_callable_via_contract_interface() { let env = Env::default(); env.mock_all_auths(); let contract_id = env.register(crate::AccessControl, ()); @@ -2036,7 +2089,18 @@ fn test_execute_proposal_is_callable_via_contract_interface() { } #[test] -fn test_emergency_classification_requires_emergency_threshold_and_skips_time_lock() { +fn test_execute_proposal_is_callable_via_contract_interface() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::AccessControl, ()); + let client = crate::AccessControlClient::new(&env, &contract_id); + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + let admin3 = Address::generate(&env); + + let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone(), admin3.clone()]); + client.initialize_multisig(&admins, &2); + let proposal_id = client.create_proposal(&admin1, &ProposalAction::Pause); client.approve_proposal(&admin2, &proposal_id); client.approve_proposal(&admin3, &proposal_id); @@ -2048,13 +2112,46 @@ fn test_emergency_classification_requires_emergency_threshold_and_skips_time_loc } #[test] -fn test_cleanup_expired_proposals_is_bounded_per_call() { +fn test_emergency_classification_requires_emergency_threshold_and_skips_time_lock() { let env = Env::default(); env.mock_all_auths(); let contract_id = env.register(crate::AccessControl, ()); - let client = crate::AccessControlClient::new(&env, &contract_id); -fn test_reconfiguration_rejects_duplicate_signers() { -fn test_multisig_config_rejects_out_of_range_thresholds() { + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + let admin3 = Address::generate(&env); + + // Both emergency actions are classified as Emergency. + assert_eq!( + ProposalAction::EmergencyPause(String::from_str(&env, "incident")).classify_type(), + ProposalType::Emergency + ); + assert_eq!( + ProposalAction::ClearEmergencyMode.classify_type(), + ProposalType::Emergency + ); + + env.as_contract(&contract_id, || { + let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone(), admin3.clone()]); + AccessControlModule::initialize_multisig(&env, admins, 2, None).unwrap(); + + let proposal_id = AccessControlModule::create_proposal( + &env, + admin1.clone(), + ProposalAction::EmergencyPause(String::from_str(&env, "incident")), + ) + .unwrap(); + + let proposal = AccessControlModule::get_proposal(&env, proposal_id).unwrap(); + // Emergency uses the highest threshold and is deliberately not + // time-locked so it can respond immediately once quorum is reached. + assert_eq!(proposal.required_signatures, 3); + assert!(proposal.time_lock_until.is_none()); + assert!(!AccessControlModule::is_emergency_mode(&env)); + }); +} + +#[test] +fn test_duplicate_approval_is_rejected_and_counted_once() { let env = Env::default(); env.mock_all_auths(); let contract_id = env.register(crate::AccessControl, ()); @@ -2065,90 +2162,55 @@ fn test_multisig_config_rejects_out_of_range_thresholds() { let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone()]); AccessControlModule::initialize_multisig(&env, admins, 2, None).unwrap(); - // A reconfiguration proposal carrying a duplicate signer is rejected by - // the shared validator before it is recorded. - let duplicate = MultiSigConfig { - admins: Vec::from_array(&env, [admin1.clone(), admin1.clone()]), - required_signatures: 1, - critical_threshold: 1, - emergency_threshold: 1, - time_lock_duration: 86_400, - max_pending_proposals: 50, - proposal_expiry_duration: 604_800, - }; + let user = Address::generate(&env); + let proposal_id = AccessControlModule::create_proposal( + &env, + admin1.clone(), + ProposalAction::SetRole(user.clone(), UserRole::Member), + ) + .unwrap(); + + // The proposer is counted exactly once. assert_eq!( - AccessControlModule::validate_multisig_input(&duplicate).unwrap_err(), - AccessControlError::DuplicateAdmin + AccessControlModule::get_proposal(&env, proposal_id) + .unwrap() + .approvals + .len(), + 1 ); - // Valid configurations still pass. - let valid = MultiSigConfig { - admins: Vec::from_array(&env, [admin1.clone(), admin2.clone()]), - required_signatures: 2, - critical_threshold: 2, - emergency_threshold: 2, - time_lock_duration: 86_400, - max_pending_proposals: 50, - proposal_expiry_duration: 604_800, - }; - assert!(AccessControlModule::validate_multisig_input(&valid).is_ok()); - AccessControlModule::initialize_multisig(&env, admins.clone(), 2, None).unwrap(); - - // Zero threshold. + // Repeated approval by the same signer is explicitly rejected and does + // not increase the approval count. assert_eq!( - AccessControlModule::propose_multisig_config( - &env, - admin1.clone(), - admins.clone(), - 0, - 0, - 0, - 86_400, - 50, - 604_800, - ) - .unwrap_err(), - AccessControlError::ThresholdTooLow + AccessControlModule::approve_proposal(&env, admin1.clone(), proposal_id).unwrap_err(), + AccessControlError::AlreadyApproved + ); + assert_eq!( + AccessControlModule::get_proposal(&env, proposal_id) + .unwrap() + .approvals + .len(), + 1 ); - // Threshold larger than the signer set. + // A distinct signer reaches the threshold and executes the proposal. + AccessControlModule::approve_proposal(&env, admin2.clone(), proposal_id).unwrap(); assert_eq!( - AccessControlModule::propose_multisig_config( - &env, - admin1.clone(), - admins.clone(), - 3, - 3, - 3, - 86_400, - 50, - 604_800, - ) - .unwrap_err(), - AccessControlError::ThresholdTooHigh + AccessControlModule::get_role(&env, user.clone()), + UserRole::Member ); }); } #[test] -fn test_duplicate_approval_is_rejected_and_counted_once() { +fn test_cleanup_expired_proposals_is_bounded_per_call() { let env = Env::default(); env.mock_all_auths(); let contract_id = env.register(crate::AccessControl, ()); + let client = crate::AccessControlClient::new(&env, &contract_id); let admin1 = Address::generate(&env); let admin2 = Address::generate(&env); let admin3 = Address::generate(&env); - - // Both emergency actions are classified as Emergency. - assert_eq!( - ProposalAction::EmergencyPause(String::from_str(&env, "incident")).classify_type(), - ProposalType::Emergency - ); - assert_eq!( - ProposalAction::ClearEmergencyMode.classify_type(), - ProposalType::Emergency - ); - let user = Address::generate(&env); let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone(), admin3.clone()]); @@ -2174,24 +2236,6 @@ fn test_duplicate_approval_is_rejected_and_counted_once() { // Progress is complete: a new proposal is no longer blocked. client.create_proposal(&admin1, &ProposalAction::SetRole(user, UserRole::Member)); assert_eq!(client.get_pending_proposals().len(), 1); - env.as_contract(&contract_id, || { - let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone(), admin3.clone()]); - AccessControlModule::initialize_multisig(&env, admins, 2, None).unwrap(); - - let proposal_id = AccessControlModule::create_proposal( - &env, - admin1.clone(), - ProposalAction::EmergencyPause(String::from_str(&env, "incident")), - ) - .unwrap(); - - let proposal = AccessControlModule::get_proposal(&env, proposal_id).unwrap(); - // Emergency uses the highest threshold and is deliberately not - // time-locked so it can respond immediately once quorum is reached. - assert_eq!(proposal.required_signatures, 3); - assert!(proposal.time_lock_until.is_none()); - assert!(!AccessControlModule::is_emergency_mode(&env)); - }); } #[test] @@ -2225,38 +2269,9 @@ fn test_emergency_mode_lifecycle_respects_multisig() { client.approve_proposal(&admin2, &clear_id); client.approve_proposal(&admin3, &clear_id); assert!(!client.is_emergency_mode()); - ProposalAction::SetRole(user.clone(), UserRole::Member), - ) - .unwrap(); - - // The proposer is counted exactly once. - assert_eq!( - AccessControlModule::get_proposal(&env, proposal_id) - .unwrap() - .approvals - .len(), - 1 - ); - - // Repeated approval by the same signer is explicitly rejected and does - // not increase the approval count. - assert_eq!( - AccessControlModule::approve_proposal(&env, admin1.clone(), proposal_id).unwrap_err(), - AccessControlError::AlreadyApproved - ); - assert_eq!( - AccessControlModule::get_proposal(&env, proposal_id) - .unwrap() - .approvals - .len(), - 1 - ); +} - // A distinct signer reaches the threshold and executes the proposal. - AccessControlModule::approve_proposal(&env, admin2.clone(), proposal_id).unwrap(); - assert_eq!( - AccessControlModule::get_role(&env, user.clone()), - UserRole::Member +#[test] fn test_initialize_multisig_rejects_invalid_thresholds_and_duplicates() { let env = Env::default(); env.mock_all_auths(); @@ -2287,3 +2302,285 @@ fn test_initialize_multisig_rejects_invalid_thresholds_and_duplicates() { ); }); } + +// ==================== CT-28: blacklist behavior for admins and multisig signers ==================== +// +// Policy under test: blacklisting an administrator or multisig signer revokes +// their governance power. A blacklisted signer cannot create, approve, reject +// or execute proposals, and approvals they cast before being blacklisted no +// longer count towards the execution quorum. Lifting the blacklist restores +// every capability, and governance is never left permanently frozen: the +// remaining signers can always reach the threshold without the blacklisted one. + +/// Runs one governance call in a fresh invocation frame. +/// +/// `Env::mock_all_auths` records authorizations per frame and rejects a second +/// `require_auth` for the same address inside one frame, so a test that drives +/// several privileged calls must isolate them — exactly like the independent +/// contract invocations it stands in for. +fn in_frame(env: &Env, contract_id: &Address, f: impl FnOnce() -> T) -> T { + env.as_contract(contract_id, f) +} + +fn setup_multisig_env(required_signatures: u32) -> (Env, Address, Address, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::AccessControl, ()); + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + let admin3 = Address::generate(&env); + + in_frame(&env, &contract_id, || { + let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone(), admin3.clone()]); + AccessControlModule::initialize_multisig(&env, admins, required_signatures, None).unwrap(); + }); + + (env, contract_id, admin1, admin2, admin3) +} + +#[test] +fn test_blacklisted_admin_cannot_create_proposal() { + let (env, contract_id, admin1, admin2, _admin3) = setup_multisig_env(2); + + in_frame(&env, &contract_id, || { + AccessControlModule::blacklist_user(&env, admin1.clone(), admin2.clone()).unwrap(); + }); + + let target = Address::generate(&env); + let action = ProposalAction::SetRole(target, UserRole::Member); + let result = in_frame(&env, &contract_id, || { + AccessControlModule::create_proposal(&env, admin2.clone(), action) + }); + + assert_eq!(result.unwrap_err(), AccessControlError::UserBlacklisted); + assert_eq!( + in_frame(&env, &contract_id, || { + AccessControlModule::get_pending_proposals(&env) + }) + .len(), + 0 + ); +} + +#[test] +fn test_blacklisted_admin_cannot_approve_or_reject_proposal() { + let (env, contract_id, admin1, admin2, _admin3) = setup_multisig_env(3); + + let target = Address::generate(&env); + let proposal_id = in_frame(&env, &contract_id, || { + AccessControlModule::create_proposal( + &env, + admin1.clone(), + ProposalAction::SetRole(target.clone(), UserRole::Member), + ) + .unwrap() + }); + + in_frame(&env, &contract_id, || { + AccessControlModule::blacklist_user(&env, admin1.clone(), admin2.clone()).unwrap(); + }); + + assert_eq!( + in_frame( + &env, + &contract_id, + || AccessControlModule::approve_proposal(&env, admin2.clone(), proposal_id) + ) + .unwrap_err(), + AccessControlError::UserBlacklisted + ); + assert_eq!( + in_frame(&env, &contract_id, || AccessControlModule::reject_proposal( + &env, + admin2.clone(), + proposal_id + )) + .unwrap_err(), + AccessControlError::UserBlacklisted + ); + + // The blacklisted vote was recorded in neither direction. + let proposal = in_frame(&env, &contract_id, || { + AccessControlModule::get_proposal(&env, proposal_id).unwrap() + }); + assert_eq!(proposal.approvals.len(), 1); + assert_eq!(proposal.rejections.len(), 0); + assert_eq!( + in_frame(&env, &contract_id, || AccessControlModule::get_role( + &env, + target.clone() + )), + UserRole::Guest + ); +} + +#[test] +fn test_blacklisted_approval_is_excluded_from_execution_quorum() { + let (env, contract_id, admin1, admin2, admin3) = setup_multisig_env(3); + + let target = Address::generate(&env); + let proposal_id = in_frame(&env, &contract_id, || { + AccessControlModule::create_proposal( + &env, + admin1.clone(), + ProposalAction::SetRole(target.clone(), UserRole::Member), + ) + .unwrap() + }); + + in_frame(&env, &contract_id, || { + AccessControlModule::approve_proposal(&env, admin2.clone(), proposal_id).unwrap(); + }); + in_frame(&env, &contract_id, || { + AccessControlModule::blacklist_user(&env, admin1.clone(), admin2.clone()).unwrap(); + }); + + // admin3's approval completes the third stored approval, but admin2's vote + // is no longer eligible, so the proposal must not execute. + assert_eq!( + in_frame( + &env, + &contract_id, + || AccessControlModule::approve_proposal(&env, admin3.clone(), proposal_id) + ) + .unwrap_err(), + AccessControlError::InsufficientApprovals + ); + assert_eq!( + in_frame(&env, &contract_id, || AccessControlModule::get_role( + &env, + target.clone() + )), + UserRole::Guest + ); + + // Lifting the blacklist restores the stored approval; the proposal can then + // be executed without re-voting. + in_frame(&env, &contract_id, || { + AccessControlModule::unblacklist_user(&env, admin1.clone(), admin2.clone()).unwrap(); + }); + in_frame(&env, &contract_id, || { + AccessControlModule::execute_proposal(&env, proposal_id).unwrap(); + }); + assert_eq!( + in_frame(&env, &contract_id, || AccessControlModule::get_role( + &env, + target.clone() + )), + UserRole::Member + ); +} + +#[test] +fn test_blacklisted_proposer_blocks_execution_until_cleared() { + let (env, contract_id, admin1, admin2, _admin3) = setup_multisig_env(2); + + let target = Address::generate(&env); + let proposal_id = in_frame(&env, &contract_id, || { + AccessControlModule::create_proposal( + &env, + admin1.clone(), + ProposalAction::SetRole(target.clone(), UserRole::Member), + ) + .unwrap() + }); + + in_frame(&env, &contract_id, || { + AccessControlModule::blacklist_user(&env, admin2.clone(), admin1.clone()).unwrap(); + }); + + // admin2 completes the quorum, but the proposal was raised by a blacklisted + // administrator and must not execute. + assert_eq!( + in_frame( + &env, + &contract_id, + || AccessControlModule::approve_proposal(&env, admin2.clone(), proposal_id) + ) + .unwrap_err(), + AccessControlError::UserBlacklisted + ); + assert_eq!( + in_frame(&env, &contract_id, || AccessControlModule::get_role( + &env, + target.clone() + )), + UserRole::Guest + ); + + in_frame(&env, &contract_id, || { + AccessControlModule::unblacklist_user(&env, admin2.clone(), admin1.clone()).unwrap(); + }); + in_frame(&env, &contract_id, || { + AccessControlModule::execute_proposal(&env, proposal_id).unwrap(); + }); + assert_eq!( + in_frame(&env, &contract_id, || AccessControlModule::get_role( + &env, + target.clone() + )), + UserRole::Member + ); +} + +#[test] +fn test_blacklisted_admin_cannot_use_direct_admin_actions() { + let (env, contract_id, admin1, admin2, _admin3) = setup_multisig_env(2); + + in_frame(&env, &contract_id, || { + AccessControlModule::blacklist_user(&env, admin2.clone(), admin1.clone()).unwrap(); + }); + + assert_eq!( + in_frame(&env, &contract_id, || AccessControlModule::set_role( + &env, + admin1.clone(), + Address::generate(&env), + UserRole::Member, + )) + .unwrap_err(), + AccessControlError::UserBlacklisted + ); + assert_eq!( + in_frame(&env, &contract_id, || AccessControlModule::blacklist_user( + &env, + admin1.clone(), + Address::generate(&env), + )) + .unwrap_err(), + AccessControlError::UserBlacklisted + ); + + // Another administrator can still lift the restriction, which restores the + // account's governance rights. + in_frame(&env, &contract_id, || { + AccessControlModule::unblacklist_user(&env, admin2.clone(), admin1.clone()).unwrap(); + }); + assert!( + in_frame(&env, &contract_id, || AccessControlModule::set_role( + &env, + admin1.clone(), + Address::generate(&env), + UserRole::Member, + )) + .is_ok() + ); +} + +#[test] +fn test_admin_cannot_blacklist_self() { + let (env, contract_id, admin1, _admin2, _admin3) = setup_multisig_env(2); + + assert_eq!( + in_frame(&env, &contract_id, || AccessControlModule::blacklist_user( + &env, + admin1.clone(), + admin1.clone() + )) + .unwrap_err(), + AccessControlError::InvalidAddress + ); + assert!(!in_frame(&env, &contract_id, || { + AccessControlModule::is_blacklisted(&env, &admin1) + })); +} diff --git a/contracts/access_control/src/errors.rs b/contracts/access_control/src/errors.rs index bc60304c..af0b131e 100644 --- a/contracts/access_control/src/errors.rs +++ b/contracts/access_control/src/errors.rs @@ -73,7 +73,9 @@ pub enum AccessControlError { NotMultisigAdmin = 132, /// Proposal rejection threshold reached ProposalRejected = 133, - /// ADDED BY FIX #270: User is blacklisted and cannot be elevated to admin + /// User is blacklisted. Blacklisted accounts cannot be elevated to admin + /// (#270) and, per CT-28, cannot create, approve, reject or execute + /// governance proposals. UserBlacklisted = 134, /// C03: Batch blacklist/role operation exceeds maximum allowed size BatchSizeExceeded = 135, @@ -124,7 +126,7 @@ impl AccessControlError { AccessControlError::NotMultisigAdmin => "Not authorized as multisig admin", AccessControlError::ProposalRejected => "Proposal rejection threshold reached", AccessControlError::UserBlacklisted => { - "User is blacklisted and cannot be elevated to admin" + "User is blacklisted and cannot perform privileged actions" } AccessControlError::BatchSizeExceeded => { "Batch size exceeds the maximum allowed for access-control operations" From 50ca5491c9afa856cbff22fe7bd4e494ecbff519 Mon Sep 17 00:00:00 2001 From: MatanOga01 Date: Sun, 18 Oct 2026 20:31:48 +0000 Subject: [PATCH 2/2] fix(frontend): one kobo formatter, accessible 2FA states, booking-payment e2e (FE-18/19/20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FE-19: `formatNaira` in lib/utils is now the single locale-aware kobo-to-naira formatter. It splits kobo from the whole naira with integer arithmetic before formatting, so nothing is rounded or scaled twice and values past MAX_SAFE_INTEGER/100 stay exact. Money fields are typed as KoboAmount / NairaAmount and every ad-hoc `x / 100` currency call site (bookings, payments, invoices, admin and dashboard summaries, workspaces) now uses the one formatter. FE-18: the 2FA sign-in challenge and setup modal expose their loading, invalid code, backup-code and recovery outcomes: errors are tied to the code input with aria-invalid/aria-describedby plus role=alert, submission is disabled and aria-busy while pending, progress and success are announced through polite live regions, the modal is a labelled dialog that closes on Escape and moves focus into itself, and an expired challenge offers a way back to sign in. Both surfaces describe a failure through the shared `describeTwoFactorError`. FE-20: Playwright coverage for the booking-to-payment journey runs the real form against a mocked API and provider, asserting a successful payment, a provider that closes without paying, a workspace picked from the catalogue, and a retry after a failed initialization that never creates a second booking. The files the touched flows depend on were also repaired: merge-damaged splices in lib/apiClient.ts, app/invoices and app/workspaces, the `""/lib` import prefixes and a `celup` state literal in app/settings, and BookingForm calling a `markProviderOpened` the payment hook never exposed. With those fixed the frontend type-checks cleanly again. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- frontend/.gitignore | 2 + frontend/app/(auth)/verify-2fa/page.tsx | 181 ++++++--- frontend/app/admin/analytics/page.tsx | 9 +- frontend/app/admin/bookings/page.tsx | 9 +- frontend/app/admin/workspaces/page.tsx | 9 +- frontend/app/bookings/page.tsx | 7 +- frontend/app/invoices/page.tsx | 25 +- .../app/sandbox/components/BookingWizard.tsx | 9 +- frontend/app/settings/page.tsx | 12 +- frontend/app/workspaces/[id]/page.tsx | 9 +- frontend/app/workspaces/page.tsx | 9 +- frontend/components/bookings/BookingForm.tsx | 20 +- .../components/dashboard/MemberStatsCards.tsx | 9 +- .../components/settings/TwoFactorModal.tsx | 203 ++++++++-- .../components/workspaces/WorkspaceCard.tsx | 7 +- frontend/e2e/booking-payment.spec.ts | 355 ++++++++++++++++++ frontend/lib/apiClient.ts | 3 + .../hooks/two-factor/useConfirm2fa.ts | 42 ++- frontend/lib/types/booking.ts | 8 +- frontend/lib/types/payment.ts | 17 +- frontend/lib/utils.test.ts | 61 ++- frontend/lib/utils.ts | 103 +++++ frontend/package-lock.json | 46 +++ frontend/package.json | 4 +- frontend/playwright.config.ts | 39 ++ 25 files changed, 1017 insertions(+), 181 deletions(-) create mode 100644 frontend/e2e/booking-payment.spec.ts create mode 100644 frontend/playwright.config.ts diff --git a/frontend/.gitignore b/frontend/.gitignore index 5ef6a520..62121fb8 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -12,6 +12,8 @@ # testing /coverage +/playwright-report +/test-results # next.js /.next/ diff --git a/frontend/app/(auth)/verify-2fa/page.tsx b/frontend/app/(auth)/verify-2fa/page.tsx index c97f2e76..ae06f40c 100644 --- a/frontend/app/(auth)/verify-2fa/page.tsx +++ b/frontend/app/(auth)/verify-2fa/page.tsx @@ -1,13 +1,20 @@ "use client"; -import { useState, Suspense } from "react"; +import { useEffect, useRef, useState, Suspense } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { toast } from "sonner"; import { apiClient } from "@/lib/apiClient"; import { useAuthStore } from "@/lib/store/authStore"; import { storage } from "@/lib/storage"; -import { Shield, ArrowLeft, Loader2 } from "lucide-react"; +import { Shield, ArrowLeft, Loader2, AlertCircle } from "lucide-react"; import Link from "next/link"; +import { describeTwoFactorError } from "@/lib/react-query/hooks/two-factor/useConfirm2fa"; + +type ChallengeMode = "totp" | "backup"; + +const CODE_HINT_ID = "verify-2fa-code-hint"; +const CODE_ERROR_ID = "verify-2fa-code-error"; +const CHALLENGE_STATUS_ID = "verify-2fa-status"; function Verify2FAForm() { const router = useRouter(); @@ -15,23 +22,41 @@ function Verify2FAForm() { const tempToken = searchParams.get("tempToken") ?? ""; const email = searchParams.get("email") ?? ""; - const [mode, setMode] = useState<"totp" | "backup">("totp"); + const [mode, setMode] = useState("totp"); const [code, setCode] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [status, setStatus] = useState(null); + const codeInputRef = useRef(null); + + // A missing temporary token cannot be completed: the user has to sign in + // again to be issued a fresh challenge. + const hasChallenge = tempToken.trim().length > 0; + const isTotp = mode === "totp"; + const codeComplete = isTotp ? code.length === 6 : code.trim().length >= 6; + const canSubmit = hasChallenge && codeComplete && !isSubmitting; + + useEffect(() => { + codeInputRef.current?.focus(); + }, [mode]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!code.trim()) return; + if (!canSubmit) return; + setError(null); + setStatus( + isTotp + ? "Verifying your authentication code…" + : "Verifying your backup code…" + ); setIsSubmitting(true); - try { - const endpoint = - mode === "totp" ? "/auth/2fa/verify" : "/auth/2fa/backup-code"; - const body = - mode === "totp" - ? { token: code.trim(), tempToken } - : { backupCode: code.trim(), tempToken }; + try { + const endpoint = isTotp ? "/auth/2fa/verify" : "/auth/2fa/backup-code"; + const body = isTotp + ? { token: code.trim(), tempToken } + : { backupCode: code.trim(), tempToken }; const response = await apiClient.post<{ user: any; @@ -45,22 +70,41 @@ function Verify2FAForm() { storage.setToken(response.accessToken); storage.setUser(response.user); - if (mode === "backup" && response.backupCodesRemaining !== undefined) { - toast.success( - `Signed in. ${response.backupCodesRemaining} backup codes remaining.`, + if (!isTotp && response.backupCodesRemaining !== undefined) { + const remaining = response.backupCodesRemaining; + setStatus( + `Backup code accepted. ${remaining} backup ${ + remaining === 1 ? "code" : "codes" + } remaining. Signing you in…` ); + toast.success(`Signed in. ${remaining} backup codes remaining.`); } else { + setStatus("Code accepted. Signing you in…"); toast.success("Signed in successfully"); } + // Success continues the auth flow. router.push("/dashboard"); - } catch (error: any) { - toast.error(error.message || "Invalid code. Please try again."); + } catch (caught) { + const message = describeTwoFactorError(caught); + setError(message); + setStatus(null); + toast.error(message); + // Keyboard users land back on the field they need to correct. + codeInputRef.current?.focus(); + codeInputRef.current?.select(); } finally { setIsSubmitting(false); } }; + const switchMode = () => { + setMode((current) => (current === "totp" ? "backup" : "totp")); + setCode(""); + setError(null); + setStatus(null); + }; + return (
@@ -74,7 +118,7 @@ function Verify2FAForm() { Two-factor verification

- {mode === "totp" + {isTotp ? "Enter the 6-digit code from your authenticator app." : "Enter one of your saved backup codes."}

@@ -87,37 +131,81 @@ function Verify2FAForm() {
-
+ {!hasChallenge && ( +
+ + + This verification request is no longer valid.{" "} + + Sign in again + {" "} + to start a new one. + +
+ )} + +
-
+ + {/* Progress and success are announced without moving focus. */} +

+ {status ?? ""} +

diff --git a/frontend/app/admin/analytics/page.tsx b/frontend/app/admin/analytics/page.tsx index 5d176be7..2f30db5d 100644 --- a/frontend/app/admin/analytics/page.tsx +++ b/frontend/app/admin/analytics/page.tsx @@ -12,14 +12,13 @@ import { RefreshCw, Trophy, } from "lucide-react"; +import { formatNaira as formatCurrency } from "@/lib/utils"; +/** Summary figures switch to compact notation once they pass ₦1,000,000. */ function formatNaira(kobo: number): string { - return new Intl.NumberFormat("en-NG", { - style: "currency", - currency: "NGN", - minimumFractionDigits: 0, + return formatCurrency(kobo, { notation: kobo >= 100_000_000 ? "compact" : "standard", - }).format(kobo / 100); + }); } function StatCard({ diff --git a/frontend/app/admin/bookings/page.tsx b/frontend/app/admin/bookings/page.tsx index 3e21e883..306bb1b1 100644 --- a/frontend/app/admin/bookings/page.tsx +++ b/frontend/app/admin/bookings/page.tsx @@ -6,6 +6,7 @@ import { useGetAllBookings } from "@/lib/react-query/hooks/admin/bookings/useGet import { useUpdateBookingStatus } from "@/lib/react-query/hooks/admin/bookings/useUpdateBookingStatus"; import { BookingStatus } from "@/lib/types/booking"; import { BookOpen, ChevronLeft, ChevronRight } from "lucide-react"; +import { formatNaira } from "@/lib/utils"; const STATUSES: { value: BookingStatus | ""; label: string }[] = [ { value: "", label: "All" }, @@ -26,14 +27,6 @@ function formatDate(iso: string): string { return new Date(iso).toLocaleDateString("en-NG", { dateStyle: "medium" }); } -function formatNaira(kobo: number): string { - return new Intl.NumberFormat("en-NG", { - style: "currency", - currency: "NGN", - minimumFractionDigits: 0, - }).format(kobo / 100); -} - export default function AdminBookingsPage() { const [page, setPage] = useState(1); const [statusFilter, setStatusFilter] = useState(""); diff --git a/frontend/app/admin/workspaces/page.tsx b/frontend/app/admin/workspaces/page.tsx index e5b216e6..c3febe1b 100644 --- a/frontend/app/admin/workspaces/page.tsx +++ b/frontend/app/admin/workspaces/page.tsx @@ -16,6 +16,7 @@ import { ChevronRight, Building2, } from "lucide-react"; +import { formatNaira } from "@/lib/utils"; const TYPE_LABELS: Record = { COWORKING: "Coworking", @@ -25,14 +26,6 @@ const TYPE_LABELS: Record = { DEDICATED_DESK: "Dedicated Desk", }; -function formatNaira(kobo: number): string { - return new Intl.NumberFormat("en-NG", { - style: "currency", - currency: "NGN", - minimumFractionDigits: 0, - }).format(kobo / 100); -} - export default function AdminWorkspacesPage() { const [page, setPage] = useState(1); const [search, setSearch] = useState(""); diff --git a/frontend/app/bookings/page.tsx b/frontend/app/bookings/page.tsx index f1809a55..458ae26b 100644 --- a/frontend/app/bookings/page.tsx +++ b/frontend/app/bookings/page.tsx @@ -17,7 +17,7 @@ import { XCircle, } from "lucide-react"; import { toast } from "sonner"; -import { formatDate } from "@/lib/utils"; +import { formatDate, formatNaira } from "@/lib/utils"; const STATUS_STYLES: Record = { PENDING: "bg-amber-50 text-amber-700", @@ -47,10 +47,7 @@ function BookingRow({ booking, onCancelled }: { booking: Booking; onCancelled: ( const { mutateAsync: initPayment, isPending: paying } = useInitializePayment(); const [confirmCancel, setConfirmCancel] = useState(false); - const amountNaira = (booking.totalAmount / 100).toLocaleString("en-NG", { - style: "currency", - currency: "NGN", - }); + const amountNaira = formatNaira(booking.totalAmount); async function handleCancel() { await submitCancelAction({ diff --git a/frontend/app/invoices/page.tsx b/frontend/app/invoices/page.tsx index f758d027..cc0aa77e 100644 --- a/frontend/app/invoices/page.tsx +++ b/frontend/app/invoices/page.tsx @@ -6,10 +6,7 @@ import { useGetMyInvoices } from "@/lib/react-query/hooks/invoices/useGetMyInvoi import { Invoice, InvoiceDownload, InvoiceStatus } from "@/lib/types/invoice"; import { FileText, Download, Loader2 } from "lucide-react"; import { apiClient, sanitizeDownloadFilename } from "@/lib/apiClient"; -import { formatDate } from "@/lib/utils"; -import { toast } from "sonner"; -import { storage } from "@/lib/storage"; -import { env } from "@/utils/env"; +import { formatDate, formatNaira } from "@/lib/utils"; const STATUS_STYLES: Record = { PAID: "bg-green-50 text-green-700", @@ -17,13 +14,6 @@ const STATUS_STYLES: Record = { CANCELLED: "bg-red-50 text-red-600", }; -function formatNaira(amountKobo: number): string { - return (amountKobo / 100).toLocaleString("en-NG", { - style: "currency", - currency: "NGN", - }); -} - function InvoiceRow({ invoice }: { invoice: Invoice }) { const [downloading, setDownloading] = useState(false); const [error, setError] = useState(null); @@ -64,19 +54,6 @@ function InvoiceRow({ invoice }: { invoice: Invoice }) { anchor.click(); } finally { URL.revokeObjectURL(url); - const token = storage.getToken(); - const API_BASE = env.NEXT_PUBLIC_API_URL; - const res = await fetch(`${API_BASE}/invoices/${invoice.id}/download`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - if (!res.ok) { - throw new Error( - res.status === 404 - ? `Invoice ${invoice.invoiceNumber} could not be found for download.` - : res.status === 401 || res.status === 403 - ? "You are not authorized to download this invoice." - : `Unible to download this invoice (status ${res.status}). Please try again.`, - ); } } catch (cause) { setError( diff --git a/frontend/app/sandbox/components/BookingWizard.tsx b/frontend/app/sandbox/components/BookingWizard.tsx index edc04082..a61ef56c 100644 --- a/frontend/app/sandbox/components/BookingWizard.tsx +++ b/frontend/app/sandbox/components/BookingWizard.tsx @@ -11,6 +11,7 @@ import { } from "lucide-react"; import { AvailabilityCalendar } from "./AvailabilityCalendar"; import SeatMap, { type Seat, type SeatStatus } from "./SeatMap"; +import { formatNaira } from "@/lib/utils"; // ---- Types ---- @@ -74,14 +75,6 @@ const TYPE_COLORS: Record = { // ---- Helpers ---- -function formatNaira(kobo: number) { - return (kobo / 100).toLocaleString("en-NG", { - style: "currency", - currency: "NGN", - maximumFractionDigits: 0, - }); -} - function daysBetween(start: string, end: string) { const diff = Math.ceil( diff --git a/frontend/app/settings/page.tsx b/frontend/app/settings/page.tsx index 1a4bc519..2823da90 100644 --- a/frontend/app/settings/page.tsx +++ b/frontend/app/settings/page.tsx @@ -4,11 +4,11 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useAuthState, useAuthActions } from ""/lib/store/authStore"; -import { apiClient } from ""/lib/apiClient"; -import DashboardLayout from ""/components/dashboard/DashboardLayout"; -import TwoFactorModal from ""/components/settings/TwoFactorModal"; -import { use2faStatus } from ""/lib/react-query/hooks/two-factor/use2faStatus"; +import { useAuthState, useAuthActions } from "@/lib/store/authStore"; +import { apiClient } from "@/lib/apiClient"; +import DashboardLayout from "@/components/dashboard/DashboardLayout"; +import TwoFactorModal from "@/components/settings/TwoFactorModal"; +import { use2faStatus } from "@/lib/react-query/hooks/two-factor/use2faStatus"; import { Eye, EyeOff, Shield, Bell, Palette } from "lucide-react"; /* ✀ Password change schema ✀ */ @@ -111,7 +111,7 @@ export default function SettingsPage() { /* ✀ 2FA ✀ */ const { data: twoFaData } = use2faStatus(); const twoFaEnabled = twoFaData?.data?.enabled ?? false; - const [twoFaModal, setTwoFaModal] = useState<"celup" | "disable" | null>( + const [twoFaModal, setTwoFaModal] = useState<"setup" | "disable" | null>( null ); diff --git a/frontend/app/workspaces/[id]/page.tsx b/frontend/app/workspaces/[id]/page.tsx index 6ae38579..e2404227 100644 --- a/frontend/app/workspaces/[id]/page.tsx +++ b/frontend/app/workspaces/[id]/page.tsx @@ -15,6 +15,7 @@ import { RefreshCcw, Loader2, } from "lucide-react"; +import { formatNaira } from "@/lib/utils"; const TYPE_LABELS: Record = { COWORKING: "Coworking", @@ -44,13 +45,7 @@ export default function WorkspaceDetailPage({ router.push(`/bookings/new?workspaceId=${workspace.id}`); } - const hourlyNaira = workspace - ? (workspace.hourlyRate / 100).toLocaleString("en-NG", { - style: "currency", - currency: "NGN", - maximumFractionDigits: 0, - }) - : ""; + const hourlyNaira = workspace ? formatNaira(workspace.hourlyRate) : ""; return ( diff --git a/frontend/app/workspaces/page.tsx b/frontend/app/workspaces/page.tsx index 4dc4e316..dd048c74 100644 --- a/frontend/app/workspaces/page.tsx +++ b/frontend/app/workspaces/page.tsx @@ -103,20 +103,13 @@ export default function WorkspacesPage() { setPage(1); }, [searchParams]); - const { data, isLoading, isError, isFetching } = useGetWorkspaces( + const { data, isLoading, isError, refetch, isFetching } = useGetWorkspaces( toWorkspaceQuery({ search, type }, page, PAGE_SIZE) ); - const { data, isLoading, isError, refetch, isFetching } = useGetWorkspaces({ - search: search || undefined, - type: type || undefined, - page, - limit: 9, - }); const workspaces = data?.data ?? []; const meta = data?.meta; - const hasActiveFilters = searchInput !== "" || type !== ""; // One explicit state per outcome, so loading, failure, empty and success can // never be mistaken for one another. const panel = queryPanelState({ diff --git a/frontend/components/bookings/BookingForm.tsx b/frontend/components/bookings/BookingForm.tsx index fe61e660..351f09e7 100644 --- a/frontend/components/bookings/BookingForm.tsx +++ b/frontend/components/bookings/BookingForm.tsx @@ -19,6 +19,7 @@ import { validateBookingDates, } from "@/lib/bookingValidation"; import { createSubmissionGate } from "@/lib/bookingSubmission"; +import { formatNaira } from "@/lib/utils"; import { ArrowLeft, ArrowRight, @@ -106,10 +107,7 @@ export default function BookingForm() { usePriceEstimate(estimateParams); const totalAmount = estimateData?.data?.totalAmount ?? 0; - const totalNaira = (totalAmount / 100).toLocaleString("en-NG", { - style: "currency", - currency: "NGN", - }); + const totalNaira = formatNaira(totalAmount); const { mutateAsync: createBooking, isPending: creatingBooking } = useCreateBooking(); @@ -117,7 +115,6 @@ export default function BookingForm() { mutateAsync: initializePayment, isPending: initializingPayment, attempt: paymentAttempt, - markProviderOpened, markSubmitted, markProviderClosed, } = useInitializePayment(); @@ -205,7 +202,6 @@ export default function BookingForm() { // Use access_code to avoid needing user email again void accessCode; handler.openIframe(); - markProviderOpened(); } catch { // error toast handled in hook } @@ -252,10 +248,14 @@ export default function BookingForm() { {/* Workspace select (only shown if no preselection) */} {!preselectedId && (
-
@@ -115,11 +212,10 @@ export default function TwoFactorModal({ mode, onClose }: Props) { type="button" onClick={handleDisable} disabled={!password || disable2fa.isPending} + aria-busy={disable2fa.isPending} className="flex-1 px-5 py-2.5 text-sm font-medium rounded-lg bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 transition-colors" > - {disable2fa.isPending - ? "Disabling..." - : "Disable 2FA"} + {disable2fa.isPending ? "Disabling..." : "Disable 2FA"} @@ -244,6 +378,7 @@ export default function TwoFactorModal({ mode, onClose }: Props) { type="button" onClick={handleConfirm} disabled={totpToken.length !== 6 || confirm2fa.isPending} + aria-busy={confirm2fa.isPending} className="flex-1 px-5 py-2.5 text-sm font-medium rounded-lg bg-gray-900 text-white hover:bg-gray-800 disabled:opacity-50 transition-colors" > {confirm2fa.isPending ? "Verifying..." : "Enable 2FA"} @@ -263,7 +398,11 @@ export default function TwoFactorModal({ mode, onClose }: Props) {

-
+
{backupCodes.map((code) => (
)} + + {/* Pending, success and recovery outcomes are announced politely. */} +

+ {status ?? ""} +

diff --git a/frontend/components/workspaces/WorkspaceCard.tsx b/frontend/components/workspaces/WorkspaceCard.tsx index 1f40509e..4e29b2c5 100644 --- a/frontend/components/workspaces/WorkspaceCard.tsx +++ b/frontend/components/workspaces/WorkspaceCard.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { Workspace } from "@/lib/types/workspace"; import { MapPin, Users, ArrowRight } from "lucide-react"; +import { formatNaira } from "@/lib/utils"; const TYPE_LABELS: Record = { COWORKING: "Coworking", @@ -25,11 +26,7 @@ interface Props { } export default function WorkspaceCard({ workspace }: Props) { - const hourlyNaira = (workspace.hourlyRate / 100).toLocaleString("en-NG", { - style: "currency", - currency: "NGN", - maximumFractionDigits: 0, - }); + const hourlyNaira = formatNaira(workspace.hourlyRate); return (
diff --git a/frontend/e2e/booking-payment.spec.ts b/frontend/e2e/booking-payment.spec.ts new file mode 100644 index 00000000..e289b73d --- /dev/null +++ b/frontend/e2e/booking-payment.spec.ts @@ -0,0 +1,355 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +/** + * Booking-to-payment journey. + * + * The three journeys below drive the real form: search a workspace, pick a + * plan and dates, review, create the booking, then initialize payment. The API + * and the Paystack popup are mocked, so each journey can deterministically + * observe a provider success, a provider closing the window, and a retry after + * a failed initialization — including the requirement that retrying never + * creates a second booking. + */ + +const ACCESS_TOKEN = "e2e-access-token"; + +const WORKSPACE = { + id: "ws_e2e", + name: "E2E Hub", + type: "COWORKING", + hourlyRate: 150000, + totalSeats: 12, + active: true, +}; + +const USER = { + id: "user_e2e", + firstname: "Test", + lastname: "Member", + email: "member@example.com", + role: "user", + isActive: true, + isSuspended: false, + isDeleted: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, +}; + +/** Total shown in the review step: 250,000 kobo = ₦2,500.00. */ +const TOTAL_KOBO = 250_000; +const TOTAL_LABEL = "₦2,500.00"; + +/** Message the mocked API returns when payment initialization fails. */ +const PAYMENT_OUTAGE_MESSAGE = "Payment provider unavailable"; + +type Journey = { + /** Requests received by the mocked API, for duplicate-request assertions. */ + calls: { + bookings: number; + initialize: number; + }; + /** How the mocked Paystack popup behaves when the user clicks "Pay". */ + setProviderBehaviour: (behaviour: "success" | "close") => Promise; + /** Makes the next `POST /payments/initialize` fail like a provider outage. */ + failNextInitialize: () => void; + /** Bodies captured by `POST /bookings`. */ + bookingBodies: Array>; +}; + +function isoDaysFromNow(days: number): string { + return new Date(Date.now() + days * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); +} + +/** The payment step's primary action, labelled with the exact total. */ +function payButton(page: Page) { + return page.getByRole("button", { name: `Pay ${TOTAL_LABEL}` }); +} + +function json(route: Route, body: unknown, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(body), + }); +} + +/** + * Signs the browser in the way the app expects (cookie for the middleware, + * storage keys for the auth store) and installs the Paystack double. + */ +async function signIn(page: Page, baseURL: string) { + await page.context().addCookies([ + { name: "authToken", value: ACCESS_TOKEN, url: baseURL }, + ]); + + await page.addInitScript( + ({ token, user }) => { + window.localStorage.setItem("authToken", token); + window.localStorage.setItem("authUser", JSON.stringify(user)); + + type PaystackOptions = { + ref: string; + callback: (response: { reference: string }) => void; + onClose: () => void; + }; + type PaystackWindow = Window & { + PaystackPop: { setup: (options: PaystackOptions) => { openIframe: () => void } }; + __paystackBehaviour: "success" | "close"; + }; + + const scoped = window as unknown as PaystackWindow; + scoped.__paystackBehaviour = "success"; + + // The popup double: opening it either reports a completed payment or + // closes without one, mirroring the two provider outcomes. + scoped.PaystackPop = { + setup: (options) => ({ + openIframe: () => { + if (scoped.__paystackBehaviour === "close") { + options.onClose(); + return; + } + options.callback({ reference: options.ref }); + }, + }), + }; + }, + { token: ACCESS_TOKEN, user: USER } + ); + + // The real inline script would overwrite the double; keep it out. + await page.route("https://js.paystack.co/**", (route) => route.abort()); +} + +async function mockApi(page: Page, baseURL: string): Promise { + const journey: Journey = { + calls: { bookings: 0, initialize: 0 }, + bookingBodies: [], + setProviderBehaviour: (behaviour) => + page.evaluate((value) => { + (window as unknown as { __paystackBehaviour: string }).__paystackBehaviour = + value; + }, behaviour), + failNextInitialize: () => { + failInitialize = true; + }, + }; + + let failInitialize = false; + + await page.route("**/api/**", async (route) => { + const request = route.request(); + const { pathname } = new URL(request.url()); + const method = request.method(); + + if (method === "GET" && pathname === `/api/workspaces/${WORKSPACE.id}`) { + return json(route, { success: true, data: WORKSPACE }); + } + + // The workspace picker on the form reads the catalogue. + if (method === "GET" && pathname === "/api/workspaces") { + return json(route, { + success: true, + data: [WORKSPACE], + meta: { total: 1, page: 1, limit: 100, totalPages: 1 }, + }); + } + + if (method === "GET" && pathname === "/api/bookings/price-estimate") { + return json(route, { + success: true, + data: { + totalAmount: TOTAL_KOBO, + planType: "DAILY", + seatCount: 1, + startDate: isoDaysFromNow(7), + endDate: isoDaysFromNow(8), + }, + }); + } + + if (method === "POST" && pathname === "/api/bookings") { + journey.calls.bookings += 1; + journey.bookingBodies.push( + (request.postDataJSON() ?? {}) as Record + ); + return json(route, { + success: true, + data: { + id: `booking_${journey.calls.bookings}`, + userId: USER.id, + workspaceId: WORKSPACE.id, + planType: "DAILY", + startDate: isoDaysFromNow(7), + endDate: isoDaysFromNow(8), + totalAmount: TOTAL_KOBO, + status: "PENDING", + seatCount: 1, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + } + + if (method === "POST" && pathname === "/api/payments/initialize") { + journey.calls.initialize += 1; + if (failInitialize) { + failInitialize = false; + // A rejected request keeps the client from reporting a provider + // success; the message is what the payment step surfaces. + return json( + route, + { statusCode: 500, message: PAYMENT_OUTAGE_MESSAGE }, + 500 + ); + } + return json(route, { + success: true, + data: { + authorizationUrl: `${baseURL}/paystack-mock`, + accessCode: "access_code_e2e", + reference: "ref_e2e", + paymentId: "payment_e2e", + }, + }); + } + + if (method === "GET" && pathname === "/api/bookings") { + return json(route, { + success: true, + data: [], + meta: { total: 0, page: 1, limit: 10, totalPages: 0 }, + }); + } + + // Notifications and anything else the shell asks for. + return json(route, { success: true, data: [] }); + }); + + return journey; +} + +/** Fills step 1 (details) and moves to the review step. */ +async function fillBookingDetails(page: Page) { + // The selected workspace panel renders its (formatted) hourly rate. + await expect(page.getByText(/\/ hr ·/)).toBeVisible(); + + await page.locator("#booking-start-date").fill(isoDaysFromNow(7)); + await page.locator("#booking-end-date").fill(isoDaysFromNow(8)); + + // The estimate is what the review and payment steps display. + await expect(page.getByText(TOTAL_LABEL).first()).toBeVisible(); + + await page.getByRole("button", { name: /continue to review/i }).click(); +} + +/** Confirms the booking (step 2) and pays (step 3). */ +async function confirmBookingAndStartPayment(page: Page) { + await page + .getByRole("button", { name: /confirm & proceed to payment/i }) + .click(); + + await expect(page.getByText(/your booking is reserved/i)).toBeVisible(); + await payButton(page).click(); +} + +test.describe("booking to payment journey", () => { + test.beforeEach(async ({ page, baseURL }) => { + await signIn(page, baseURL!); + }); + + test("a workspace picked from the catalogue is the one that gets booked", async ({ + page, + baseURL, + }) => { + const journey = await mockApi(page, baseURL!); + + // No `workspaceId` in the URL: the user searches the catalogue instead. + await page.goto("/bookings/new"); + await expect(page.locator("#booking-workspace")).toBeVisible(); + await page.locator("#booking-workspace").selectOption(WORKSPACE.id); + + await fillBookingDetails(page); + await page + .getByRole("button", { name: /confirm & proceed to payment/i }) + .click(); + + await expect(page.getByText(/your booking is reserved/i)).toBeVisible(); + expect(journey.bookingBodies).toHaveLength(1); + expect(journey.bookingBodies[0].workspaceId).toBe(WORKSPACE.id); + }); + + test("successful payment confirms the booking and returns to the bookings list", async ({ + page, + baseURL, + }) => { + const journey = await mockApi(page, baseURL!); + + await page.goto("/bookings/new?workspaceId=ws_e2e"); + await fillBookingDetails(page); + await confirmBookingAndStartPayment(page); + + // The provider reported a completed payment, so the app hands the user off + // to their bookings while the backend confirms the payment. + await page.waitForURL("**/bookings"); + await expect(page.getByRole("heading", { name: "My Bookings" })).toBeVisible(); + + // One booking, one payment initialization: the success path does not retry. + expect(journey.calls.bookings).toBe(1); + expect(journey.calls.initialize).toBe(1); + }); + + test("provider failure is reported and leaves the payment ready to retry", async ({ + page, + baseURL, + }) => { + const journey = await mockApi(page, baseURL!); + + await page.goto("/bookings/new?workspaceId=ws_e2e"); + // The popup closes without a completed payment. + await journey.setProviderBehaviour("close"); + await fillBookingDetails(page); + await confirmBookingAndStartPayment(page); + + await expect(page.getByText(/payment window closed/i)).toBeVisible(); + + // Still on the payment step, with the pay button usable again: the failure + // is reported without claiming a payment was made. + expect(new URL(page.url()).pathname).toBe("/bookings/new"); + await expect(payButton(page)).toBeEnabled(); + await expect(page.getByText(/payment confirmed/i)).toHaveCount(0); + + expect(journey.calls.bookings).toBe(1); + expect(journey.calls.initialize).toBe(1); + }); + + test("retrying a failed payment does not create a duplicate booking", async ({ + page, + baseURL, + }) => { + const journey = await mockApi(page, baseURL!); + + await page.goto("/bookings/new?workspaceId=ws_e2e"); + await fillBookingDetails(page); + + journey.failNextInitialize(); + await confirmBookingAndStartPayment(page); + + // The first initialization failed: the failure is surfaced and the booking + // is not created a second time. + await expect(page.getByText(PAYMENT_OUTAGE_MESSAGE).first()).toBeVisible(); + expect(journey.calls.bookings).toBe(1); + expect(journey.calls.initialize).toBe(1); + + // The retry reaches the provider and completes the journey. + await payButton(page).click(); + await page.waitForURL("**/bookings"); + + expect(journey.calls.bookings).toBe(1); + expect(journey.calls.initialize).toBe(2); + expect(journey.bookingBodies).toHaveLength(1); + }); +}); diff --git a/frontend/lib/apiClient.ts b/frontend/lib/apiClient.ts index a5684b76..ae392519 100644 --- a/frontend/lib/apiClient.ts +++ b/frontend/lib/apiClient.ts @@ -393,6 +393,9 @@ class ApiClient { response.headers?.get?.("content-disposition") ?? null ), }; + } + + /** * `post` that never participates in the 401 refresh flow. Reserved for the * refresh endpoint itself, so a rejected refresh cannot trigger another one. */ diff --git a/frontend/lib/react-query/hooks/two-factor/useConfirm2fa.ts b/frontend/lib/react-query/hooks/two-factor/useConfirm2fa.ts index 56fe6870..506ff71c 100644 --- a/frontend/lib/react-query/hooks/two-factor/useConfirm2fa.ts +++ b/frontend/lib/react-query/hooks/two-factor/useConfirm2fa.ts @@ -1,7 +1,7 @@ "use client"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { apiClient } from "@/lib/apiClient"; +import { ApiError, apiClient } from "@/lib/apiClient"; import { queryKeys } from "@/lib/react-query/keys/queryKeys"; import { toast } from "sonner"; @@ -12,6 +12,44 @@ interface Confirm2faResponse { }; } +export const INVALID_2FA_CODE_MESSAGE = + "That code didn't work. Check your authenticator app and try again."; +export const RATE_LIMITED_2FA_MESSAGE = + "Too many attempts. Wait a moment, then try again."; +export const EXPIRED_2FA_CHALLENGE_MESSAGE = + "This verification request has expired. Sign in again to get a new code."; +export const UNKNOWN_2FA_ERROR_MESSAGE = + "We couldn't verify that code. Please try again."; + +/** + * Single description of a failed 2FA step, shared by the setup modal and the + * sign-in challenge so both surfaces explain the same failure the same way. + * + * The three outcomes a user can act on are told apart explicitly — invalid + * code, rate limited, and an expired challenge — because each one needs a + * different response (retype, wait, start over). + */ +export function describeTwoFactorError(error: unknown): string { + if (error instanceof ApiError) { + if (error.status === 429) return RATE_LIMITED_2FA_MESSAGE; + if (error.status === 408 || error.status === 410) { + return EXPIRED_2FA_CHALLENGE_MESSAGE; + } + if ( + error.status === 400 || + error.status === 401 || + error.status === 403 || + error.status === 422 + ) { + return INVALID_2FA_CODE_MESSAGE; + } + return error.message || UNKNOWN_2FA_ERROR_MESSAGE; + } + + if (error instanceof Error && error.message) return error.message; + return UNKNOWN_2FA_ERROR_MESSAGE; +} + export const useConfirm2fa = () => { const queryClient = useQueryClient(); @@ -23,7 +61,7 @@ export const useConfirm2fa = () => { toast.success("Two-factor authentication enabled"); }, onError: (error: Error) => { - toast.error(error.message || "Invalid code. Please try again."); + toast.error(describeTwoFactorError(error)); }, }); }; diff --git a/frontend/lib/types/booking.ts b/frontend/lib/types/booking.ts index 4ebb8cd0..1eb1fdd4 100644 --- a/frontend/lib/types/booking.ts +++ b/frontend/lib/types/booking.ts @@ -1,3 +1,5 @@ +import type { KoboAmount, NairaAmount } from "./payment"; + export type BookingStatus = | "PENDING" | "CONFIRMED" @@ -19,7 +21,7 @@ export interface Booking { planType: PlanType; startDate: string; endDate: string; - totalAmount: number; // in kobo + totalAmount: KoboAmount; status: BookingStatus; seatCount: number; notes?: string; @@ -43,8 +45,8 @@ export interface CreateBookingDto { } export interface PriceEstimate { - totalAmount: number; // kobo - totalAmountNaira: number; + totalAmount: KoboAmount; + totalAmountNaira: NairaAmount; planType: PlanType; seatCount: number; startDate: string; diff --git a/frontend/lib/types/payment.ts b/frontend/lib/types/payment.ts index 344f4bf4..b3572bf6 100644 --- a/frontend/lib/types/payment.ts +++ b/frontend/lib/types/payment.ts @@ -1,5 +1,20 @@ export type PaymentStatus = "PENDING" | "SUCCESS" | "FAILED" | "REFUNDED"; +/** + * An integer amount in kobo (1 naira = 100 kobo). + * + * Every price the API accepts or returns is carried in this unit so no value is + * scaled or rounded on the way through the app; render it with `formatNaira` + * from `@/lib/utils` instead of dividing by 100 at the call site. + */ +export type KoboAmount = number; + +/** + * An amount denominated in whole naira. Only used for values the API documents + * as naira, such as `PriceEstimate.totalAmountNaira`. + */ +export type NairaAmount = number; + /** * Lifecycle of one payment attempt, from pressing "Pay" until the provider * confirms it. `paid` is only reachable from a backend confirmation, so the UI @@ -19,7 +34,7 @@ export interface Payment { id: string; bookingId: string; userId: string; - amount: number; // kobo + amount: KoboAmount; currency: string; provider: PaymentProvider; providerReference?: string; diff --git a/frontend/lib/utils.test.ts b/frontend/lib/utils.test.ts index 6ef4b921..a26acf47 100644 --- a/frontend/lib/utils.test.ts +++ b/frontend/lib/utils.test.ts @@ -1,4 +1,4 @@ -import { formatDate } from "./utils"; +import { formatDate, formatNaira } from "./utils"; import { describe, it, expect } from "vitest"; describe("formatDate", () => { @@ -24,3 +24,62 @@ describe("formatDate", () => { expect(formatDate("invalid-date")).toBe("Invalid date"); }); }); + +/** + * `formatNaira` is the one place kobo is scaled to naira, so these cases pin the + * conversions the whole app depends on: zero, kobo fractions, amounts that used + * to be rounded away, and values past the safe-integer division boundary. + */ +describe("formatNaira", () => { + it("formats zero as ₦0.00", () => { + expect(formatNaira(0)).toBe("₦0.00"); + }); + + it("keeps the kobo part instead of rounding it away", () => { + expect(formatNaira(1)).toBe("₦0.01"); + expect(formatNaira(50)).toBe("₦0.50"); + expect(formatNaira(105)).toBe("₦1.05"); + expect(formatNaira(999)).toBe("₦9.99"); + }); + + it("scales kobo to naira exactly once", () => { + expect(formatNaira(123456)).toBe("₦1,234.56"); + expect(formatNaira(100_000_00)).toBe("₦100,000.00"); + }); + + it("formats large amounts without losing a kobo", () => { + // Number.MAX_SAFE_INTEGER kobo: dividing first would render ...409.90. + expect(formatNaira(Number.MAX_SAFE_INTEGER)).toBe( + "₦90,071,992,547,409.91" + ); + expect(formatNaira(123_456_789_012_345)).toBe( + "₦1,234,567,890,123.45" + ); + }); + + it("formats negative amounts", () => { + expect(formatNaira(-123456)).toBe("-₦1,234.56"); + expect(formatNaira(-50)).toBe("-₦0.50"); + }); + + it("rounds non-integer kobo to the nearest kobo", () => { + expect(formatNaira(1234.5)).toBe("₦12.35"); + expect(formatNaira(1234.4)).toBe("₦12.34"); + }); + + it("falls back to zero for values that are not finite", () => { + expect(formatNaira(Number.NaN)).toBe("₦0.00"); + expect(formatNaira(Number.POSITIVE_INFINITY)).toBe("₦0.00"); + }); + + it("supports compact notation for summary figures", () => { + // 150,000,000 kobo = ₦1,500,000 + expect(formatNaira(150_000_000, { notation: "compact" })).toBe("₦1.5M"); + }); + + it("honours locale and currency overrides", () => { + expect(formatNaira(123456, { locale: "en-US", currency: "USD" })).toBe( + "$1,234.56" + ); + }); +}); diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts index 0a20c9ec..786a78d1 100644 --- a/frontend/lib/utils.ts +++ b/frontend/lib/utils.ts @@ -16,3 +16,106 @@ export function formatDate(date: string | Date | undefined | null): string { day: 'numeric' }); } + +/** Locale every money value in the app is rendered in. */ +export const DEFAULT_LOCALE = "en-NG"; + +/** Currency every price the API returns is denominated in. */ +export const DEFAULT_CURRENCY = "NGN"; + +/** Minor units per major unit: 100 kobo make one naira. */ +export const MINOR_UNITS_PER_MAJOR = 100; + +export interface NairaFormatOptions { + /** BCP-47 locale tag. Defaults to {@link DEFAULT_LOCALE}. */ + locale?: string; + /** ISO 4217 currency code. Defaults to {@link DEFAULT_CURRENCY}. */ + currency?: string; + /** + * `standard` (the default) renders the exact amount, `compact` renders + * summary figures such as `₦1.5M` for dashboard cards. + */ + notation?: "standard" | "compact"; +} + +/** Reads the locale's own decimal separator instead of assuming a `.`. */ +function decimalSeparatorFor(locale: string): string { + return ( + new Intl.NumberFormat(locale) + .formatToParts(1.1) + .find((part) => part.type === "decimal")?.value ?? "." + ); +} + +/** + * Formats an integer kobo amount as naira, e.g. `123456` renders `₦1,234.56`. + * + * This is the single formatter for money in the app: amounts stay in kobo + * (integers) until the very last moment, so nothing is rounded or scaled twice + * on the way to the screen. + * + * Kobo are split from the whole naira with integer arithmetic before the value + * is formatted. Dividing first (`kobo / 100`) loses precision once the amount + * passes `Number.MAX_SAFE_INTEGER / 100`, which renders some large amounts a + * kobo short; splitting keeps zero, fractional and very large amounts exact. + * Non-integer kobo are rounded to the nearest kobo, since kobo is the smallest + * unit the ledger carries. + */ +export function formatNaira( + kobo: number, + options: NairaFormatOptions = {} +): string { + const { + locale = DEFAULT_LOCALE, + currency = DEFAULT_CURRENCY, + notation = "standard", + } = options; + + // Never render `NaN`/`Infinity` as a price. + const amount = Number.isFinite(kobo) ? kobo : 0; + + if (notation === "compact") { + // Compact figures are approximate by definition, so the naira value can be + // handed to Intl directly without losing anything that is still readable. + return new Intl.NumberFormat(locale, { + style: "currency", + currency, + notation: "compact", + maximumFractionDigits: 1, + }).format(amount / MINOR_UNITS_PER_MAJOR); + } + + const negative = amount < 0; + const absolute = Math.abs(amount); + const wholeNaira = Math.floor(absolute / MINOR_UNITS_PER_MAJOR); + const koboRemainder = Math.round( + absolute - wholeNaira * MINOR_UNITS_PER_MAJOR + ); + + const formatter = new Intl.NumberFormat(locale, { + style: "currency", + currency, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }); + + // Formatting the whole naira with no fraction digits keeps grouping, symbol + // placement and sign placement in the locale's hands; the kobo digits are + // then appended to the integer part so they are never rounded away. + const parts = formatter.formatToParts(negative ? -wholeNaira : wholeNaira); + const fraction = String(koboRemainder).padStart(2, "0"); + const decimalSeparator = decimalSeparatorFor(locale); + + let lastIntegerIndex = -1; + parts.forEach((part, index) => { + if (part.type === "integer") lastIntegerIndex = index; + }); + + return parts + .map((part, index) => + index === lastIntegerIndex + ? `${part.value}${decimalSeparator}${fraction}` + : part.value + ) + .join(""); +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c7cd5a0f..52b88ec0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -32,6 +32,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@playwright/test": "^1.64.1", "@tailwindcss/postcss": "^4", "@types/js-cookie": "^3.0.6", "@types/node": "^20", @@ -1558,6 +1559,22 @@ "node": ">=12.4.0" } }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -6923,6 +6940,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 95278ede..9d7e3bea 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,8 @@ "build": "next build --turbopack", "start": "next start", "lint": "eslint", - "test": "vitest run" + "test": "vitest run", + "test:e2e": "playwright test" }, "dependencies": { "@hookform/resolvers": "^5.2.2", @@ -34,6 +35,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@playwright/test": "^1.64.1", "@tailwindcss/postcss": "^4", "@types/js-cookie": "^3.0.6", "@types/node": "^20", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000..81e02813 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,39 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * End-to-end configuration for the browser journeys in `frontend/e2e`. + * + * The suite boots the real Next.js app and answers every API call with a mocked + * response, so the journeys exercise the client's own routing, state and + * rendering rather than a stub server. + */ +const PORT = Number(process.env.E2E_PORT ?? 3100); +const BASE_URL = `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: "./e2e", + // The journeys drive one shared dev server and mock routes per page, so they + // run one at a time. + fullyParallel: false, + workers: 1, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: [["list"]], + timeout: 60_000, + expect: { timeout: 10_000 }, + use: { + baseURL: BASE_URL, + trace: "on-first-retry", + ...devices["Desktop Chrome"], + }, + webServer: { + command: `npx next dev --port ${PORT}`, + url: `${BASE_URL}/login`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + // Same-origin API so `page.route` can answer every call deterministically. + NEXT_PUBLIC_API_URL: `${BASE_URL}/api`, + }, + }, +});