From bcfe1f980bf2d9eb773e292fb7fc1462334b0b52 Mon Sep 17 00:00:00 2001 From: CaniceFavour Date: Fri, 25 Sep 2026 12:29:04 +0100 Subject: [PATCH 1/2] audit emitted contract events and their expected topic layouts --- contract/contracts/hello-world/src/lib.rs | 3 + .../src/tests/event_regression_test.rs | 903 ++++++++++++++++++ 2 files changed, 906 insertions(+) create mode 100644 contract/contracts/hello-world/src/tests/event_regression_test.rs diff --git a/contract/contracts/hello-world/src/lib.rs b/contract/contracts/hello-world/src/lib.rs index 01652d5b..33764a8a 100644 --- a/contract/contracts/hello-world/src/lib.rs +++ b/contract/contracts/hello-world/src/lib.rs @@ -256,4 +256,7 @@ mod tests { #[path = "../tests/test_utils_test.rs"] mod test_utils_test; + + #[path = "../tests/event_regression_test.rs"] + mod event_regression_test; } diff --git a/contract/contracts/hello-world/src/tests/event_regression_test.rs b/contract/contracts/hello-world/src/tests/event_regression_test.rs new file mode 100644 index 00000000..9f9242f6 --- /dev/null +++ b/contract/contracts/hello-world/src/tests/event_regression_test.rs @@ -0,0 +1,903 @@ +/// Event Regression Tests +/// +/// This module provides comprehensive regression testing for all smart contract events +/// to ensure strict ABI stability. These tests validate: +/// - Event names (symbols) +/// - Topic count and ordering +/// - Topic values (addresses, IDs, etc.) +/// - Event payload data structures +/// +/// Any breaking change to event structure will cause these tests to fail, +/// preventing silent breakage of downstream consumers and indexers. + +use crate::base::types::GroupMember; +use crate::test_utils::{create_test_group, mint_tokens, setup_test_env}; +use crate::{AutoShareContract, AutoShareContractClient}; +use soroban_sdk::{ + testutils::{Address as _, Events}, + vec, Address, BytesN, Env, IntoVal, String, Symbol, Val, Vec, +}; + +// ============================================================================ +// Event Name Constants +// These constants represent the canonical event names that must never change +// ============================================================================ + +const EVENT_AUTOSHARE_CREATED: &str = "AutoshareCreated"; +const EVENT_CONTRACT_PAUSED: &str = "ContractPaused"; +const EVENT_CONTRACT_UNPAUSED: &str = "ContractUnpaused"; +const EVENT_AUTOSHARE_UPDATED: &str = "AutoshareUpdated"; +const EVENT_GROUP_DEACTIVATED: &str = "GroupDeactivated"; +const EVENT_GROUP_ACTIVATED: &str = "GroupActivated"; +const EVENT_ADMIN_TRANSFERRED: &str = "AdminTransferred"; +const EVENT_WITHDRAWAL: &str = "Withdrawal"; + +// ============================================================================ +// Helper Functions for Event Inspection +// ============================================================================ + +/// Extract all events emitted during a test and return them for inspection +fn get_all_events(env: &Env) -> soroban_sdk::vec::Vec<(Address, (Vec, Val), Val)> { + env.events().all() +} + +/// Find the most recent event with the given name +fn find_latest_event_by_name( + env: &Env, + contract_id: &Address, + event_name: &str, +) -> Option<(Vec, Val)> { + let events = env.events().all(); + + // Iterate in reverse to find the latest event + for i in (0..events.len()).rev() { + let event = events.get(i).unwrap(); + + // event.0 = contract address + // event.1 = topics (Vec) + // event.2 = data + if &event.0 == contract_id { + let topics = &event.1.0; + if topics.len() > 0 { + // Topic 0 should be the event name + let topic0_symbol: Result = topics.get(0).unwrap().try_into_val(env); + if let Ok(sym) = topic0_symbol { + let sym_str = sym.to_string(); + if sym_str == event_name { + return Some(event.1.clone()); + } + } + } + } + } + None +} + +/// Assert that a specific event was emitted with exact topic structure +fn assert_event_emitted( + env: &Env, + contract_id: &Address, + expected_event_name: &str, + expected_topic_count: usize, +) { + let event = find_latest_event_by_name(env, contract_id, expected_event_name); + assert!( + event.is_some(), + "Event '{}' was not emitted", + expected_event_name + ); + + let (topics, _) = event.unwrap(); + assert_eq!( + topics.len(), + expected_topic_count, + "Event '{}' has incorrect topic count. Expected {}, got {}", + expected_event_name, + expected_topic_count, + topics.len() + ); +} + +/// Extract and validate the event name from topic 0 +fn assert_event_name(env: &Env, topics: &Vec, expected_name: &str) { + assert!( + topics.len() > 0, + "Event has no topics (expected at least event name)" + ); + + let topic0: Symbol = topics + .get(0) + .unwrap() + .try_into_val(env) + .expect("Topic 0 should be a Symbol (event name)"); + + let actual_name = topic0.to_string(); + assert_eq!( + actual_name, expected_name, + "Event name mismatch. Expected '{}', got '{}'", + expected_name, actual_name + ); +} + +/// Extract an Address from a specific topic index +fn get_address_from_topic(env: &Env, topics: &Vec, index: u32) -> Address { + let topic_val = topics + .get(index) + .expect(&format!("Topic {} does not exist", index)); + + topic_val + .try_into_val(env) + .expect(&format!("Topic {} is not an Address", index)) +} + +/// Extract a BytesN<32> from event data +fn get_bytes32_from_data(env: &Env, data: &Val) -> BytesN<32> { + data.try_into_val(env) + .expect("Event data should be BytesN<32>") +} + +/// Extract an i128 from event data +fn get_i128_from_data(env: &Env, data: &Val) -> i128 { + data.try_into_val(env) + .expect("Event data should be i128") +} + +// ============================================================================ +// AutoshareCreated Event Tests +// ============================================================================ + +#[test] +fn test_autoshare_created_event_structure() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let creator = test_env.users.get(0).unwrap().clone(); + let token = test_env.mock_tokens.get(0).unwrap().clone(); + + // Mint tokens for the creator + mint_tokens(&test_env.env, &token, &creator, 10000); + + // Create a group + let id = BytesN::from_array(&test_env.env, &[1u8; 32]); + let name = String::from_str(&test_env.env, "Test Group"); + client.create(&id, &name, &creator, &1u32, &token); + + // Validate event was emitted with correct structure + assert_event_emitted( + &test_env.env, + &test_env.autoshare_contract, + EVENT_AUTOSHARE_CREATED, + 2, // Topic 0: event name, Topic 1: creator address + ); + + let event = find_latest_event_by_name( + &test_env.env, + &test_env.autoshare_contract, + EVENT_AUTOSHARE_CREATED, + ) + .unwrap(); + + let (topics, data) = event; + + // Assert Topic 0: Event name + assert_event_name(&test_env.env, &topics, EVENT_AUTOSHARE_CREATED); + + // Assert Topic 1: Creator address + let event_creator = get_address_from_topic(&test_env.env, &topics, 1); + assert_eq!( + event_creator, creator, + "AutoshareCreated event topic 1 (creator) mismatch" + ); + + // Assert Data: Group ID + let event_id: BytesN<32> = get_bytes32_from_data(&test_env.env, &data); + assert_eq!( + event_id, id, + "AutoshareCreated event data (id) mismatch" + ); +} + +#[test] +fn test_autoshare_created_event_topic_ordering() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let creator = test_env.users.get(0).unwrap().clone(); + let token = test_env.mock_tokens.get(0).unwrap().clone(); + mint_tokens(&test_env.env, &token, &creator, 10000); + + let id = BytesN::from_array(&test_env.env, &[2u8; 32]); + let name = String::from_str(&test_env.env, "Ordering Test"); + client.create(&id, &name, &creator, &1u32, &token); + + let event = find_latest_event_by_name( + &test_env.env, + &test_env.autoshare_contract, + EVENT_AUTOSHARE_CREATED, + ) + .expect("AutoshareCreated event should be emitted"); + + let (topics, _) = event; + + // Strict ordering check: Topic 0 = event name, Topic 1 = creator + assert_eq!( + topics.len(), + 2, + "AutoshareCreated must have exactly 2 topics" + ); + + // Verify topic 0 is a Symbol (event name) + let _: Symbol = topics + .get(0) + .unwrap() + .try_into_val(&test_env.env) + .expect("Topic 0 must be a Symbol (event name)"); + + // Verify topic 1 is an Address (creator) + let _: Address = topics + .get(1) + .unwrap() + .try_into_val(&test_env.env) + .expect("Topic 1 must be an Address (creator)"); +} + +// ============================================================================ +// ContractPaused Event Tests +// ============================================================================ + +#[test] +fn test_contract_paused_event_structure() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + // Pause the contract + client.pause(&test_env.admin); + + // Validate event was emitted with correct structure + assert_event_emitted( + &test_env.env, + &test_env.autoshare_contract, + EVENT_CONTRACT_PAUSED, + 1, // Only topic 0: event name (no additional topics) + ); + + let event = find_latest_event_by_name( + &test_env.env, + &test_env.autoshare_contract, + EVENT_CONTRACT_PAUSED, + ) + .unwrap(); + + let (topics, _) = event; + + // Assert Topic 0: Event name + assert_event_name(&test_env.env, &topics, EVENT_CONTRACT_PAUSED); + + // Assert no additional topics + assert_eq!( + topics.len(), + 1, + "ContractPaused should only have event name topic" + ); +} + +#[test] +fn test_contract_paused_event_no_data_payload() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + client.pause(&test_env.admin); + + let event = find_latest_event_by_name( + &test_env.env, + &test_env.autoshare_contract, + EVENT_CONTRACT_PAUSED, + ) + .expect("ContractPaused event should be emitted"); + + let (_, data) = event; + + // ContractPaused has no data payload (empty struct) + // Verify that data is the unit type () + let _: () = data + .try_into_val(&test_env.env) + .expect("ContractPaused data should be unit type ()"); +} + +// ============================================================================ +// ContractUnpaused Event Tests +// ============================================================================ + +#[test] +fn test_contract_unpaused_event_structure() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + // Pause and then unpause + client.pause(&test_env.admin); + client.unpause(&test_env.admin); + + // Validate event was emitted with correct structure + assert_event_emitted( + &test_env.env, + &test_env.autoshare_contract, + EVENT_CONTRACT_UNPAUSED, + 1, // Only topic 0: event name + ); + + let event = find_latest_event_by_name( + &test_env.env, + &test_env.autoshare_contract, + EVENT_CONTRACT_UNPAUSED, + ) + .unwrap(); + + let (topics, _) = event; + + // Assert Topic 0: Event name + assert_event_name(&test_env.env, &topics, EVENT_CONTRACT_UNPAUSED); + + // Assert no additional topics + assert_eq!( + topics.len(), + 1, + "ContractUnpaused should only have event name topic" + ); +} + +// ============================================================================ +// AutoshareUpdated Event Tests +// ============================================================================ + +#[test] +fn test_autoshare_updated_event_structure() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let creator = test_env.users.get(0).unwrap().clone(); + let token = test_env.mock_tokens.get(0).unwrap().clone(); + + // Create a group + let id = BytesN::from_array(&test_env.env, &[3u8; 32]); + let name = String::from_str(&test_env.env, "Update Test"); + mint_tokens(&test_env.env, &token, &creator, 10000); + client.create(&id, &name, &creator, &1u32, &token); + + // Update members + let mut new_members = Vec::new(&test_env.env); + new_members.push_back(GroupMember { + address: Address::generate(&test_env.env), + percentage: 100, + }); + client.update_members(&id, &creator, &new_members); + + // Validate event was emitted with correct structure + assert_event_emitted( + &test_env.env, + &test_env.autoshare_contract, + EVENT_AUTOSHARE_UPDATED, + 2, // Topic 0: event name, Topic 1: updater address + ); + + let event = find_latest_event_by_name( + &test_env.env, + &test_env.autoshare_contract, + EVENT_AUTOSHARE_UPDATED, + ) + .unwrap(); + + let (topics, data) = event; + + // Assert Topic 0: Event name + assert_event_name(&test_env.env, &topics, EVENT_AUTOSHARE_UPDATED); + + // Assert Topic 1: Updater address + let event_updater = get_address_from_topic(&test_env.env, &topics, 1); + assert_eq!( + event_updater, creator, + "AutoshareUpdated event topic 1 (updater) mismatch" + ); + + // Assert Data: Group ID + let event_id: BytesN<32> = get_bytes32_from_data(&test_env.env, &data); + assert_eq!( + event_id, id, + "AutoshareUpdated event data (id) mismatch" + ); +} + +// ============================================================================ +// GroupDeactivated Event Tests +// ============================================================================ + +#[test] +fn test_group_deactivated_event_structure() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let creator = test_env.users.get(0).unwrap().clone(); + let token = test_env.mock_tokens.get(0).unwrap().clone(); + + // Create and setup a group + let mut members = Vec::new(&test_env.env); + members.push_back(GroupMember { + address: Address::generate(&test_env.env), + percentage: 100, + }); + + let id = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &members, + 1, + &token, + ); + + // Deactivate the group + client.deactivate_group(&id, &creator); + + // Validate event was emitted with correct structure + assert_event_emitted( + &test_env.env, + &test_env.autoshare_contract, + EVENT_GROUP_DEACTIVATED, + 2, // Topic 0: event name, Topic 1: creator address + ); + + let event = find_latest_event_by_name( + &test_env.env, + &test_env.autoshare_contract, + EVENT_GROUP_DEACTIVATED, + ) + .unwrap(); + + let (topics, data) = event; + + // Assert Topic 0: Event name + assert_event_name(&test_env.env, &topics, EVENT_GROUP_DEACTIVATED); + + // Assert Topic 1: Creator address + let event_creator = get_address_from_topic(&test_env.env, &topics, 1); + assert_eq!( + event_creator, creator, + "GroupDeactivated event topic 1 (creator) mismatch" + ); + + // Assert Data: Group ID + let event_id: BytesN<32> = get_bytes32_from_data(&test_env.env, &data); + assert_eq!( + event_id, id, + "GroupDeactivated event data (id) mismatch" + ); +} + +// ============================================================================ +// GroupActivated Event Tests +// ============================================================================ + +#[test] +fn test_group_activated_event_structure() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let creator = test_env.users.get(0).unwrap().clone(); + let token = test_env.mock_tokens.get(0).unwrap().clone(); + + // Create and setup a group + let mut members = Vec::new(&test_env.env); + members.push_back(GroupMember { + address: Address::generate(&test_env.env), + percentage: 100, + }); + + let id = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &members, + 1, + &token, + ); + + // Deactivate first, then activate + client.deactivate_group(&id, &creator); + client.activate_group(&id, &creator); + + // Validate event was emitted with correct structure + assert_event_emitted( + &test_env.env, + &test_env.autoshare_contract, + EVENT_GROUP_ACTIVATED, + 2, // Topic 0: event name, Topic 1: creator address + ); + + let event = find_latest_event_by_name( + &test_env.env, + &test_env.autoshare_contract, + EVENT_GROUP_ACTIVATED, + ) + .unwrap(); + + let (topics, data) = event; + + // Assert Topic 0: Event name + assert_event_name(&test_env.env, &topics, EVENT_GROUP_ACTIVATED); + + // Assert Topic 1: Creator address + let event_creator = get_address_from_topic(&test_env.env, &topics, 1); + assert_eq!( + event_creator, creator, + "GroupActivated event topic 1 (creator) mismatch" + ); + + // Assert Data: Group ID + let event_id: BytesN<32> = get_bytes32_from_data(&test_env.env, &data); + assert_eq!( + event_id, id, + "GroupActivated event data (id) mismatch" + ); +} + +// ============================================================================ +// AdminTransferred Event Tests +// ============================================================================ + +#[test] +fn test_admin_transferred_event_structure() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let new_admin = Address::generate(&test_env.env); + + // Transfer admin + client.transfer_admin(&test_env.admin, &new_admin); + + // Validate event was emitted with correct structure + assert_event_emitted( + &test_env.env, + &test_env.autoshare_contract, + EVENT_ADMIN_TRANSFERRED, + 2, // Topic 0: event name, Topic 1: old_admin address + ); + + let event = find_latest_event_by_name( + &test_env.env, + &test_env.autoshare_contract, + EVENT_ADMIN_TRANSFERRED, + ) + .unwrap(); + + let (topics, data) = event; + + // Assert Topic 0: Event name + assert_event_name(&test_env.env, &topics, EVENT_ADMIN_TRANSFERRED); + + // Assert Topic 1: Old admin address + let event_old_admin = get_address_from_topic(&test_env.env, &topics, 1); + assert_eq!( + event_old_admin, test_env.admin, + "AdminTransferred event topic 1 (old_admin) mismatch" + ); + + // Assert Data: New admin address + let event_new_admin: Address = data + .try_into_val(&test_env.env) + .expect("AdminTransferred data should be Address (new_admin)"); + assert_eq!( + event_new_admin, new_admin, + "AdminTransferred event data (new_admin) mismatch" + ); +} + +#[test] +fn test_admin_transferred_event_topic_ordering() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let new_admin = Address::generate(&test_env.env); + client.transfer_admin(&test_env.admin, &new_admin); + + let event = find_latest_event_by_name( + &test_env.env, + &test_env.autoshare_contract, + EVENT_ADMIN_TRANSFERRED, + ) + .expect("AdminTransferred event should be emitted"); + + let (topics, _) = event; + + // Strict ordering check + assert_eq!( + topics.len(), + 2, + "AdminTransferred must have exactly 2 topics" + ); + + // Topic 0 must be Symbol (event name) + let _: Symbol = topics + .get(0) + .unwrap() + .try_into_val(&test_env.env) + .expect("Topic 0 must be Symbol"); + + // Topic 1 must be Address (old_admin) + let _: Address = topics + .get(1) + .unwrap() + .try_into_val(&test_env.env) + .expect("Topic 1 must be Address (old_admin)"); +} + +// ============================================================================ +// Withdrawal Event Tests +// ============================================================================ + +#[test] +fn test_withdrawal_event_structure() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let token = test_env.mock_tokens.get(0).unwrap().clone(); + let recipient = Address::generate(&test_env.env); + + // Fund the contract first + let creator = test_env.users.get(0).unwrap().clone(); + let mut members = Vec::new(&test_env.env); + members.push_back(GroupMember { + address: Address::generate(&test_env.env), + percentage: 100, + }); + + create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &members, + 10, + &token, + ); + + // Withdraw + let withdraw_amount = 50i128; + client.withdraw(&test_env.admin, &token, &withdraw_amount, &recipient); + + // Validate event was emitted with correct structure + assert_event_emitted( + &test_env.env, + &test_env.autoshare_contract, + EVENT_WITHDRAWAL, + 3, // Topic 0: event name, Topic 1: token, Topic 2: recipient + ); + + let event = find_latest_event_by_name( + &test_env.env, + &test_env.autoshare_contract, + EVENT_WITHDRAWAL, + ) + .unwrap(); + + let (topics, data) = event; + + // Assert Topic 0: Event name + assert_event_name(&test_env.env, &topics, EVENT_WITHDRAWAL); + + // Assert Topic 1: Token address + let event_token = get_address_from_topic(&test_env.env, &topics, 1); + assert_eq!( + event_token, token, + "Withdrawal event topic 1 (token) mismatch" + ); + + // Assert Topic 2: Recipient address + let event_recipient = get_address_from_topic(&test_env.env, &topics, 2); + assert_eq!( + event_recipient, recipient, + "Withdrawal event topic 2 (recipient) mismatch" + ); + + // Assert Data: Amount + let event_amount: i128 = get_i128_from_data(&test_env.env, &data); + assert_eq!( + event_amount, withdraw_amount, + "Withdrawal event data (amount) mismatch" + ); +} + +#[test] +fn test_withdrawal_event_topic_ordering() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let token = test_env.mock_tokens.get(0).unwrap().clone(); + let recipient = Address::generate(&test_env.env); + + // Fund the contract + let creator = test_env.users.get(0).unwrap().clone(); + let mut members = Vec::new(&test_env.env); + members.push_back(GroupMember { + address: Address::generate(&test_env.env), + percentage: 100, + }); + + create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &members, + 10, + &token, + ); + + client.withdraw(&test_env.admin, &token, &50i128, &recipient); + + let event = find_latest_event_by_name( + &test_env.env, + &test_env.autoshare_contract, + EVENT_WITHDRAWAL, + ) + .expect("Withdrawal event should be emitted"); + + let (topics, _) = event; + + // Strict ordering check + assert_eq!( + topics.len(), + 3, + "Withdrawal must have exactly 3 topics" + ); + + // Topic 0: Symbol (event name) + let _: Symbol = topics + .get(0) + .unwrap() + .try_into_val(&test_env.env) + .expect("Topic 0 must be Symbol"); + + // Topic 1: Address (token) + let _: Address = topics + .get(1) + .unwrap() + .try_into_val(&test_env.env) + .expect("Topic 1 must be Address (token)"); + + // Topic 2: Address (recipient) + let _: Address = topics + .get(2) + .unwrap() + .try_into_val(&test_env.env) + .expect("Topic 2 must be Address (recipient)"); +} + +// ============================================================================ +// Cross-Event Validation Tests +// ============================================================================ + +#[test] +fn test_multiple_events_in_sequence() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let creator = test_env.users.get(0).unwrap().clone(); + let token = test_env.mock_tokens.get(0).unwrap().clone(); + + // Create a group (AutoshareCreated event) + let mut members = Vec::new(&test_env.env); + members.push_back(GroupMember { + address: Address::generate(&test_env.env), + percentage: 100, + }); + + let id = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &members, + 1, + &token, + ); + + // Deactivate the group (GroupDeactivated event) + client.deactivate_group(&id, &creator); + + // Activate the group (GroupActivated event) + client.activate_group(&id, &creator); + + // Verify all three events exist with correct names + let all_events = get_all_events(&test_env.env); + let mut event_names = Vec::new(&test_env.env); + + for i in 0..all_events.len() { + let event = all_events.get(i).unwrap(); + if &event.0 == &test_env.autoshare_contract { + let topics = &event.1.0; + if topics.len() > 0 { + if let Ok(sym) = topics.get(0).unwrap().try_into_val::(&test_env.env) { + event_names.push_back(sym.to_string()); + } + } + } + } + + // Should contain at least AutoshareCreated, GroupDeactivated, GroupActivated + let event_names_str: std::vec::Vec = (0..event_names.len()) + .map(|i| event_names.get(i).unwrap().to_string()) + .collect(); + + assert!( + event_names_str.contains(&EVENT_AUTOSHARE_CREATED.to_string()), + "Missing AutoshareCreated event" + ); + assert!( + event_names_str.contains(&EVENT_GROUP_DEACTIVATED.to_string()), + "Missing GroupDeactivated event" + ); + assert!( + event_names_str.contains(&EVENT_GROUP_ACTIVATED.to_string()), + "Missing GroupActivated event" + ); +} + +#[test] +fn test_event_names_are_stable() { + // This test ensures that event names remain unchanged + // Any change to these constant values indicates a breaking change + + assert_eq!(EVENT_AUTOSHARE_CREATED, "AutoshareCreated"); + assert_eq!(EVENT_CONTRACT_PAUSED, "ContractPaused"); + assert_eq!(EVENT_CONTRACT_UNPAUSED, "ContractUnpaused"); + assert_eq!(EVENT_AUTOSHARE_UPDATED, "AutoshareUpdated"); + assert_eq!(EVENT_GROUP_DEACTIVATED, "GroupDeactivated"); + assert_eq!(EVENT_GROUP_ACTIVATED, "GroupActivated"); + assert_eq!(EVENT_ADMIN_TRANSFERRED, "AdminTransferred"); + assert_eq!(EVENT_WITHDRAWAL, "Withdrawal"); +} + +// ============================================================================ +// Negative Tests: Ensuring Events Are NOT Emitted When They Shouldn't Be +// ============================================================================ + +#[test] +fn test_no_event_on_read_operations() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let creator = test_env.users.get(0).unwrap().clone(); + let token = test_env.mock_tokens.get(0).unwrap().clone(); + + // Create a group + let mut members = Vec::new(&test_env.env); + members.push_back(GroupMember { + address: Address::generate(&test_env.env), + percentage: 100, + }); + + let id = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &members, + 1, + &token, + ); + + // Count events before read operations + let events_before = get_all_events(&test_env.env).len(); + + // Perform read operations + let _ = client.get(&id); + let _ = client.get_all_groups(); + let _ = client.get_groups_by_creator(&creator); + let _ = client.is_group_member(&id, &creator); + let _ = client.is_group_active(&id); + let _ = client.get_usage_fee(); + + // Count events after read operations + let events_after = get_all_events(&test_env.env).len(); + + // No new events should be emitted for read operations + assert_eq!( + events_before, events_after, + "Read operations should not emit events" + ); +} From 8083c956d1e76abad745b9d01f20a7bb39995a58 Mon Sep 17 00:00:00 2001 From: CaniceFavour Date: Fri, 25 Sep 2026 13:28:36 +0100 Subject: [PATCH 2/2] add automated config drift detection --- .config-audit-ignore | 51 +++ .github/workflows/config-lint.yml | 84 +++++ Makefile | 31 ++ README.md | 27 ++ docs/CONFIG_DRIFT_DETECTION.md | 595 ++++++++++++++++++++++++++++++ package.json | 20 + scripts/README.md | 159 ++++++++ scripts/check-unused-config.mjs | 553 +++++++++++++++++++++++++++ 8 files changed, 1520 insertions(+) create mode 100644 .config-audit-ignore create mode 100644 .github/workflows/config-lint.yml create mode 100644 Makefile create mode 100644 docs/CONFIG_DRIFT_DETECTION.md create mode 100644 package.json create mode 100644 scripts/README.md create mode 100644 scripts/check-unused-config.mjs diff --git a/.config-audit-ignore b/.config-audit-ignore new file mode 100644 index 00000000..f79ad0f4 --- /dev/null +++ b/.config-audit-ignore @@ -0,0 +1,51 @@ +# Configuration Audit Allowlist +# +# This file contains environment variables that are documented but not directly +# referenced in application code. Each variable should have a comment explaining +# why it's exempt from the unused configuration check. +# +# Format: +# # Reason for exemption +# VARIABLE_NAME +# +# Example: +# # Used by Docker Compose at runtime +# DATABASE_URL +# + +# Vite environment variables are accessed through import.meta.env and may not +# be directly referenced in source code but are used by the build process +# VITE_STELLAR_NETWORK +# VITE_EVENTS_API_URL + +# Node.js runtime environment variable +# Used by logger and runtime detection, not always directly referenced +NODE_ENV + +# Logging configuration +# Used by Winston logger configuration +LOG_LEVEL + +# ============================================================================ +# Task Bounty Contract Deployment Variables +# These are used by deployment scripts and hardhat/truffle configuration +# They are not referenced in smart contract source code +# ============================================================================ + +# Private key for contract deployment (used by deployment tools) +PRIVATE_KEY + +# Ethereum RPC URLs for different networks (used by hardhat/truffle) +MAINNET_RPC_URL +SEPOLIA_RPC_URL + +# Etherscan API key for contract verification (used by hardhat-etherscan plugin) +ETHERSCAN_API_KEY + +# Deployed contract addresses (documented for reference, not consumed by code) +BOUNTY_ADDRESS +RESOLVER_ADDRESS +FACTORY_ADDRESS + +# Contract administrator address (used by deployment scripts) +ARBITRATOR diff --git a/.github/workflows/config-lint.yml b/.github/workflows/config-lint.yml new file mode 100644 index 00000000..997dda3d --- /dev/null +++ b/.github/workflows/config-lint.yml @@ -0,0 +1,84 @@ +name: Configuration Drift Detection + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +jobs: + config-drift: + name: Check for Unused Configuration Variables + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Run configuration drift detection + id: config-check + run: | + echo "Running configuration drift detection..." + npm run lint:config + continue-on-error: true + + - name: Upload results as artifact + if: always() + uses: actions/upload-artifact@v3 + with: + name: config-drift-report + path: | + .config-audit-ignore + scripts/check-unused-config.mjs + retention-days: 30 + + - name: Comment on PR (if drift detected) + if: failure() && github.event_name == 'pull_request' + uses: actions/github-script@v6 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `## ⚠️ Configuration Drift Detected + + The configuration drift detection tool found environment variables documented in \`.env.example\` files that are not referenced in the codebase. + + ### Action Required + + 1. **Review the unreferenced variables** in the check output above + 2. **Either:** + - Remove unused variables from \`.env.example\` files + - OR add them to \`.config-audit-ignore\` with a clear reason + + ### How to Fix + + \`\`\`bash + # Run locally to see details + npm run lint:config + + # Add to allowlist if legitimately used externally + echo "# Reason for exemption" >> .config-audit-ignore + echo "VARIABLE_NAME" >> .config-audit-ignore + \`\`\` + + 📚 [Configuration Drift Detection Documentation](docs/CONFIG_DRIFT_DETECTION.md)` + }) + + - name: Fail workflow if drift detected + if: steps.config-check.outcome == 'failure' + run: | + echo "❌ Configuration drift detected!" + echo "See logs above for details or check the documentation:" + echo "docs/CONFIG_DRIFT_DETECTION.md" + exit 1 diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..9d13979b --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +.PHONY: help lint-config lint-config-verbose test-lint-config + +help: + @echo "NotifyChain - Available Commands" + @echo "" + @echo " make lint-config - Check for unused configuration variables" + @echo " make lint-config-verbose - Check with detailed usage information" + @echo " make test-lint-config - Test the config linter with a mock unused variable" + @echo "" + +lint-config: + @node scripts/check-unused-config.mjs + +lint-config-verbose: + @VERBOSE=true node scripts/check-unused-config.mjs + +# Test the linter by temporarily adding an unused variable +test-lint-config: + @echo "Testing configuration drift detection..." + @echo "" + @echo "# Test unused variable" >> listener/.env.example + @echo "MOCK_UNUSED_VARIABLE=test" >> listener/.env.example + @echo "Added MOCK_UNUSED_VARIABLE to listener/.env.example" + @echo "" + @node scripts/check-unused-config.mjs || true + @echo "" + @echo "Cleaning up test variable..." + @grep -v "MOCK_UNUSED_VARIABLE" listener/.env.example > listener/.env.example.tmp || true + @grep -v "Test unused variable" listener/.env.example.tmp > listener/.env.example || true + @rm -f listener/.env.example.tmp + @echo "Test complete!" diff --git a/README.md b/README.md index f323d7de..4884f8fb 100644 --- a/README.md +++ b/README.md @@ -394,6 +394,28 @@ Add this to `.vscode/settings.json`: --- +## Developer Tools + +### Configuration Drift Detection + +NotifyChain includes an automated configuration drift detection tool that identifies environment variables documented in `.env.example` files but no longer used in the codebase. + +**Usage:** +```bash +# Check for unused configuration variables +npm run lint:config + +# With verbose output +npm run lint:config:verbose + +# Using Make +make lint-config +``` + +**Documentation:** See [CONFIG_DRIFT_DETECTION.md](docs/CONFIG_DRIFT_DETECTION.md) + +--- + ## Contributing Contributions are welcome! Please follow these steps: @@ -406,6 +428,11 @@ Contributions are welcome! Please follow these steps: Please follow the project's coding standards and include tests where applicable. +**Before committing:** +- Run `npm run lint:config` to check for configuration drift +- Ensure all tests pass +- Update documentation as needed + For more detailed contribution guidelines, check: - `Documents/Task Bounty/CONTRIBUTING.md` diff --git a/docs/CONFIG_DRIFT_DETECTION.md b/docs/CONFIG_DRIFT_DETECTION.md new file mode 100644 index 00000000..f0b9d2c7 --- /dev/null +++ b/docs/CONFIG_DRIFT_DETECTION.md @@ -0,0 +1,595 @@ +# Configuration Drift Detection + +## Overview + +The Configuration Drift Detection tool automatically identifies environment variables and configuration keys that are documented in `.env.example` files or documentation but are no longer used anywhere in the application codebase. + +This prevents configuration bloat, reduces confusion for new developers, and ensures that documentation stays synchronized with actual code usage. + +--- + +## Table of Contents + +1. [Why This Matters](#why-this-matters) +2. [How It Works](#how-it-works) +3. [Usage](#usage) +4. [Configuration](#configuration) +5. [Allowlist Management](#allowlist-management) +6. [CI/CD Integration](#cicd-integration) +7. [Examples](#examples) +8. [Troubleshooting](#troubleshooting) + +--- + +## Why This Matters + +### Problems Solved + +1. **Dead Configuration**: Over time, refactoring leaves obsolete variables in `.env.example` files +2. **Developer Confusion**: New team members waste time configuring variables that aren't actually used +3. **Documentation Drift**: Config docs become outdated and misleading +4. **CI/CD Bloat**: Unnecessary secrets and variables in deployment pipelines +5. **Security Risk**: Orphaned credentials that should have been removed + +### Real-World Example + +```bash +# .env.example contains: +LEGACY_API_KEY=xxx +OLD_DATABASE_URL=xxx +DEPRECATED_FEATURE_FLAG=xxx + +# But code has been refactored and these are never referenced +# Result: Developers set these up for nothing +``` + +--- + +## How It Works + +### Step 1: Extract Documented Variables + +The tool scans configuration documentation sources: + +- **`.env.example` files**: Extracts all `VAR_NAME=value` patterns +- **Markdown documentation**: Finds variables in code blocks, tables, and inline code +- **Configuration schemas**: Parses config validation files + +### Step 2: Scan Codebase for Usage + +The tool searches for variable references across multiple languages and patterns: + +#### JavaScript/TypeScript +```typescript +process.env.VAR_NAME +process.env['VAR_NAME'] +import.meta.env.VAR_NAME +const { VAR_NAME } = process.env +``` + +#### Rust +```rust +env::var("VAR_NAME") +std::env::var("VAR_NAME") +``` + +#### Python +```python +os.environ["VAR_NAME"] +os.getenv("VAR_NAME") +``` + +#### Configuration Files +```yaml +database_url: ${DATABASE_URL} +api_key: $API_KEY +``` + +### Step 3: Report Unreferenced Variables + +Variables with **zero code references** and **not in allowlist** are flagged as drift. + +--- + +## Usage + +### Local Development + +```bash +# Using npm +npm run lint:config + +# Using make +make lint-config + +# With verbose output (shows usage examples) +npm run lint:config:verbose +make lint-config-verbose + +# Direct execution +node scripts/check-unused-config.mjs +``` + +### Exit Codes + +- **0**: All documented variables are used or allowlisted ✅ +- **1**: Unreferenced variables detected ❌ +- **2**: Fatal error (e.g., script failure) + +--- + +## Configuration + +The tool is configured in `scripts/check-unused-config.mjs`: + +```javascript +const CONFIG = { + // Directories to scan for usage + scanDirs: [ + 'listener/src', + 'dashboard/src', + 'contract/contracts', + 'Documents/Task Bounty/src' + ], + + // File extensions to check + scanExtensions: ['.ts', '.tsx', '.js', '.jsx', '.rs', '.toml'], + + // Documentation sources + envExampleFiles: [ + 'listener/.env.example', + 'Documents/Task Bounty/.env.example', + 'dashboard/.env.example' + ], + + // Ignore patterns + ignoreDirs: [ + 'node_modules', + 'dist', + 'build', + 'target', + '.git' + ] +}; +``` + +### Customization + +To add new scan locations, edit `CONFIG.scanDirs` in the script. + +--- + +## Allowlist Management + +### When to Allowlist + +Allowlist variables that are: + +1. **Runtime-only**: Used by Docker, deployment platforms, or shell scripts +2. **External dependencies**: Consumed by frameworks or libraries +3. **Build-time only**: Used during build process but not in source code +4. **Infrastructure**: Used by monitoring, logging, or deployment tools + +### Allowlist Format + +Edit `.config-audit-ignore`: + +``` +# Reason for exemption +VARIABLE_NAME + +# Another variable with a reason +ANOTHER_VAR + +# Example: Used by Docker Compose at container runtime +DATABASE_URL + +# Example: Vite build-time variable prefix +VITE_APP_TITLE +``` + +### Best Practices + +1. **Always document why**: Every allowlisted variable needs a comment +2. **Be specific**: Explain exactly where and how it's used +3. **Review regularly**: Periodically audit the allowlist +4. **Prefer code usage**: If possible, reference the variable in code instead + +### Example Allowlist + +``` +# Used by Docker Compose service configuration +DATABASE_URL + +# Consumed by Next.js at build time +NEXT_PUBLIC_API_URL + +# Used by GitHub Actions workflow +CI_DEPLOY_KEY + +# Winston logger reads this at runtime initialization +LOG_LEVEL + +# Node.js built-in environment detection +NODE_ENV +``` + +--- + +## CI/CD Integration + +### GitHub Actions + +Add to `.github/workflows/config-lint.yml`: + +```yaml +name: Configuration Lint + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + config-drift: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Check for unused configuration variables + run: npm run lint:config + + - name: Comment on PR if failed + if: failure() && github.event_name == 'pull_request' + uses: actions/github-script@v6 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '⚠️ Configuration drift detected! Please review unused variables.' + }) +``` + +### GitLab CI + +Add to `.gitlab-ci.yml`: + +```yaml +config-lint: + stage: test + image: node:18 + script: + - npm run lint:config + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + - if: '$CI_COMMIT_BRANCH == "main"' +``` + +### Pre-commit Hook + +Add to `.husky/pre-commit` or `.git/hooks/pre-commit`: + +```bash +#!/bin/sh +echo "Checking for configuration drift..." +npm run lint:config || { + echo "❌ Configuration drift detected. Fix before committing." + exit 1 +} +``` + +--- + +## Examples + +### Example 1: Clean Run + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Configuration Drift Detection Report +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +▶ Extracting Documented Configuration Variables +──────────────────────────────────────────────────────────────────────────────── + ✓ Found 8 variables in listener/.env.example + ✓ Found 0 variables in dashboard/.env.example + + Total unique variables: 8 + +▶ Scanning Codebase +──────────────────────────────────────────────────────────────────────────────── + ✓ Scanned 45 files in listener/src + ✓ Scanned 23 files in dashboard/src + + Total files to scan: 68 + +▶ Checking Variable Usage +──────────────────────────────────────────────────────────────────────────────── + Checking STELLAR_NETWORK... USED (2 references) + Checking STELLAR_RPC_URL... USED (2 references) + Checking CONTRACT_ADDRESSES... USED (3 references) + Checking POLL_INTERVAL_MS... USED (1 references) + ... + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Total documented variables: 8 + Variables with usage found: 8 + Variables allowlisted: 0 + Unreferenced variables: 0 + + ✓ PASSED: All documented variables are accounted for! +``` + +### Example 2: Drift Detected + +``` +▶ Checking Variable Usage +──────────────────────────────────────────────────────────────────────────────── + Checking STELLAR_NETWORK... USED (2 references) + Checking LEGACY_API_KEY... UNREFERENCED + Checking OLD_DATABASE_URL... UNREFERENCED + +▶ Unreferenced Variables (Not Allowlisted) +──────────────────────────────────────────────────────────────────────────────── + LEGACY_API_KEY + Source: listener/.env.example:15 + + OLD_DATABASE_URL + Source: listener/.env.example:18 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Total documented variables: 10 + Variables with usage found: 8 + Variables allowlisted: 0 + Unreferenced variables: 2 + + ⚠ FAILED: Unreferenced configuration variables detected! + + Action Required: + 1. Remove unused variables from .env.example files + 2. OR add them to .config-audit-ignore with a reason +``` + +### Example 3: With Allowlist + +``` +▶ Loading Allowlist +──────────────────────────────────────────────────────────────────────────────── + ✓ Loaded 2 allowlisted variables + - NODE_ENV: Used by runtime detection + - LOG_LEVEL: Used by Winston logger configuration + +▶ Checking Variable Usage +──────────────────────────────────────────────────────────────────────────────── + Checking NODE_ENV... ALLOWLISTED + Checking LOG_LEVEL... ALLOWLISTED + Checking STELLAR_NETWORK... USED (2 references) +``` + +--- + +## Troubleshooting + +### False Positives + +**Problem**: Variable is used but tool reports it as unreferenced. + +**Solutions**: + +1. **Check pattern matching**: Ensure the variable is accessed in a supported pattern +2. **Add to allowlist**: If it's consumed externally (Docker, etc.) +3. **Verify scan directories**: Make sure the file containing the usage is scanned +4. **Check string matching**: Variable names are case-sensitive + +### Variable Not Detected in .env.example + +**Problem**: Tool doesn't extract a variable from `.env.example`. + +**Solution**: Ensure the format is: + +```bash +# Good +VAR_NAME=value + +# Bad (will be missed) +var_name=value # lowercase not detected +# VAR_NAME=value # commented out, not extracted +VAR NAME=value # space in name, not valid +``` + +### Tool Crashes or Errors + +**Problem**: Script fails with an error. + +**Solutions**: + +1. **Check Node.js version**: Requires Node.js 14+ with ES modules support +2. **Verify file paths**: Ensure all paths in CONFIG are correct +3. **Check permissions**: Ensure read access to all scanned directories +4. **Review error stack**: Look for specific file or pattern causing issues + +### Performance Issues + +**Problem**: Tool is slow on large codebases. + +**Solutions**: + +1. **Limit scan directories**: Only include source directories, not build outputs +2. **Add to ignoreDirs**: Exclude `node_modules`, `dist`, `build`, `target` +3. **Reduce scan extensions**: Only include necessary file types +4. **Run in parallel**: Use `--parallel` flag if available + +--- + +## Advanced Usage + +### Custom Patterns + +To detect custom variable access patterns, edit the `findVariableUsage` function: + +```javascript +// Add your custom pattern +const customPattern = new RegExp(`myFramework\\.config\\(['"]${varName}['"]\\)`, 'g'); +patterns.push(customPattern); +``` + +### Multiple Allowlists + +For projects with multiple services, create service-specific allowlists: + +``` +.config-audit-ignore # Root allowlist +listener/.config-audit-ignore # Listener-specific +dashboard/.config-audit-ignore # Dashboard-specific +``` + +Then modify the script to load all of them. + +### Integration with Linters + +Add to `package.json`: + +```json +{ + "scripts": { + "lint": "npm run lint:eslint && npm run lint:config", + "lint:eslint": "eslint .", + "lint:config": "node scripts/check-unused-config.mjs" + } +} +``` + +--- + +## Best Practices + +### 1. Run Before Committing + +Add to pre-commit hook to catch drift early: + +```bash +npm run lint:config +``` + +### 2. Regular Audits + +Schedule periodic reviews: + +- Weekly: Check new variables added +- Monthly: Review allowlist for obsolete entries +- Quarterly: Full audit of all configuration + +### 3. Document New Variables + +When adding environment variables: + +1. Add to `.env.example` with clear comments +2. Document in README or config docs +3. Ensure code references the variable +4. Run `npm run lint:config` to verify + +### 4. Remove Before Refactoring + +When removing features: + +1. Delete the code +2. Remove from `.env.example` +3. Remove from documentation +4. Run `npm run lint:config` to confirm + +### 5. CI/CD Enforcement + +Make the check mandatory in CI: + +```yaml +- name: Config Lint + run: npm run lint:config + # Fail the build if unreferenced variables exist +``` + +--- + +## Testing + +### Manual Test + +Test the tool with a mock unused variable: + +```bash +# Add a test variable +echo "MOCK_UNUSED_VARIABLE=test" >> listener/.env.example + +# Run the check (should fail) +npm run lint:config + +# Clean up +grep -v "MOCK_UNUSED_VARIABLE" listener/.env.example > listener/.env.example.tmp +mv listener/.env.example.tmp listener/.env.example +``` + +### Automated Test + +Use the built-in test command: + +```bash +make test-lint-config +``` + +This will: +1. Add a mock unused variable +2. Run the checker (expecting failure) +3. Clean up the test variable +4. Report results + +--- + +## Contributing + +To improve the configuration drift detection tool: + +1. Fork the repository +2. Create a feature branch +3. Modify `scripts/check-unused-config.mjs` +4. Test your changes +5. Submit a pull request + +### Adding New Patterns + +To support additional languages or frameworks: + +1. Add pattern to `findVariableUsage()` function +2. Test with sample code +3. Document in this guide +4. Update examples + +--- + +## Support + +If you encounter issues: + +1. Check this documentation +2. Review examples above +3. Check GitHub issues +4. Open a new issue with details + +--- + +## License + +MIT License - See LICENSE file for details diff --git a/package.json b/package.json new file mode 100644 index 00000000..13da0c0c --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "notify-chain", + "version": "1.0.0", + "description": "NotifyChain - Event monitoring and notification system for smart contracts", + "private": true, + "type": "module", + "scripts": { + "lint:config": "node scripts/check-unused-config.mjs", + "lint:config:verbose": "VERBOSE=true node scripts/check-unused-config.mjs" + }, + "keywords": [ + "blockchain", + "stellar", + "soroban", + "events", + "notifications" + ], + "author": "", + "license": "MIT" +} diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 00000000..356d744e --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,159 @@ +# Scripts Directory + +This directory contains automation scripts and developer tools for the NotifyChain project. + +--- + +## Available Scripts + +### Configuration Drift Detection + +**Script:** `check-unused-config.mjs` + +**Purpose:** Automatically identifies environment variables documented in `.env.example` files that are no longer referenced in the application codebase. + +**Usage:** + +```bash +# Run the check +npm run lint:config + +# Run with verbose output +npm run lint:config:verbose + +# Direct execution +node scripts/check-unused-config.mjs + +# Using Make +make lint-config +make lint-config-verbose +``` + +**Exit Codes:** +- `0` - All documented variables are accounted for or allowlisted ✅ +- `1` - Unreferenced variables detected ❌ +- `2` - Fatal error (script failure) ⚠️ + +**Documentation:** See [CONFIG_DRIFT_DETECTION.md](../docs/CONFIG_DRIFT_DETECTION.md) + +--- + +## Quick Reference + +### Check Configuration + +```bash +npm run lint:config +``` + +### Test with Mock Variable + +```bash +# Add a test unused variable +echo "MOCK_TEST=value" >> listener/.env.example + +# Run check (should fail) +npm run lint:config + +# Clean up +# Remove the line manually or use sed/grep +``` + +### Add to Allowlist + +```bash +# Edit .config-audit-ignore +echo "# Reason: Used by deployment script" >> .config-audit-ignore +echo "DEPLOY_KEY" >> .config-audit-ignore +``` + +--- + +## Integration + +### Pre-commit Hook + +Add to `.husky/pre-commit`: + +```bash +npm run lint:config +``` + +### CI/CD + +GitHub Actions workflow is already configured in `.github/workflows/config-lint.yml`. + +--- + +## Troubleshooting + +### Variable Not Detected + +Ensure the variable follows this pattern in `.env.example`: + +```bash +VARIABLE_NAME=value +``` + +### False Positive + +Add to `.config-audit-ignore` with a reason: + +``` +# Used by Docker Compose +DOCKER_VAR +``` + +### Script Fails + +- Check Node.js version (requires 14+) +- Verify file paths in script CONFIG section +- Ensure read permissions on all directories + +--- + +## Contributing + +When adding new scripts: + +1. Place in `scripts/` directory +2. Make executable: `chmod +x scripts/script-name.sh` +3. Add to this README +4. Add to `package.json` scripts section +5. Document usage and purpose + +--- + +## Script Maintenance + +### Check for Updates + +```bash +# Review script dependencies +node --version # Should be 14+ + +# Check for syntax errors +node --check scripts/check-unused-config.mjs +``` + +### Performance + +- Scripts should complete in <10 seconds on typical repos +- Use `ignoreDirs` to skip unnecessary directories +- Limit `scanExtensions` to relevant file types + +--- + +## Support + +For issues or questions: + +1. Check documentation in `docs/` +2. Review examples in this README +3. Open an issue on GitHub + +--- + +## License + +MIT License - See LICENSE file for details diff --git a/scripts/check-unused-config.mjs b/scripts/check-unused-config.mjs new file mode 100644 index 00000000..70b1d76f --- /dev/null +++ b/scripts/check-unused-config.mjs @@ -0,0 +1,553 @@ +#!/usr/bin/env node + +/** + * Configuration Drift Detection Tool + * + * This tool identifies configuration variables documented in .env.example files + * or configuration documentation that are no longer referenced in the application codebase. + * + * Usage: + * node scripts/check-unused-config.mjs + * npm run lint:config + * + * Exit Codes: + * 0 - All documented variables are accounted for or allowlisted + * 1 - Unreferenced, non-allowlisted variables detected + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ROOT_DIR = path.resolve(__dirname, '..'); + +// ============================================================================ +// Configuration +// ============================================================================ + +const CONFIG = { + // Directories to scan for environment variable usage + scanDirs: [ + 'listener/src', + 'dashboard/src', + 'contract/contracts', + 'Documents/Task Bounty/src' + ], + + // File extensions to scan for usage + scanExtensions: ['.ts', '.tsx', '.js', '.jsx', '.rs', '.toml', '.json', '.yaml', '.yml'], + + // Files containing environment variable documentation + envExampleFiles: [ + 'listener/.env.example', + 'Documents/Task Bounty/.env.example', + 'dashboard/.env.example' + ], + + // Additional documentation files to parse + docFiles: [ + 'README.md', + 'listener/README.md', + 'dashboard/README.md' + ], + + // Allowlist file path + allowlistFile: '.config-audit-ignore', + + // Ignore directories + ignoreDirs: [ + 'node_modules', + 'dist', + 'build', + 'target', + '.git', + 'coverage', + '__tests__', + 'test', + 'tests' + ] +}; + +// ============================================================================ +// Color Output Utilities +// ============================================================================ + +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', + gray: '\x1b[90m', + bold: '\x1b[1m' +}; + +function colorize(text, color) { + return `${color}${text}${colors.reset}`; +} + +// ============================================================================ +// File System Utilities +// ============================================================================ + +function fileExists(filePath) { + try { + return fs.existsSync(path.resolve(ROOT_DIR, filePath)); + } catch { + return false; + } +} + +function readFile(filePath) { + try { + const fullPath = path.resolve(ROOT_DIR, filePath); + return fs.readFileSync(fullPath, 'utf-8'); + } catch (error) { + return null; + } +} + +function getAllFiles(dirPath, extensions, ignoreDirs) { + const files = []; + const fullDirPath = path.resolve(ROOT_DIR, dirPath); + + if (!fs.existsSync(fullDirPath)) { + return files; + } + + function traverse(currentPath) { + const entries = fs.readdirSync(currentPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(currentPath, entry.name); + const relativePath = path.relative(ROOT_DIR, fullPath); + + if (entry.isDirectory()) { + if (!ignoreDirs.includes(entry.name)) { + traverse(fullPath); + } + } else if (entry.isFile()) { + const ext = path.extname(entry.name); + if (extensions.includes(ext)) { + files.push(relativePath); + } + } + } + } + + traverse(fullDirPath); + return files; +} + +// ============================================================================ +// Environment Variable Extraction +// ============================================================================ + +/** + * Extract environment variable names from .env.example files + * Handles: + * - VAR_NAME=value + * - VAR_NAME = value + * - # comments + * - Empty lines + */ +function extractFromEnvExample(content, sourcePath) { + const variables = []; + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // Skip comments and empty lines + if (!line || line.startsWith('#')) { + continue; + } + + // Match VAR_NAME=... pattern + const match = line.match(/^([A-Z_][A-Z0-9_]*)\s*=/); + if (match) { + variables.push({ + name: match[1], + source: sourcePath, + line: i + 1 + }); + } + } + + return variables; +} + +/** + * Extract environment variables mentioned in documentation + * Looks for patterns like `VAR_NAME`, **VAR_NAME**, etc. + */ +function extractFromDocs(content, sourcePath) { + const variables = new Set(); + + // Match environment variable patterns in markdown + // Patterns: `VAR_NAME`, **VAR_NAME**, VAR_NAME in tables, etc. + const patterns = [ + /`([A-Z_][A-Z0-9_]+)`/g, + /\*\*([A-Z_][A-Z0-9_]+)\*\*/g, + /\|\s*([A-Z_][A-Z0-9_]+)\s*\|/g, + /^([A-Z_][A-Z0-9_]+):/gm + ]; + + for (const pattern of patterns) { + const matches = content.matchAll(pattern); + for (const match of matches) { + variables.add(match[1]); + } + } + + return Array.from(variables).map(name => ({ + name, + source: sourcePath, + line: 0 // Line numbers not tracked for doc files + })); +} + +// ============================================================================ +// Usage Detection +// ============================================================================ + +/** + * Check if a variable is used in the codebase + * Handles various access patterns: + * - process.env.VAR_NAME + * - process.env['VAR_NAME'] + * - process.env["VAR_NAME"] + * - import.meta.env.VAR_NAME + * - std::env::var("VAR_NAME") + * - os.environ["VAR_NAME"] + * - Destructured: const { VAR_NAME } = process.env + * - String literals: 'VAR_NAME', "VAR_NAME" in config contexts + */ +function findVariableUsage(varName, files) { + const usages = []; + + // Build regex patterns for different access methods + const patterns = [ + // JavaScript/TypeScript patterns + new RegExp(`process\\.env\\.${varName}\\b`, 'g'), + new RegExp(`process\\.env\\[['"]${varName}['"]\\]`, 'g'), + new RegExp(`import\\.meta\\.env\\.${varName}\\b`, 'g'), + new RegExp(`import\\.meta\\.env\\[['"]${varName}['"]\\]`, 'g'), + + // Destructuring patterns + new RegExp(`\\{\\s*${varName}\\s*\\}\\s*=\\s*process\\.env`, 'g'), + new RegExp(`\\{\\s*${varName}\\s*\\}\\s*=\\s*import\\.meta\\.env`, 'g'), + + // Rust patterns + new RegExp(`env::var\\(["']${varName}["']\\)`, 'g'), + new RegExp(`std::env::var\\(["']${varName}["']\\)`, 'g'), + + // Python patterns + new RegExp(`os\\.environ\\[["']${varName}["']\\]`, 'g'), + new RegExp(`os\\.getenv\\(["']${varName}["']\\)`, 'g'), + + // Config file string references (case-sensitive exact match) + new RegExp(`['"]${varName}['"]`, 'g'), + + // YAML/TOML environment variable references + new RegExp(`\\$\\{${varName}\\}`, 'g'), + new RegExp(`\\$${varName}\\b`, 'g') + ]; + + for (const filePath of files) { + const content = readFile(filePath); + if (!content) continue; + + for (const pattern of patterns) { + const matches = content.matchAll(pattern); + for (const match of matches) { + const lineNumber = content.substring(0, match.index).split('\n').length; + usages.push({ + file: filePath, + line: lineNumber, + context: getLineContext(content, lineNumber) + }); + } + } + } + + return usages; +} + +function getLineContext(content, lineNumber, contextLines = 0) { + const lines = content.split('\n'); + const index = lineNumber - 1; + + if (contextLines === 0) { + return lines[index]?.trim() || ''; + } + + const start = Math.max(0, index - contextLines); + const end = Math.min(lines.length, index + contextLines + 1); + + return lines.slice(start, end).map(l => l.trim()).join(' ... '); +} + +// ============================================================================ +// Allowlist Management +// ============================================================================ + +function loadAllowlist() { + const allowlistPath = path.resolve(ROOT_DIR, CONFIG.allowlistFile); + const allowlist = new Map(); // varName -> reason + + if (!fs.existsSync(allowlistPath)) { + return allowlist; + } + + const content = fs.readFileSync(allowlistPath, 'utf-8'); + const lines = content.split('\n'); + + let currentVar = null; + let currentReason = ''; + + for (const line of lines) { + const trimmed = line.trim(); + + // Skip empty lines + if (!trimmed) { + if (currentVar) { + allowlist.set(currentVar, currentReason.trim()); + currentVar = null; + currentReason = ''; + } + continue; + } + + // Comment lines starting with # are reasons + if (trimmed.startsWith('#')) { + currentReason += trimmed.substring(1).trim() + ' '; + continue; + } + + // Variable names + if (trimmed.match(/^[A-Z_][A-Z0-9_]*$/)) { + if (currentVar) { + allowlist.set(currentVar, currentReason.trim()); + } + currentVar = trimmed; + currentReason = ''; + } + } + + // Handle last entry + if (currentVar) { + allowlist.set(currentVar, currentReason.trim()); + } + + return allowlist; +} + +// ============================================================================ +// Reporting +// ============================================================================ + +function printHeader() { + console.log(''); + console.log(colorize('━'.repeat(80), colors.cyan)); + console.log(colorize(' Configuration Drift Detection Report', colors.bold + colors.cyan)); + console.log(colorize('━'.repeat(80), colors.cyan)); + console.log(''); +} + +function printSection(title) { + console.log(''); + console.log(colorize(`▶ ${title}`, colors.bold + colors.blue)); + console.log(colorize('─'.repeat(80), colors.gray)); +} + +function printVariable(varInfo) { + console.log(` ${colorize(varInfo.name, colors.bold + colors.yellow)}`); + console.log(` ${colorize('Source:', colors.gray)} ${varInfo.source}:${varInfo.line}`); +} + +function printUsage(usage) { + console.log(` ${colorize('→', colors.green)} ${usage.file}:${usage.line}`); + if (usage.context) { + console.log(` ${colorize(usage.context.substring(0, 70), colors.gray)}`); + } +} + +function printSummary(stats) { + console.log(''); + console.log(colorize('━'.repeat(80), colors.cyan)); + console.log(colorize(' Summary', colors.bold + colors.cyan)); + console.log(colorize('━'.repeat(80), colors.cyan)); + console.log(''); + console.log(` Total documented variables: ${colorize(stats.total, colors.bold)}`); + console.log(` Variables with usage found: ${colorize(stats.used, colors.green)}`); + console.log(` Variables allowlisted: ${colorize(stats.allowlisted, colors.yellow)}`); + console.log(` Unreferenced variables: ${colorize(stats.unreferenced, colors.red)}`); + console.log(''); + + if (stats.unreferenced > 0) { + console.log(colorize(' ⚠ FAILED: Unreferenced configuration variables detected!', colors.bold + colors.red)); + console.log(''); + console.log(colorize(' Action Required:', colors.yellow)); + console.log(' 1. Remove unused variables from .env.example files'); + console.log(' 2. OR add them to .config-audit-ignore with a reason'); + console.log(''); + } else { + console.log(colorize(' ✓ PASSED: All documented variables are accounted for!', colors.bold + colors.green)); + console.log(''); + } +} + +// ============================================================================ +// Main Logic +// ============================================================================ + +async function main() { + printHeader(); + + // Step 1: Extract documented variables + printSection('Extracting Documented Configuration Variables'); + + const documentedVars = []; + + for (const envFile of CONFIG.envExampleFiles) { + if (fileExists(envFile)) { + const content = readFile(envFile); + const vars = extractFromEnvExample(content, envFile); + documentedVars.push(...vars); + console.log(` ${colorize('✓', colors.green)} Found ${vars.length} variables in ${envFile}`); + } else { + console.log(` ${colorize('○', colors.gray)} Skipped ${envFile} (not found)`); + } + } + + for (const docFile of CONFIG.docFiles) { + if (fileExists(docFile)) { + const content = readFile(docFile); + const vars = extractFromDocs(content, docFile); + documentedVars.push(...vars); + console.log(` ${colorize('✓', colors.green)} Found ${vars.length} variables in ${docFile}`); + } + } + + // Deduplicate variables + const uniqueVars = new Map(); + for (const varInfo of documentedVars) { + if (!uniqueVars.has(varInfo.name)) { + uniqueVars.set(varInfo.name, varInfo); + } + } + + console.log(''); + console.log(` Total unique variables: ${colorize(uniqueVars.size, colors.bold)}`); + + // Step 2: Scan codebase for files + printSection('Scanning Codebase'); + + let allFiles = []; + for (const scanDir of CONFIG.scanDirs) { + const files = getAllFiles(scanDir, CONFIG.scanExtensions, CONFIG.ignoreDirs); + allFiles = allFiles.concat(files); + console.log(` ${colorize('✓', colors.green)} Scanned ${files.length} files in ${scanDir}`); + } + + console.log(''); + console.log(` Total files to scan: ${colorize(allFiles.length, colors.bold)}`); + + // Step 3: Load allowlist + printSection('Loading Allowlist'); + + const allowlist = loadAllowlist(); + console.log(` ${colorize('✓', colors.green)} Loaded ${allowlist.size} allowlisted variables`); + + if (allowlist.size > 0) { + for (const [varName, reason] of allowlist.entries()) { + console.log(` - ${colorize(varName, colors.yellow)}: ${reason || 'No reason provided'}`); + } + } + + // Step 4: Check usage + printSection('Checking Variable Usage'); + + const usedVars = []; + const unusedVars = []; + const allowlistedVars = []; + + for (const [varName, varInfo] of uniqueVars.entries()) { + process.stdout.write(` Checking ${varName}... `); + + if (allowlist.has(varName)) { + console.log(colorize('ALLOWLISTED', colors.yellow)); + allowlistedVars.push({ ...varInfo, reason: allowlist.get(varName) }); + continue; + } + + const usages = findVariableUsage(varName, allFiles); + + if (usages.length > 0) { + console.log(colorize(`USED (${usages.length} references)`, colors.green)); + usedVars.push({ ...varInfo, usages }); + } else { + console.log(colorize('UNREFERENCED', colors.red)); + unusedVars.push(varInfo); + } + } + + // Step 5: Detailed reporting + if (unusedVars.length > 0) { + printSection('Unreferenced Variables (Not Allowlisted)'); + + for (const varInfo of unusedVars) { + printVariable(varInfo); + console.log(''); + } + + console.log(colorize(' These variables should be removed or added to .config-audit-ignore', colors.yellow)); + } + + if (process.env.VERBOSE === 'true') { + printSection('Used Variables (Sample)'); + + for (const varInfo of usedVars.slice(0, 5)) { + printVariable(varInfo); + console.log(colorize(' Usage:', colors.gray)); + for (const usage of varInfo.usages.slice(0, 3)) { + printUsage(usage); + } + if (varInfo.usages.length > 3) { + console.log(` ${colorize(`... and ${varInfo.usages.length - 3} more`, colors.gray)}`); + } + console.log(''); + } + } + + // Step 6: Summary and exit + const stats = { + total: uniqueVars.size, + used: usedVars.length, + allowlisted: allowlistedVars.length, + unreferenced: unusedVars.length + }; + + printSummary(stats); + + // Exit with appropriate code + if (unusedVars.length > 0) { + process.exit(1); + } else { + process.exit(0); + } +} + +// ============================================================================ +// Entry Point +// ============================================================================ + +main().catch(error => { + console.error(colorize('Fatal Error:', colors.red), error.message); + console.error(error.stack); + process.exit(2); +});